{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://tamarind.bio/schemas/pipeline-v1.json",
  "title": "Pipeline",
  "description": "Schema for a Tamarind pipeline template (v1.0).",
  "type": "object",
  "additionalProperties": false,
  "required": ["nodes"],
  "properties": {
    "schema_version": { "const": "1.0" },
    "nodes": {
      "description": "Map keyed by node id. Object keys are unique by language semantics, so node-id uniqueness is structurally enforced.",
      "type": "object",
      "propertyNames": { "type": "string", "minLength": 1 },
      "additionalProperties": { "$ref": "#/$defs/Node" }
    },
    "metadata": { "type": "object" }
  },

  "$defs": {
    "Node": {
      "type": "object",
      "discriminator": { "propertyName": "kind" },
      "required": ["kind"],
      "oneOf": [
        { "$ref": "#/$defs/UserInputNode" },
        { "$ref": "#/$defs/ToolNode" },
        { "$ref": "#/$defs/FilterNode" },
        { "$ref": "#/$defs/ContainerNode" }
      ]
    },

    "UserInputNode": {
      "description": "A source vertex — an EMPTY input SLOT. flow='molecule' declares it emits a MolDB group; flow='file' a single file. The slot carries NO binding: which molecule/file fills it is the RUN's data (root_input), not the template's, so the slot stays empty and downstream edges are typed while unbound.",
      "type": "object",
      "additionalProperties": false,
      "required": ["kind"],
      "properties": {
        "kind": { "const": "user_input" },
        "label": { "type": "string" },
        "flow": {
          "description": "What this input emits: a MolDB molecule group, or a single file. Default 'molecule'.",
          "enum": ["molecule", "file"],
          "default": "molecule"
        },
        "molecule_type": {
          "description": "The molecule class this input expects (molecule flow only). A submitter must supply a molecule of this class. Absent ⇒ 'protein'.",
          "enum": ["protein", "small_molecule", "nucleic_acid"],
          "default": "protein"
        },
        "contains_structure": {
          "description": "Whether this input carries a 3D structure (protein inputs). When true, a submitter must supply a structure (pdb/cif), not just a sequence.",
          "type": "boolean"
        },
        "description": {
          "description": "Optional instructions shown to whoever supplies this input when configuring a run.",
          "type": "string",
          "maxLength": 280
        },
        "chain_labels": {
          "description": "Maker's free-text description per REFERENCE chain label (e.g. {\"A\": \"heavy chain\"}), shown to a submitter so they know which of their molecule's chains maps onto each reference chain.",
          "type": "object",
          "additionalProperties": { "type": "string" }
        },
        "metadata": { "type": "object" }
      }
    },

    "ToolNode": {
      "description": "A tool. 1-2 mol-group input slots; emits one mol-group.",
      "type": "object",
      "additionalProperties": false,
      "required": ["kind", "tool", "inputs"],
      "properties": {
        "kind": { "const": "tool" },
        "tool": {
          "description": "Tool URI: bare name | tamarind:// | mcp:// | user:, each with optional @version.",
          "type": "string",
          "pattern": "^([a-z][a-z0-9_-]*(@[a-zA-Z0-9._-]+)?|tamarind://[a-z][a-z0-9_-]*(@[a-zA-Z0-9._-]+)?|mcp://[a-zA-Z][a-zA-Z0-9.-]*/[a-z][a-z0-9_-]*|user:[a-zA-Z0-9][a-zA-Z0-9._-]*/[a-z][a-z0-9_-]*(@[a-zA-Z0-9]+)?)$"
        },
        "inputs": {
          "type": "object",
          "additionalProperties": { "$ref": "#/$defs/InputPort" }
        },
        "combination_mode": {
          "description": "PAIR (2-input tools): how the two inputs combine. 'all' = cartesian, '1:1' = matched.",
          "enum": ["all", "1:1"]
        },
        "combination_primary_field": {
          "description": "PAIR: the input port the combined pose attaches to (e.g. 'ligandFile' for docking). Optional; defaults to the first port.",
          "type": "string"
        },
        "container": {
          "description": "Id of the container node this tool is a member of (grouping tag). Optional; absent = not in a container.",
          "type": "string",
          "minLength": 1
        },
        "settings": {
          "type": "object",
          "propertyNames": { "not": { "const": "_max_runtime_seconds" } }
        },
        "execution": {
          "description": "Platform execution policy for this tool node (not worker Sequence settings).",
          "type": "object",
          "additionalProperties": false,
          "required": ["maxRuntimeSeconds"],
          "properties": {
            "maxRuntimeSeconds": {
              "type": "integer",
              "minimum": 60,
              "maximum": 9007199254740991
            }
          }
        },
        "metadata": { "type": "object" }
      }
    },

    "FilterNode": {
      "description": "A flow primitive: gathers its sources (union by default, or intersect with `intersect:true`), then narrows by ordered jsonlogic/top_k rules. Many filter nodes can read one source — that's how the same output splits into different filtered branches. Not a science tool; the orchestrator runs it as an inline transform.",
      "type": "object",
      "additionalProperties": false,
      "required": ["kind", "inputs"],
      "properties": {
        "kind": { "const": "filter" },
        "label": { "type": "string" },
        "inputs": {
          "type": "object",
          "additionalProperties": { "$ref": "#/$defs/InputPort" }
        },
        "intersect": {
          "description": "Intersect the input's sources by canonical MolDB id. Absent = union (the no-op default). `true` is the only value — union is the zero.",
          "const": true
        },
        "filters": { "type": "array", "items": { "$ref": "#/$defs/Filter" } },
        "container": {
          "description": "Id of the container node this filter is a member of (grouping tag). Optional; absent = not in a container.",
          "type": "string",
          "minLength": 1
        },
        "metadata": { "type": "object" }
      },
      "allOf": [
        {
          "description": "An intersect over a single source is meaningless — require >=2 sources in every port.",
          "if": { "properties": { "intersect": { "const": true } }, "required": ["intersect"] },
          "then": { "properties": { "inputs": { "additionalProperties": { "minItems": 2 } } } }
        },
        {
          "description": "A filter must do something: intersect, or carry at least one narrowing rule.",
          "anyOf": [
            { "properties": { "intersect": { "const": true } }, "required": ["intersect"] },
            { "required": ["filters"], "properties": { "filters": { "minItems": 1 } } }
          ]
        }
      ]
    },

    "FilterFieldRef": {
      "type": "object",
      "additionalProperties": false,
      "required": ["producerNodeId", "columnName"],
      "properties": {
        "producerNodeId": { "type": "string", "minLength": 1 },
        "columnName": { "type": "string", "minLength": 1 }
      }
    },

    "ContainerNode": {
      "description": "A named group of member nodes presenting one shared input pool and one shared output. Members are tagged `container: <this id>`. `inputs.pool` holds the sources dragged into the container; each member binds a rep-compatible subset (auto-inferred, overridable). A downstream ref to this container's id means the UNION of every member's output. Containers are authoring/API constructs: expanded to plain member refs before the execution shred, so the orchestrator never sees them. `metadata.memberOrder` (optional string[]) orders members on the canvas.",
      "type": "object",
      "additionalProperties": false,
      "required": ["kind"],
      "properties": {
        "kind": { "const": "container" },
        "label": { "type": "string" },
        "inputs": {
          "description": "Shared input pool. Conventionally a single 'pool' port; each entry is a source available to every member.",
          "type": "object",
          "additionalProperties": { "$ref": "#/$defs/InputPort" }
        },
        "metadata": { "type": "object" }
      }
    },

    "InputPort": {
      "description": "An input slot: the (non-empty, unique) list of upstream sources feeding it. Pure topology — slots always union; intersect/narrow live on the filter node.",
      "type": "array",
      "minItems": 1,
      "uniqueItems": true,
      "items": { "$ref": "#/$defs/NodeRef" }
    },

    "NodeRef": {
      "type": "object",
      "additionalProperties": false,
      "required": ["node"],
      "properties": {
        "node": {
          "description": "Id of the upstream node whose output feeds this slot.",
          "type": "string",
          "minLength": 1
        },
        "select": { "$ref": "#/$defs/Select" }
      }
    },

    "Select": {
      "description": "Within-group slice: which chain(s) of the wired molecule enter this port. `chains` is ALWAYS a list of reference labels (one for a single-chain pick, many for a multimer subset) — a single closed shape, no string-vs-list ambiguity. The old scalar `chain` form is unrepresentable (additionalProperties:false). The submit resolver rewrites these reference labels into the submitter's own labels.",
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "chains": {
          "type": "array",
          "items": { "type": "string" }
        }
      }
    },

    "Filter": {
      "description": "An ordered output-narrowing step: a predicate or a ranked cut.",
      "type": "object",
      "discriminator": { "propertyName": "kind" },
      "required": ["kind"],
      "oneOf": [
        { "$ref": "#/$defs/JsonLogicFilter" },
        { "$ref": "#/$defs/TopKFilter" }
      ]
    },

    "JsonLogicFilter": {
      "type": "object",
      "additionalProperties": false,
      "required": ["kind", "rule"],
      "properties": {
        "kind": { "const": "jsonlogic" },
        "rule": { "$ref": "#/$defs/JsonLogicRule" }
      }
    },

    "TopKFilter": {
      "description": "Ranked cut over the node's own output. Valid only on tools with a rankable score.",
      "type": "object",
      "additionalProperties": false,
      "required": ["kind", "k", "rank_by"],
      "properties": {
        "kind": { "const": "top_k" },
        "k": { "type": "integer", "minimum": 1 },
        "rank_by": { "$ref": "#/$defs/RankBy" },
        "group_by": { "$ref": "#/$defs/FilterFieldRef" }
      }
    },


    "RankBy": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["field", "order"],
        "properties": {
          "field": { "$ref": "#/$defs/FilterFieldRef" },
          "order": { "enum": ["asc", "desc"] }
        }
      }
    },

    "JsonLogicRule": {
      "description": "A JsonLogic expression. Recursive: leaves are literals or {\"var\": {\"producerNodeId\": \"node\", \"columnName\": \"column\"}}; composites are {op: [operands]}.",
      "anyOf": [
        { "type": ["boolean", "number", "string", "null"] },
        { "type": "array", "items": { "$ref": "#/$defs/JsonLogicRule" } },
        { "type": "object" }
      ]
    }
  }
}
