feat(aura-core): name input ports

PortSpec gains a non-load-bearing `name: String`, so an input port is named just
as FieldSpec.name (output) and ParamSpec.name (param) already are — input ports
were the lone unnamed member of the node signature. Identity stays positional by
slot (C23); the name is render/debug only, never read by bootstrap or the run
loop. PortSpec drops Copy (String is not Copy), exactly as ParamSpec already does.

- Every aura-std node names its input slots: SMA/EMA "series", Sub/Add "lhs"/"rhs",
  Exposure "signal", SimBroker "exposure"/"price" (the slots become
  self-documenting), LinComb "term[i]" and Recorder "col[i]" generated in their
  build loops (mirroring LinComb's existing weights[i] param loop).
- derive_signature carries a composite's Role.name into the derived input port
  (it was dropped before — the output side already carried FieldSpec.name), so the
  graph model is homogeneously named at both levels.
- model_to_json (port_json + the composite-header inputs in scope_json) emits the
  name as a third tuple element: ["f64","any","exposure"]. The byte golden was
  re-captured (machine bytes) and its substring twins updated; the model is now
  fully named across inputs/outputs/params.
- All 16 PortSpec construction sites threaded in one compile-gate change; test
  fixtures carry fixture names. C8 realization note added to the design ledger.

Why name-only, no validation: the name is a pure debug symbol. Wire-by-name was
rejected (it would be a C23 contract change). Bootstrap slot-wiring validation
(which would close #21's same-kind swap footgun) is deferred to its own cycle —
a name alone does not catch the swap; it makes the slots self-documenting and
gives a future validation something to check against.

Verified: cargo test --workspace 168 green; clippy --all-targets -D warnings
clean; cargo build --workspace clean. Read-only render path (C9), no serde (C14),
scalar kinds unchanged (C4).

closes #50
refs #21
refs #51
This commit is contained in:
2026-06-10 16:19:08 +02:00
parent 5288249b44
commit e304dbaae1
16 changed files with 122 additions and 38 deletions
+19 -11
View File
@@ -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: `["<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)))
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]