Files
Aura/docs/specs/0027-name-input-ports.md
T
Brummel 55cfb29c74 spec: 0027 name input ports
Give a node's input ports a name. PortSpec { kind, firing } is the lone unnamed
member of the node signature today (FieldSpec.name / ParamSpec.name already
exist); a leaf node's input slots have no declared semantics. Add a
non-load-bearing name: String to PortSpec (drops Copy, mirrors ParamSpec), name
every input slot across aura-std nodes + fixtures, carry Role.name into
derive_signature's derived composite port (it is dropped today), and make
model_to_json emit the name in each port tuple. Identity stays positional by
slot (C23); the name is render/debug only, never read by bootstrap or the run
loop.

Surfaced by the 0026 graph-render redesign: a homogeneous pin-graph needs the
name to label input pins instead of inventing "a"/"b". Single iteration.

Design ratified interactively (brainstorm); grounding-check PASS (every
load-bearing current-state assumption ratified by a named green test); parse
gate a no-op (no spec_validation parser configured). Forks resolved: name is a
pure debug symbol, not wire-by-name (C23); String not &'static str (variadic
nodes generate term[i]/col[i]); no bootstrap validation this cycle.

refs #21
refs #50
2026-06-10 15:53:46 +02:00

13 KiB

Name Input Ports — Design Spec

Date: 2026-06-10 Status: Draft — awaiting user spec review Authors: orchestrator + Claude

Goal

Give a node's input ports a name. Today PortSpec { kind, firing } (crates/aura-core/src/node.rs:33) carries no name, so a leaf node's input slots have no declared semantics — the meaning of "slot 0 vs slot 1" lives only in eval. Output fields (FieldSpec.name) and tunable params (ParamSpec.name) are already named; inputs are the lone unnamed member of the node signature.

The gap surfaced in the graph-render redesign (cycle 0026): a homogeneous pin-graph wants to label every input pin, but the data has no name to show — the 0026 prototype invented "a"/"b", exactly what "invent nothing" forbids. This cycle closes the gap by adding a non-load-bearing name to PortSpec, mirroring FieldSpec.name / ParamSpec.name precisely, and threading it into the read-only graph model so the viewer (0026 iteration 2, #51) can label input pins faithfully.

This unblocks #51. It does not close #21 (the SimBroker exposure/price swap footgun) — see Non-goals.

Architecture

One contract change, in the existing non-load-bearing-name mold:

  • PortSpec gains name: String. Identity stays positional by slot (C23); bootstrap and the run loop never read the name. The name is a debug/render symbol only — exactly the role FieldSpec.name and ParamSpec.name already play.
  • Every PortSpec construction site supplies a name. Fixed-arity nodes write a role-descriptive name by hand; the two variadic nodes (LinComb, Recorder) generate an indexed name in their build loop — the same pattern LinComb already uses for its weights[i] params.
  • The name flows only into the render path: model_to_json (crates/aura-engine/src/graph_model.rs, shipped in 0026 iteration 1) appends the input name to each port's model entry. The retired-soon ascii-dag renderer (crates/aura-cli/src/graph.rs) is not touched — it already ignores schema().inputs and is removed in 0026 iteration 2.
  • The composite boundary already holds a real name (Role.name, blueprint.rs:110) that derive_signature currently drops (it has no field to put it in). With PortSpec.name in place, derive_signature carries Role.name into the derived input port, restoring the symmetry the output side already has. So the model is homogeneously named at both levels: leaf ports from the new PortSpec.name, composite ports inherited from Role.name.

Why the name must be a String, not &'static str

FieldSpec.name is &'static str because a node's outputs are always a fixed, statically-written list. Input ports are not always static: LinComb and Recorder build a variadic number of ports in a loop and must generate names like term[0], term[1]. This is the identical reason ParamSpec.name is a String (a vector knob carries a runtime index, weights[0]). So PortSpec.name follows ParamSpec, not FieldSpec: a String. A direct consequence is that PortSpec loses Copy (a String is not Copy) and becomes Clone-only — again exactly as ParamSpec already is, and for the same reason.

Concrete code shapes

The authoring surface (what a node author writes)

A node author declaring a node's signature now names each input slot. The SimBroker recipe is the sharpest example — its two f64 inputs become self-documenting:

// crates/aura-std/src/sim_broker.rs — before
NodeSchema {
    inputs: vec![
        PortSpec { kind: ScalarKind::F64, firing: Firing::Any }, // 0 exposure
        PortSpec { kind: ScalarKind::F64, firing: Firing::Any }, // 1 price
    ],
    output: vec![FieldSpec { name: "equity", kind: ScalarKind::F64 }],
    params: vec![],
}

// after — the slot role moves from a line comment into the data
NodeSchema {
    inputs: vec![
        PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "exposure".into() },
        PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() },
    ],
    output: vec![FieldSpec { name: "equity", kind: ScalarKind::F64 }],
    params: vec![],
}

A variadic node generates the names in its loop, mirroring its own param loop:

// crates/aura-std/src/lincomb.rs — after (the param loop already does weights[i])
let inputs = (0..arity)
    .map(|i| PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: format!("term[{i}]") })
    .collect();
let params = (0..arity)
    .map(|i| ParamSpec { name: format!("weights[{i}]"), kind: ScalarKind::F64 })
    .collect();
// crates/aura-std/src/recorder.rs — after (.enumerate() to index the generated name)
let inputs = kinds
    .iter()
    .enumerate()
    .map(|(i, &kind)| PortSpec { kind, firing, name: format!("col[{i}]") })
    .collect();

The ratified per-node slot names (the node author's call per node; principle: name the slot's role):

Node Input slot names
SMA, EMA series
Sub lhs, rhs
Add lhs, rhs
Exposure signal
SimBroker exposure, price
LinComb (variadic) term[i]
Recorder (variadic) col[i]

The consumer surface (what the render model now shows)

model_to_json is the read-only serializer the browser viewer consumes. A port entry gains a third element — the name — appended to the existing [kind, firing] tuple:

// a SimBroker primitive node in the model — before
{"prim":{"type":"SimBroker","role":"node","params":[],
  "ins":[["f64","any"],["f64","any"]],"outs":[["equity","f64"]]}}

// after — the input pins are now named, invented nothing
{"prim":{"type":"SimBroker","role":"node","params":[],
  "ins":[["f64","any","exposure"],["f64","any","price"]],"outs":[["equity","f64"]]}}

A composite's derived input ports carry the boundary Role.name the same way:

// a composite's input ports in the model — before / after
"inputs":[["f64","any"]]            // before: name dropped
"inputs":[["f64","any","price"]]    // after: Role.name carried through

The contract change (secondary — implementation shape)

// crates/aura-core/src/node.rs — before
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PortSpec {
    pub kind: ScalarKind,
    pub firing: Firing,
}

// after — Copy dropped (String is not Copy), name added; doc mirrors ParamSpec
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PortSpec {
    pub kind: ScalarKind,
    pub firing: Firing,
    /// A **non-load-bearing** debug symbol (C23): identity is the positional slot,
    /// the name is for tracing / graph rendering (#13), never read by bootstrap or
    /// the run loop (which wire by index). A `String`, not `&'static str` like
    /// `FieldSpec.name`: a variadic node generates per-slot names (`term[0]`).
    pub name: String,
}
// crates/aura-engine/src/blueprint.rs derive_signature ~line 76 — before / after
// before: the boundary name is dropped (no field to hold it)
PortSpec { kind, firing: Firing::Any }
// after: Role.name carried into the derived port (symmetry with the output side,
// which already does FieldSpec { name: leak_name(&of.name), kind } at line 84)
PortSpec { kind, firing: Firing::Any, name: role.name.clone() }
// crates/aura-engine/src/graph_model.rs port_json ~line 51 — before / after
// before
format!("[{},{}]", json_str(kind_str(p.kind)), json_str(&firing_str(p.firing)))
// after — append the name as a third tuple element
format!("[{},{},{}]", json_str(kind_str(p.kind)), json_str(&firing_str(p.firing)), json_str(&p.name))

Components

  • crates/aura-core/src/node.rsPortSpec gains name: String; the #[derive(...)] drops Copy; the doc comment states the name is non-load-bearing, mirroring the ParamSpec wording already in this file.
  • crates/aura-std/src/*.rs — every node names its input slots: sma.rs, ema.rs (series), sub.rs, add.rs (lhs/rhs), exposure.rs (signal), sim_broker.rs (exposure/price), lincomb.rs (term[i] via loop), recorder.rs (col[i] via .enumerate() loop).
  • crates/aura-engine/src/blueprint.rsderive_signature carries Role.name into the derived input PortSpec.
  • crates/aura-engine/src/graph_model.rsport_json appends the name; the byte golden updates.
  • Test fixtures that construct PortSpec — engine helpers (harness.rs, blueprint.rs), aura-cli/src/main.rs, aura-engine/src/report.rs test data, aura-ingest/tests/real_bars.rs, graph_model.rs test fixture: each supplies a name (a fixture name is fine — these are not user-facing).
  • docs/design/ ledger — a "Realization (cycle 0027)" note on the C8/C23 contract that input ports are now named (non-load-bearing), analogous to the existing FieldSpec realization note.

Data flow

author writes node builder  ──>  NodeSchema.inputs: Vec<PortSpec{ kind, firing, name }>
                                        │
blueprint holds the recipe (value-empty, C19)
                                        │
model_to_json walks the blueprint (read-only, C9)
  · leaf primitive port  ──>  ["f64","any","exposure"]      (PortSpec.name)
  · composite input port ──>  ["f64","any","price"]         (Role.name via derive_signature)
                                        │
(downstream, #51) viewer reads the name as the pin label

bootstrap + run loop: name never read — wiring is by slot index (C23)

Error handling

No new error path. The name is non-load-bearing: there is no validation, no new error variant, no bootstrap check against it. (A name-consuming validation — e.g. a checked expect_name at the wiring site that would catch the #21 swap — is the explicitly deferred follow-up; see Non-goals.) The only mechanical ripple is the Copy drop: any site that copied a PortSpec by value now clones or borrows. Bootstrap already borrows (signatures.get(t.node)...inputs.get(t.slot) then reads .kind), so the hot path is unaffected.

Non-goals

  • #21 is not closed. SimBroker's two f64 inputs become self-documenting (the names exposure/price now live in the data and render), but a swapped wiring is still only caught by the kind-check, which cannot distinguish two f64 slots. Catching the swap structurally needs a name-consuming validation axis (a checked expect_name at the wiring site, or distinct newtypes) that touches the wiring API and C23 — its own cycle, now better-equipped because the names exist to validate against. This cycle refs #21, does not close it.
  • Wire-by-name is not introduced. Names stay non-load-bearing; wiring resolves by index (C23). Authoring an edge by port name would be a C23 contract change — out of scope.
  • The ascii-dag renderer is not changed, and the old render goldens are not retired — that is 0026 iteration 2 (#51).

Testing strategy

  • aura-core (node.rs): a test that a PortSpec carries its name (extend port_spec_carries_firing or add a sibling).
  • aura-std, per node: a test pinning the declared input-slot names — especially sim_broker asserting ["exposure", "price"] (the #21 self-documentation), and lincomb / recorder asserting the generated term[i] / col[i] sequence for a sample arity.
  • graph_model: the byte golden updates to show real names in every ins tuple; the determinism test stays green; a test asserting a named leaf input appears in the model; a test asserting a composite's derived input carries the Role.name.
  • Gates: cargo test --workspace green (no regression — every existing golden that prints a port updates in the same change); cargo clippy --workspace --all-targets -- -D warnings clean.

Acceptance criteria

Judged against aura's feature-acceptance criterion (audience reaches for it; improves correctness / removes redundancy; reintroduces no failure class the core constraints eliminate):

  1. Audience reaches for it. A strategy author inspecting their graph asks "what is this input pin?"; the homogeneous render can now answer with the declared name instead of a positional index or an invented letter. The 0026 render redesign is blocked on exactly this (#51).
  2. Removes redundancy / improves correctness of the render. The render stops inventing input names (the prototype's a/b); it mirrors the data. The model is homogeneously named across all three signature members (inputs/outputs/params) and across both graph levels (leaf + composite boundary).
  3. Reintroduces no eliminated failure class. The name is non-load-bearing (C23): wiring stays positional, no by-name resolution enters the engine. C9 (model_to_json read-only), C14 (no serde), and C4 (scalar kinds unchanged) are untouched. This is a conforming refinement of C8/C23, not a contract break.
  4. Concretely: PortSpec { kind, firing, name }; every aura-std node and every fixture names its input slots; model_to_json emits ["f64","any","exposure"]-shaped port entries for leaf ports and Role.name-derived entries for composite ports; the byte golden reflects it; workspace tests and clippy are green.