Composition Graphs (Lagrange JSON)¶
A composition graph is a JSON document that describes how stages connect. Noether calls this format Lagrange — named after Joseph-Louis Lagrange, whose formalism (the Lagrangian) is central to Emmy Noether's theorem: just as the Lagrangian describes a physical system and Noether's theorem derives its conservation laws, a Lagrange graph describes a computation and Noether's type checker derives its correctness guarantees.
Operators¶
There are 9 operators:
op | Description |
|---|---|
Stage | Run a single stage |
Sequential | Run steps in order, each feeds the next |
Parallel | Run branches concurrently, merge outputs |
Branch | Conditional — choose one path at runtime |
Fanout | Broadcast one input to multiple stages |
Merge | Combine multiple inputs into one record |
Retry | Wrap any node with retry-on-failure |
Const | Inject a literal constant value |
Let | Bind named sub-results and reference them in a body — solves the "carry an input field through a Sequential" problem |
RemoteStage | Call a remote Noether HTTP endpoint |
Field-naming note. Two operators that take child nodes use different shapes — this is intentional but trips up first-time graph authors:
Operator Field Shape Why Sequentialstagesarray order matters — pipeline order is preserved Parallelbranchesobject each child needs a name so its output can be addressed in the merged Record Fanouttargetsarray broadcast — order does not matter, but a list is the natural shape Mergesourcesarray union of inputs into a single target Stage IDs may be hand-authored as 8-character prefixes (the same form
noether stage listprints). The CLI resolves prefixes to the full 64-character SHA-256 at load time; ambiguous prefixes produce a clear error listing the matches.
Stage¶
Input is the output of the previous step (or the graph's top-level input if first). The optional config field merges static parameters into the input record:
Sequential¶
{
"op": "Sequential",
"stages": [
{ "op": "Stage", "id": "abc…" },
{ "op": "Stage", "id": "def…" }
]
}
stages[0] output → stages[1] input → … → last stage output is the Sequential's output.
Limitation — no field projection inside Sequential.
Sequentialpasses the complete output record of each stage as the input to the next. There is no inline$input.*reference syntax. To carry a field from the original input into a later stage, wrap the chain in aLet(see below) or include the field in every intermediate stage's output as a passthrough.
Const¶
Inject a literal value anywhere in the graph:
{
"op": "Sequential",
"stages": [
{ "op": "Const", "value": { "url": "https://api.example.com/data" } },
{ "op": "Stage", "id": "<http_get-hash>" }
]
}
Parallel¶
Run named branches concurrently. Each branch receives input[branch_name] if the input is a Record containing that key; otherwise it receives the full input. Outputs are merged into a single Record keyed by branch name.
{
"op": "Parallel",
"branches": {
"left": { "op": "Stage", "id": "branch-a-hash" },
"right": { "op": "Stage", "id": "branch-b-hash" }
}
}
The output is {"left": <branch-a-output>, "right": <branch-b-output>}.
Branch¶
{
"op": "Branch",
"predicate": { "op": "Stage", "id": "<bool-producing-stage>" },
"if_true": { "op": "Stage", "id": "<then-stage>" },
"if_false": { "op": "Stage", "id": "<else-stage>" }
}
Retry¶
{
"op": "Retry",
"stage": { "op": "Stage", "id": "<flaky-stage>" },
"max_attempts": 3,
"delay_ms": 1000
}
Let¶
Bind named sub-results and reference them in a body. The classic use case is the scan → hash → diff pattern, where diff needs a field (state_path) from the original input that hash would otherwise erase.
{
"op": "Let",
"bindings": {
"scan": { "op": "Stage", "id": "<scan-stage>" },
"hash": { "op": "Sequential", "stages": [
{ "op": "Stage", "id": "<scan-stage>" },
{ "op": "Stage", "id": "<hash-stage>" }
]}
},
"body": { "op": "Stage", "id": "<diff-stage>" }
}
Semantics:
- Every binding sub-graph receives the outer Let input (the same value given to the Let node). Bindings run concurrently — they cannot see each other's results. If you need ordering, wrap the chain in a
Sequentialinside the binding (ashashdoes above). - After bindings complete, the
bodyruns against an augmented record:{ ...outer-input fields, "scan": <scan output>, "hash": <hash output> }. A binding name shadows an outer-input field with the same name. - The Let's output is the body's output.
The type checker treats Let as you'd expect: each binding is checked against the outer input; the body is checked against the augmented record; the Let's overall input requirement is the union of every binding's input fields and every body field that isn't satisfied by a binding output.
Composition ID¶
The CompositionId is the SHA-256 of the canonical JSON of the root node. The same graph always gets the same ID on any machine — traces are keyed by this ID.
Type checking¶
noether run --dry-run graph.json type-checks every edge before execution. A type error looks like:
{
"ok": false,
"error": {
"code": "TYPE_ERROR",
"message": "edge from Stage(abc) to Stage(def): output Record{url,status} is not subtype of input Record{url,body}"
}
}
The type checker uses structural subtyping — width subtyping means extra fields are always fine.