Files
Aura/docs/plans/0027-name-input-ports.md
T
Brummel 5288249b44 plan: 0027 name input ports
Four tasks for cycle 0027 (spec docs/specs/0027-name-input-ports.md):
1. Add name: String to PortSpec (drop Copy) and thread all 16 construction
   sites in one compile-gate task — aura-std nodes get real slot names, fixtures
   get fixture names, derive_signature carries Role.name; port_json untouched so
   the byte golden stays green and the full suite passes.
2. port_json appends the name; re-capture the model byte golden and update its
   two substring twins (sink_has_empty_outs witnesses a leaf PortSpec.name in the
   model, multi_input_composite witnesses a composite Role.name carried through).
3. Lock the per-node input-slot names (8 aura-std pins).
4. C8 realization note in the design ledger (cross-links C23).

refs #50
2026-06-10 16:04:10 +02:00

16 KiB

Name Input Ports — Implementation Plan

Parent spec: docs/specs/0027-name-input-ports.md

For agentic workers: REQUIRED SUB-SKILL: use the implement skill to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Give PortSpec a non-load-bearing name: String, name every input slot across the aura-std nodes and fixtures, carry Role.name into the derived composite signature, and make model_to_json emit the name — so the graph model labels input pins faithfully (C23-clean; identity stays positional).

Architecture: One contract change in aura-core ripples to every PortSpec construction site (compiler-enforced). Task 1 adds the field and threads all 16 sites in one shot (the compile gate), leaving port_json untouched so the byte golden stays green. Task 2 flips port_json to emit the name and re-captures the golden + its two substring twins. Task 3 locks the per-node names. Task 4 records the realization in the ledger.

Tech Stack: Rust — crates/aura-core (the PortSpec contract), crates/aura-std (8 nodes), crates/aura-engine (derive_signature, graph_model, fixtures), crates/aura-cli + crates/aura-ingest (fixtures), docs/design/INDEX.md (ledger). Hand-rolled JSON, no serde (C14).

Files this plan creates or modifies:

  • Modify: crates/aura-core/src/node.rs:29-37PortSpec gains name, drops Copy, doc updated; test at :172-179.
  • Modify: crates/aura-std/src/sma.rs:31, ema.rs:63, sub.rs:27-28, add.rs:36-37, exposure.rs:32, sim_broker.rs:66-67, lincomb.rs:47-49, recorder.rs:41 — name input slots.
  • Modify: crates/aura-engine/src/blueprint.rs:76 (derive_signature carries role.name), :742, :842, :1938 (fixtures).
  • Modify: crates/aura-engine/src/graph_model.rs:50-53 (port_json), :252-253 (fixture), :422 (byte golden), :433 + :443 (substring pins — the leaf + composite model-name witnesses).
  • Modify: crates/aura-engine/src/harness.rs:362, :380 (fixtures); crates/aura-engine/src/report.rs:210; crates/aura-cli/src/main.rs:54; crates/aura-ingest/tests/real_bars.rs:26 (fixtures).
  • Test: per-node name pins in each aura-std node's #[cfg(test)] mod.
  • Modify: docs/design/INDEX.md:296 — C8 realization note (cross-link C23).

Task 1: Add name to PortSpec and thread every construction site

This is the compile-gate task: adding the field breaks all 16 construction sites workspace-wide, so they ALL land here. port_json is not touched (Task 2), so the model byte golden stays green and the whole suite passes at the end of this task.

Files:

  • Modify: crates/aura-core/src/node.rs

  • Modify: crates/aura-std/src/{sma,ema,sub,add,exposure,sim_broker,lincomb,recorder}.rs

  • Modify: crates/aura-engine/src/blueprint.rs, harness.rs, report.rs

  • Modify: crates/aura-cli/src/main.rs, crates/aura-ingest/tests/real_bars.rs

  • Modify: crates/aura-engine/src/graph_model.rs (the sub_builder test fixture only)

  • Step 1: Change the PortSpec struct — add name, drop Copy, update the doc

In crates/aura-core/src/node.rs, replace the doc block + struct at :29-37:

/// One declared input **port** of a node: its scalar kind, firing policy (C6), and
/// a non-load-bearing name. The lookback depth is NOT here — it is a build-time
/// *sizing* concern answered by `Node::lookbacks()`, not part of the static
/// signature (a node's lookback can depend on an injected param, e.g. `Sma`'s
/// window = its `length`). The `name` is 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]`), exactly as `ParamSpec.name` does (`weights[0]`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PortSpec {
    pub kind: ScalarKind,
    pub firing: Firing,
    pub name: String,
}
  • Step 2: Update the port_spec_carries_firing test to carry + assert the name

In crates/aura-core/src/node.rs, replace the test body at :172-179:

    #[test]
    fn port_spec_carries_firing() {
        let a = PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() };
        let b = PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0), name: "rhs".into() };
        assert_eq!(a.firing, Firing::Any);
        assert_eq!(b.firing, Firing::Barrier(0));
        assert_ne!(a.firing, b.firing);
        // the name is carried (non-load-bearing, but present)
        assert_eq!(a.name, "lhs");
        assert_eq!(b.name, "rhs");
    }
  • Step 3: Name the fixed-arity aura-std node input slots

Edit each construction expression, appending name:

crates/aura-std/src/sma.rs:31 — the single input becomes:

                inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }],

crates/aura-std/src/ema.rs:63 — identical, name: "series".into().

crates/aura-std/src/sub.rs:27-28 — the two inputs:

                inputs: vec![
                    PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() },
                    PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "rhs".into() },
                ],

crates/aura-std/src/add.rs:36-37 — the two inputs, identical names "lhs" / "rhs".

crates/aura-std/src/exposure.rs:32 — the single input: name: "signal".into().

crates/aura-std/src/sim_broker.rs:66-67 — the two inputs (the line comments move into the data):

                inputs: vec![
                    PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "exposure".into() },
                    PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() },
                ],
  • Step 4: Name the variadic aura-std node input slots (generated in the loop)

crates/aura-std/src/lincomb.rs:47-49 — change |_| to |i| and generate the name (mirrors the weights[i] param loop two lines below):

        let inputs = (0..arity)
            .map(|i| PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: format!("term[{i}]") })
            .collect();

crates/aura-std/src/recorder.rs:41 — add .enumerate() and generate the name:

        let inputs = kinds
            .iter()
            .enumerate()
            .map(|(i, &kind)| PortSpec { kind, firing, name: format!("col[{i}]") })
            .collect();
  • Step 5: Carry Role.name into the derived composite input port

crates/aura-engine/src/blueprint.rs:76 — inside derive_signature's .map(|role| { ... }), the input port stops dropping the boundary name (restores symmetry with the output side at :84, which already does FieldSpec { name: leak_name(&of.name), kind }):

            PortSpec { kind, firing: Firing::Any, name: role.name.clone() }
  • Step 6: Name the engine/cli/ingest test fixtures (fixture names are fine)

  • crates/aura-engine/src/blueprint.rs:742 (f64_any):

        fn f64_any() -> PortSpec {
            PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }
        }
    
  • crates/aura-engine/src/blueprint.rs:842 (sink_i64 input): PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "in".into() }.

  • crates/aura-engine/src/blueprint.rs:1938 (the exploding-builder fixture): PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "in".into() }.

  • crates/aura-engine/src/harness.rs:362 (f64_port): PortSpec { kind: ScalarKind::F64, firing, name: "in".into() } (keep its existing firing arg).

  • crates/aura-engine/src/harness.rs:380 (recorder_sig loop): mirror the real Recorder —

            kinds.iter().enumerate().map(|(i, &kind)| PortSpec { kind, firing, name: format!("col[{i}]") }).collect()
    
  • crates/aura-engine/src/report.rs:210: PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }.

  • crates/aura-cli/src/main.rs:54: aura_engine::PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() } (keep the aura_engine:: qualifier).

  • crates/aura-ingest/tests/real_bars.rs:26: PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }.

  • crates/aura-engine/src/graph_model.rs:252-253 (the sub_builder test fixture, used by Task 2's leaf-name test) — name its two ports:

                    PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() },
                    PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0), name: "rhs".into() },
    
  • Step 7: Build the workspace — confirm every site is threaded

Run: cargo build --workspace Expected: finishes with 0 errors (no missing field name in initializer of PortSpec and no Copy-move errors remain). If the compiler names any PortSpec literal still lacking name, add name: "in".into() (fixture) and rebuild.

  • Step 8: Run the full suite — confirm still green (port_json unchanged → golden intact)

Run: cargo test --workspace Expected: PASS, 0 failures. The model byte golden is unaffected because port_json still emits two-element tuples; no existing test asserts full PortSpec equality (the derive test checks only .len() and .kind).


Task 2: model_to_json emits the name — port_json + golden + twins

Files:

  • Modify: crates/aura-engine/src/graph_model.rs:50-53 (port_json), :422 (golden), :433 + :443 (substring pins)

  • Step 1: Append the name to port_json

crates/aura-engine/src/graph_model.rs:50-53 — rewrite the doc + body:

/// One input port as a model tuple: `["<kind>","<firing>","<name>"]`. The name is
/// the port's non-load-bearing label (`PortSpec.name`); for a composite boundary
/// it is the `Role.name` carried through `derive_signature`.
fn port_json(p: &aura_core::PortSpec) -> String {
    format!(
        "[{},{},{}]",
        json_str(kind_str(p.kind)),
        json_str(&firing_str(p.firing)),
        json_str(&p.name)
    )
}
  • Step 2: Run the golden test to see it go RED

Run: cargo test -p aura-engine model_golden Expected: FAIL — the emitted ins tuples now carry a third element, mismatching the stored golden at :422.

  • Step 3: Re-capture the byte golden

Follow the re-capture recipe already in the test header (graph_model.rs:419-421): run the print harness, copy the exact emitted JSON, and paste it as the new r##"..."## golden string at :422. Every "ins":[...] tuple gains its third element (leaf ports from PortSpec.name, e.g. ["f64","any","series"]; the composite "inputs":[["f64","any"]] becomes [["f64","any","price"]] from Role.name; the src_price source keeps its empty ins).

Run: cargo test -p aura-engine model_golden Expected: PASS (golden now matches the re-captured bytes).

  • Step 4: Update the two substring-pin twins

crates/aura-engine/src/graph_model.rs:433 (sink_has_empty_outs) — the Recorder sink's single input now carries col[0]. Replace the asserted substring:

        r#""role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]"#

crates/aura-engine/src/graph_model.rs:443 (multi_input_composite_has_two_inputs) — the two_input_composite() fixture's roles are named a/b, carried through derive_signature. Replace the starts_with prefix:

        r#"{"inputs":[["f64","any","a"],["f64","any","b"]],"#

This assertion now also witnesses the composite-boundary Role.name carry.

  • Step 5: Run the engine suite green

Run: cargo test -p aura-engine Expected: PASS, 0 failures (golden re-captured, both twins updated, determinism test still green). The two updated twins are exactly the spec's two model-name witnesses: sink_has_empty_outs pins a leaf PortSpec.name reaching the model (Recorder's col[0] — a leaf primitive), and multi_input_composite_has_two_inputs pins a composite boundary Role.name carried through derive_signature (a/b).


Task 3: Lock the per-node input-slot names (aura-std)

Each test pins the declared names so a future rename is a visible test change. Each goes in the node's existing #[cfg(test)] mod tests.

Files:

  • Test: crates/aura-std/src/{sma,ema,sub,add,exposure,sim_broker,lincomb,recorder}.rs

  • Step 1: Fixed-arity node name pins

Add to each node's test module:

sma.rs:

    #[test]
    fn input_slot_is_named_series() {
        assert_eq!(Sma::builder().schema().inputs[0].name, "series");
    }

ema.rs:

    #[test]
    fn input_slot_is_named_series() {
        assert_eq!(Ema::builder().schema().inputs[0].name, "series");
    }

sub.rs:

    #[test]
    fn input_slots_are_named_lhs_rhs() {
        let s = Sub::builder();
        let names: Vec<&str> = s.schema().inputs.iter().map(|p| p.name.as_str()).collect();
        assert_eq!(names, ["lhs", "rhs"]);
    }

add.rs:

    #[test]
    fn input_slots_are_named_lhs_rhs() {
        let a = Add::builder();
        let names: Vec<&str> = a.schema().inputs.iter().map(|p| p.name.as_str()).collect();
        assert_eq!(names, ["lhs", "rhs"]);
    }

exposure.rs:

    #[test]
    fn input_slot_is_named_signal() {
        assert_eq!(Exposure::builder().schema().inputs[0].name, "signal");
    }

sim_broker.rs (the #21 self-documentation win):

    #[test]
    fn input_slots_are_named_exposure_price() {
        let b = SimBroker::builder(1.0);
        let names: Vec<&str> = b.schema().inputs.iter().map(|p| p.name.as_str()).collect();
        assert_eq!(names, ["exposure", "price"]);
    }
  • Step 2: Variadic node name pins (generated sequence)

lincomb.rs:

    #[test]
    fn input_slots_are_named_term_index() {
        let lc = LinComb::builder(3);
        let names: Vec<String> = lc.schema().inputs.iter().map(|p| p.name.clone()).collect();
        assert_eq!(names, ["term[0]", "term[1]", "term[2]"]);
    }

recorder.rs (build a throwaway channel as the existing recorder tests do):

    #[test]
    fn input_slots_are_named_col_index() {
        let (tx, _rx) = std::sync::mpsc::channel();
        let r = Recorder::builder(vec![ScalarKind::F64, ScalarKind::F64], Firing::Any, tx);
        let names: Vec<String> = r.schema().inputs.iter().map(|p| p.name.clone()).collect();
        assert_eq!(names, ["col[0]", "col[1]"]);
    }

ScalarKind and Firing are already in scope in recorder.rs's test module (the existing test at :91 constructs Recorder::builder(vec![ScalarKind::F64], Firing::Any, …)), so no new import is needed.

  • Step 3: Run aura-std + aura-core green

Run: cargo test -p aura-std -p aura-core Expected: PASS, 0 failures — all 8 node name pins plus the port_spec_carries_firing name assertion (Task 1) green.


Task 4: Record the realization in the design ledger

Files:

  • Modify: docs/design/INDEX.md:296 (C8 section, after the cycle-0024 note, before ### C9)

  • Step 1: Insert the C8 realization note

At docs/design/INDEX.md:296 (the blank line after C8's last realization note, before ### C9), insert, matching the existing realization-note format (bold lead

  • prose, as the FieldSpec/0005 note at :219 and the param/0015 note at :253):
**Realization (cycle 0027 — name input ports, refs #21/#51).** `PortSpec` now
carries a `name: String` (it drops `Copy`, like `ParamSpec`), so an input port is
named just as `FieldSpec.name` (output) and `ParamSpec.name` (param) already are.
The name is **non-load-bearing** (C23): wiring stays positional by slot, bootstrap
and the run loop never read it; it exists for tracing / graph rendering (#13). Leaf
primitives declare their slot names (`SimBroker`'s `exposure`/`price`, etc.);
`derive_signature` carries a composite's `Role.name` into the derived input port,
so the graph model (`model_to_json`) is homogeneously named across inputs, outputs,
and params and across both graph levels. This does **not** close #21 (a swapped
same-kind wiring is still only kind-checked) — it makes those slots
self-documenting; a name-consuming validation is its own future cycle.
  • Step 2: Verify the ledger still reads cleanly

Run: cargo doc --workspace --no-deps 2>&1 | rg -i "warning|error" || echo "doc clean" Expected: doc clean (the ledger is Markdown, not rustdoc — this gate just confirms the code changes introduced no doc-build regression). The ledger edit itself is prose; no code gate applies.