diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index c855f0e..9524f7c 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -51,7 +51,7 @@ fn sample_harness() -> ( let (tx_eq, rx_eq) = mpsc::channel(); let (tx_ex, rx_ex) = mpsc::channel(); let f64_recorder_sig = || aura_engine::NodeSchema { - inputs: vec![aura_engine::PortSpec { kind: ScalarKind::F64, firing: Firing::Any }], + inputs: vec![aura_engine::PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }], output: vec![], params: vec![], }; diff --git a/crates/aura-core/src/node.rs b/crates/aura-core/src/node.rs index a70fca9..74bc831 100644 --- a/crates/aura-core/src/node.rs +++ b/crates/aura-core/src/node.rs @@ -26,14 +26,20 @@ pub enum Firing { Barrier(u8), } -/// One declared input **port** of a node: its scalar kind and firing policy (C6). -/// 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`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// 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, } /// One declared output column of a node's record: its name (metadata for sinks / @@ -171,11 +177,14 @@ mod tests { #[test] fn port_spec_carries_firing() { - let a = PortSpec { kind: ScalarKind::F64, firing: Firing::Any }; - let b = PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0) }; + 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"); } #[test] diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index 7010cf6..2fa3815 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -73,7 +73,7 @@ fn derive_signature(c: &Composite) -> NodeSchema { .first() .map(|t| interior_slot_kind(c.nodes(), c.edges(), t)) .unwrap_or(ScalarKind::F64); - PortSpec { kind, firing: Firing::Any } + PortSpec { kind, firing: Firing::Any, name: role.name.clone() } }) .collect(); let output = c @@ -739,7 +739,7 @@ mod tests { /// One f64 input port with `Firing::Any` (the common case for these fixtures). fn f64_any() -> PortSpec { - PortSpec { kind: ScalarKind::F64, firing: Firing::Any } + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() } } /// A one-f64-field output record under name `v`. fn out_v() -> Vec { @@ -839,7 +839,7 @@ mod tests { PrimitiveBuilder::new( "SinkI64", NodeSchema { - inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any }], + inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "in".into() }], output: vec![], params: vec![], }, @@ -1935,7 +1935,7 @@ mod tests { let exploding = PrimitiveBuilder::new( "Boom", NodeSchema { - inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any }], + inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "in".into() }], output: vec![FieldSpec { name: "v", kind: ScalarKind::I64 }], params: vec![], }, diff --git a/crates/aura-engine/src/graph_model.rs b/crates/aura-engine/src/graph_model.rs index 589ebd8..0c9541c 100644 --- a/crates/aura-engine/src/graph_model.rs +++ b/crates/aura-engine/src/graph_model.rs @@ -47,9 +47,16 @@ fn named_kind(name: &str, kind: ScalarKind) -> String { format!("[{},{}]", json_str(name), json_str(kind_str(kind))) } -/// An input port `[kind, firing]` (no name — `PortSpec` carries none). +/// One input port as a model tuple: `["","",""]`. 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))) + format!( + "[{},{},{}]", + json_str(kind_str(p.kind)), + json_str(&firing_str(p.firing)), + json_str(&p.name) + ) } /// Comma-join with no surrounding brackets. @@ -130,13 +137,14 @@ fn scope_json(c: &Composite, is_root: bool) -> String { /// re-exported field), and the interior `nodes`/`edges` with `@role` / `#N` /// endpoints folded onto the edge list. fn composite_def_json(c: &Composite) -> String { - // inputs: one port per input role; kind from the interior slot it feeds. + // inputs: one port per input role; kind from the interior slot it feeds, name + // from the role (the boundary `Role.name`, mirroring `derive_signature`). let inputs = join(c.input_roles().iter().map(|r| { let kind = r .source .or_else(|| r.targets.first().and_then(|t| input_slot_kind(c, t.node, t.slot))) .unwrap_or(ScalarKind::F64); - format!("[{},{}]", json_str(kind_str(kind)), json_str("any")) + format!("[{},{},{}]", json_str(kind_str(kind)), json_str("any"), json_str(&r.name)) })); // outputs: [name, kind] per OutField. Kind from the producing node's field. let outputs = join(c.output().iter().map(|of| { @@ -249,8 +257,8 @@ mod tests { "Sub", NodeSchema { inputs: vec![ - PortSpec { kind: ScalarKind::F64, firing: Firing::Any }, - PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0) }, + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() }, + PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0), name: "rhs".into() }, ], output: vec![FieldSpec { name: "diff", kind: ScalarKind::F64 }], params: vec![], @@ -361,7 +369,7 @@ mod tests { let got = prim_record(&sub_builder()); assert_eq!( got, - r#"{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any"],["f64","barrier 0"]],"outs":[["diff","f64"]]}}"# + r#"{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","barrier 0","rhs"]],"outs":[["diff","f64"]]}}"# ); } @@ -390,7 +398,7 @@ mod tests { _ => panic!(), }; let got = composite_def_json(inner); - assert!(got.contains(r#""inputs":[["f64","any"]]"#), "{got}"); + assert!(got.contains(r#""inputs":[["f64","any","price"]]"#), "{got}"); // an interior input role becomes an @role endpoint: assert!(got.contains(r#""@price""#), "{got}"); // an output binding becomes a #N endpoint: @@ -419,7 +427,7 @@ mod tests { // Re-capture if an intended model-shape change lands: temporarily add a // `println!("{}", model_to_json(&sample_root()))` test, run with // `-- --nocapture`, and paste the fresh bytes below. - let expected = r##"{"root":{"nodes":{"0":{"comp":"sma_cross"},"1":{"prim":{"type":"Exposure","role":"node","params":[["scale","f64"]],"ins":[["f64","any"]],"outs":[["exposure","f64"]]}},"2":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any"]],"outs":[]}},"src_price":{"prim":{"type":"price","role":"source","params":[],"ins":[],"outs":[["price","f64"]]}}},"edges":[["0.o0","1.i0"],["1.o0","2.i0"],["src_price.o0","0.i0"]]},"composites":{"sma_cross":{"inputs":[["f64","any"]],"outputs":[["out","f64"]],"nodes":{"0":{"prim":{"type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any"]],"outs":[["value","f64"]]}},"1":{"prim":{"type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any"],["f64","any"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]}}}"##; + let expected = r##"{"root":{"nodes":{"0":{"comp":"sma_cross"},"1":{"prim":{"type":"Exposure","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["exposure","f64"]]}},"2":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"src_price":{"prim":{"type":"price","role":"source","params":[],"ins":[],"outs":[["price","f64"]]}}},"edges":[["0.o0","1.i0"],["1.o0","2.i0"],["src_price.o0","0.i0"]]},"composites":{"sma_cross":{"inputs":[["f64","any","price"]],"outputs":[["out","f64"]],"nodes":{"0":{"prim":{"type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]}}}"##; assert_eq!(model_to_json(&sample_root()), expected); } @@ -430,7 +438,7 @@ mod tests { // the Recorder node in the root is a sink with no outputs. let model = model_to_json(&sample_root()); assert!( - model.contains(r#""role":"sink","params":[],"ins":[["f64","any"]],"outs":[]"#), + model.contains(r#""role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]"#), "{model}" ); } @@ -440,7 +448,7 @@ mod tests { // a composite with two f64 input roles serialises exactly two ports in "inputs". let c = two_input_composite(); let def = composite_def_json(&c); - assert!(def.starts_with(r#"{"inputs":[["f64","any"],["f64","any"]],"#), "{def}"); + assert!(def.starts_with(r#"{"inputs":[["f64","any","a"],["f64","any","b"]],"#), "{def}"); } #[test] diff --git a/crates/aura-engine/src/harness.rs b/crates/aura-engine/src/harness.rs index 2cbe966..9a64d06 100644 --- a/crates/aura-engine/src/harness.rs +++ b/crates/aura-engine/src/harness.rs @@ -359,7 +359,7 @@ mod tests { /// One f64 input port with the given firing policy (lookback 1 is the bootstrap /// default for these test fixtures, supplied by their `lookbacks()`). fn f64_port(firing: Firing) -> PortSpec { - PortSpec { kind: ScalarKind::F64, firing } + PortSpec { kind: ScalarKind::F64, firing, name: "in".into() } } /// Bootstrap a hand-wired graph from boxed nodes + their declared signatures. @@ -377,7 +377,7 @@ mod tests { /// The declared signature of a `Recorder` over `kinds` with the given firing. fn recorder_sig(kinds: &[ScalarKind], firing: Firing) -> NodeSchema { NodeSchema { - inputs: kinds.iter().map(|&kind| PortSpec { kind, firing }).collect(), + inputs: kinds.iter().enumerate().map(|(i, &kind)| PortSpec { kind, firing, name: format!("col[{i}]") }).collect(), output: vec![], params: vec![], } diff --git a/crates/aura-engine/src/report.rs b/crates/aura-engine/src/report.rs index 6fc16ca..8b4c63b 100644 --- a/crates/aura-engine/src/report.rs +++ b/crates/aura-engine/src/report.rs @@ -207,7 +207,7 @@ mod tests { /// the two-sink harness uses). fn f64_recorder_sig() -> NodeSchema { NodeSchema { - inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any }], + inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }], output: vec![], params: vec![], } diff --git a/crates/aura-ingest/tests/real_bars.rs b/crates/aura-ingest/tests/real_bars.rs index 2fbb575..ad1893c 100644 --- a/crates/aura-ingest/tests/real_bars.rs +++ b/crates/aura-ingest/tests/real_bars.rs @@ -23,7 +23,7 @@ fn run_sample_over(prices: Vec<(Timestamp, Scalar)>) -> RunReport { let (tx_eq, rx_eq) = mpsc::channel(); let (tx_ex, rx_ex) = mpsc::channel(); let f64_recorder_sig = || NodeSchema { - inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any }], + inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }], output: vec![], params: vec![], }; diff --git a/crates/aura-std/src/add.rs b/crates/aura-std/src/add.rs index 6ef282e..62a807e 100644 --- a/crates/aura-std/src/add.rs +++ b/crates/aura-std/src/add.rs @@ -33,8 +33,8 @@ impl Add { "Add", NodeSchema { inputs: vec![ - PortSpec { kind: ScalarKind::F64, firing: Firing::Any }, - PortSpec { kind: ScalarKind::F64, firing: Firing::Any }, + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() }, + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "rhs".into() }, ], output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], params: vec![], @@ -91,4 +91,11 @@ mod tests { inputs[1].push(Scalar::F64(4.0)).unwrap(); assert_eq!(add.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(14.0)].as_slice())); } + + #[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"]); + } } diff --git a/crates/aura-std/src/ema.rs b/crates/aura-std/src/ema.rs index 01aae83..64ef841 100644 --- a/crates/aura-std/src/ema.rs +++ b/crates/aura-std/src/ema.rs @@ -60,7 +60,7 @@ impl Ema { PrimitiveBuilder::new( "EMA", NodeSchema { - inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any }], + inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }], output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], }, @@ -161,4 +161,9 @@ mod tests { assert_eq!(b.schema().params[0].name, "length"); assert_eq!(b.schema().params[0].kind, ScalarKind::I64); } + + #[test] + fn input_slot_is_named_series() { + assert_eq!(Ema::builder().schema().inputs[0].name, "series"); + } } diff --git a/crates/aura-std/src/exposure.rs b/crates/aura-std/src/exposure.rs index 12d8d76..7542b38 100644 --- a/crates/aura-std/src/exposure.rs +++ b/crates/aura-std/src/exposure.rs @@ -29,7 +29,7 @@ impl Exposure { PrimitiveBuilder::new( "Exposure", NodeSchema { - inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any }], + inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "signal".into() }], output: vec![FieldSpec { name: "exposure", kind: ScalarKind::F64 }], params: vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }], }, @@ -89,4 +89,9 @@ mod tests { let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; assert_eq!(e.eval(Ctx::new(&inputs, Timestamp(0))), None); } + + #[test] + fn input_slot_is_named_signal() { + assert_eq!(Exposure::builder().schema().inputs[0].name, "signal"); + } } diff --git a/crates/aura-std/src/lincomb.rs b/crates/aura-std/src/lincomb.rs index 6074936..bc5bea9 100644 --- a/crates/aura-std/src/lincomb.rs +++ b/crates/aura-std/src/lincomb.rs @@ -45,7 +45,7 @@ impl LinComb { /// are injected, slot by slot, through `LinComb::new` (the single sizing gate). pub fn builder(arity: usize) -> PrimitiveBuilder { let inputs = (0..arity) - .map(|_| PortSpec { kind: ScalarKind::F64, firing: Firing::Any }) + .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 }) @@ -141,4 +141,11 @@ mod tests { fn lincomb_empty_weights_panics() { let _ = LinComb::new(vec![]); } + + #[test] + fn input_slots_are_named_term_index() { + let lc = LinComb::builder(3); + let names: Vec = lc.schema().inputs.iter().map(|p| p.name.clone()).collect(); + assert_eq!(names, ["term[0]", "term[1]", "term[2]"]); + } } diff --git a/crates/aura-std/src/recorder.rs b/crates/aura-std/src/recorder.rs index 3b3f0cf..dc95657 100644 --- a/crates/aura-std/src/recorder.rs +++ b/crates/aura-std/src/recorder.rs @@ -38,7 +38,11 @@ impl Recorder { firing: Firing, tx: Sender<(Timestamp, Vec)>, ) -> PrimitiveBuilder { - let inputs = kinds.iter().map(|&kind| PortSpec { kind, firing }).collect(); + let inputs = kinds + .iter() + .enumerate() + .map(|(i, &kind)| PortSpec { kind, firing, name: format!("col[{i}]") }) + .collect(); let build_kinds = kinds.clone(); PrimitiveBuilder::new( "Recorder", @@ -137,4 +141,12 @@ mod tests { let rows: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); assert_eq!(rows, vec![(Timestamp(2), vec![Scalar::F64(1.0), Scalar::F64(2.0)])]); } + + #[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 = r.schema().inputs.iter().map(|p| p.name.clone()).collect(); + assert_eq!(names, ["col[0]", "col[1]"]); + } } diff --git a/crates/aura-std/src/sim_broker.rs b/crates/aura-std/src/sim_broker.rs index ba79f39..1e531e4 100644 --- a/crates/aura-std/src/sim_broker.rs +++ b/crates/aura-std/src/sim_broker.rs @@ -63,8 +63,8 @@ impl SimBroker { "SimBroker", NodeSchema { inputs: vec![ - PortSpec { kind: ScalarKind::F64, firing: Firing::Any }, // 0 exposure - PortSpec { kind: ScalarKind::F64, firing: Firing::Any }, // 1 price + 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![], @@ -162,4 +162,11 @@ mod tests { let mut inputs = two_f64_inputs(); assert_eq!(step(&mut b, &mut inputs, Some(1.0), 100.0), 0.0); // no prev price to mark } + + #[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"]); + } } diff --git a/crates/aura-std/src/sma.rs b/crates/aura-std/src/sma.rs index c9e909d..e9ea9e1 100644 --- a/crates/aura-std/src/sma.rs +++ b/crates/aura-std/src/sma.rs @@ -28,7 +28,7 @@ impl Sma { PrimitiveBuilder::new( "SMA", NodeSchema { - inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any }], + inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }], output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], }, @@ -150,4 +150,9 @@ mod tests { .is_empty() ); } + + #[test] + fn input_slot_is_named_series() { + assert_eq!(Sma::builder().schema().inputs[0].name, "series"); + } } diff --git a/crates/aura-std/src/sub.rs b/crates/aura-std/src/sub.rs index 9856094..1c4a756 100644 --- a/crates/aura-std/src/sub.rs +++ b/crates/aura-std/src/sub.rs @@ -24,8 +24,8 @@ impl Sub { "Sub", NodeSchema { inputs: vec![ - PortSpec { kind: ScalarKind::F64, firing: Firing::Any }, - PortSpec { kind: ScalarKind::F64, firing: Firing::Any }, + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() }, + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "rhs".into() }, ], output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], params: vec![], @@ -82,4 +82,11 @@ mod tests { inputs[1].push(Scalar::F64(4.0)).unwrap(); assert_eq!(sub.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(6.0)].as_slice())); } + + #[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"]); + } } diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index afe05fa..ede6042 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -294,6 +294,18 @@ primitive returns its recipe's schema; a composite *derives* it from the interio role kinds in, re-exported field kinds out, aggregated params), so "every node has a signature in the blueprint" holds for composites too. +**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. + ### C9 — Fractal, acyclic composition **Guarantee.** A composite is itself a `Node` that wires a sub-graph and exposes one output; signal, combined signal, and (with execution) strategy are all the