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
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
# 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:
|
||||
|
||||
```rust
|
||||
// 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:
|
||||
|
||||
```rust
|
||||
// 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();
|
||||
```
|
||||
|
||||
```rust
|
||||
// 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:
|
||||
|
||||
```json
|
||||
// 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:
|
||||
|
||||
```json
|
||||
// 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)
|
||||
|
||||
```rust
|
||||
// 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,
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// 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() }
|
||||
```
|
||||
|
||||
```rust
|
||||
// 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.rs`** — `PortSpec` 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.rs`** — `derive_signature` carries
|
||||
`Role.name` into the derived input `PortSpec`.
|
||||
- **`crates/aura-engine/src/graph_model.rs`** — `port_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.
|
||||
Reference in New Issue
Block a user