diff --git a/docs/specs/fieldtest-0031-node-naming.md b/docs/specs/fieldtest-0031-node-naming.md new file mode 100644 index 0000000..7b117a3 --- /dev/null +++ b/docs/specs/fieldtest-0031-node-naming.md @@ -0,0 +1,194 @@ +# Fieldtest — cycle-0031 — 2026-06-11 + +**Status:** Draft — awaiting orchestrator triage +**Author:** fieldtester (dispatched by fieldtest skill) + +## Scope + +Cycle 0031 (node-instance naming, #56) gives every blueprint node a name. A +primitive builder gains an optional instance name (`Sma::builder().named("fast")`); +omitted, it defaults to the node's type label ASCII-lowercased verbatim +(`"SMA"`→`"sma"`, no snake_case). `param_space()` is now uniformly +`.` at every level including the root (`sma_cross.fast.length`, +`exposure.scale`). Fan-in distinguishability (C9) and `signature_of` re-key onto +the node name: two same-type siblings both defaulting to `"sma"` collide and +`IndistinguishableFanIn` fires; naming them `"fast"`/`"slow"` fixes both the +naming collision and the fan-in check in one act. The index-addressed +`ParamAlias` overlay (and `Composite::new`'s 5th argument) was retired. + +Tested from the public interface only: the design ledger (C9/C23 amendments), +spec 0031, and `cargo doc --workspace` rustdoc; never `crates/*/src`. Build +exercised: `cargo build --workspace` (green from HEAD `0e411f1`), then each +fixture via `cargo run --manifest-path +fieldtests/cycle-0031-node-naming/Cargo.toml --bin ` so HEAD source is +always what runs. The fixture crate is a standalone non-member crate +path-depending on `aura-core/-engine/-std`, as a C16 research project would. + +## Per-task roll-up + +| Task | Axis | Built? | Ran? | Matched expected? | +|---|---|---|---|---| +| 1 named single-run | a | yes | yes | yes — `sma_cross.fast.length` etc., bound & ran, deterministic | +| 2 forcing function | b | yes | yes | partially — fan-in is rejected, but **not via the diagnostic the spec headlines** (see F4) | +| 3 default names | c | yes | yes | yes — `sma.length`/`exposure.scale`, verbatim lowercase, paramless legal | + +## Examples + +### fieldtests/cycle-0031-node-naming/c0031_1_named_single_run.rs — the named single-run authoring path (axis a) +- Builds the canonical harness `source → sma_cross{ Sma("fast"), Sma("slow"), + Sub } → Exposure → SimBroker → Recorder`, inspects `param_space()`, binds + `sma_cross.fast.length` / `sma_cross.slow.length` / `exposure.scale` by name + (no `ParamAlias`, no index counting), runs, and re-runs disjoint for C1. +- Why it fits: this is exactly the friction the 0030 milestone fixture (`mw_1`) + had to work around with two hand-counted `ParamAlias` overlays — acceptance + #1/#2/#4 of the spec. +- Outcome: built and ran first try. `param_space()` = `[sma_cross.fast.length:I64, + sma_cross.slow.length:I64, exposure.scale:F64]`; named bind bootstrapped and + recorded 12 exposure rows; two disjoint runs bit-identical. + +### fieldtests/cycle-0031-node-naming/c0031_2_forcing_function.rs — the forcing function (axis b) +- Authors the SAME 2-SMA cross WITHOUT names, then tries to bind/bootstrap it + the way a downstream author naturally would (`.with(...).bootstrap()`), then + isolates the compile wall via `compile_with_params`, then recovers by naming + the legs and runs. +- Why it fits: acceptance #3 — the deliberate forcing function, and the carrier's + explicit question "is the diagnostic discoverable/helpful from the public + surface alone?" +- Outcome: built and ran. The un-named cross IS rejected (good), but the + diagnostic an author reaches depends entirely on the path (see F4/F5). Naming + the legs resolves it and the named cross runs (12 rows). + +### fieldtests/cycle-0031-node-naming/c0031_3_default_names.rs — default names + paramless-legal (axis c) +- A single un-named `Sma` at root → `sma.length`; un-named `Exposure` → + `exposure.scale`; checks no `sim_broker`-style snake_case leaks; binds the + default names and runs; then a two-`Add` paramless same-name fan-in compiles + (stays legal). +- Why it fits: the spec's "Default name" and "Paramless interchangeable stays + legal" clauses, plus the carrier's optional 3rd axis. +- Outcome: built and ran. `sma.length` / `exposure.scale` emitted at root and + bindable; paramless same-name fan-in compiled (`ok: true`). + +## Findings + +### [working] Named single-run path: `.named()` → uniform `.` → bind by name → run, deterministic +- Example: c0031_1. +- What happened: `Sma::builder().named("fast")` made `param_space()` emit + `sma_cross.fast.length` / `sma_cross.slow.length`, and the root `Exposure` + emitted `exposure.scale` — exactly the spec's uniform shape. Binding all three + by name with `.with(...).bootstrap()` succeeded with no `ParamAlias` and no + index counting; the run recorded a populated 12-row trace; a disjoint second + run was bit-identical (C1). +- Why working: the headline acceptance (#1/#2/#4) is reachable, correct, and + unambiguous from rustdoc + ledger alone. The exact pain the 0030 milestone + fixture documented (`mw_1`'s two hand-counted overlays) is gone. +- Recommended action: carry-on. + +### [working] Naming the colliding legs is the single recovering act; the named cross runs +- Example: c0031_2 (block [2]). +- What happened: the same un-named topology that is rejected becomes legal the + moment both SMAs are `.named("fast")`/`.named("slow")` — one act clears both + the param-path collision and the fan-in check, and the harness runs (12 rows). +- Why working: confirms the spec's central claim that one author act fixes both + walls. +- Recommended action: carry-on. + +### [working] Default names = verbatim-lowercased type label, root-level qualified, paramless fan-in stays legal +- Example: c0031_3. +- What happened: an un-named `Sma` at root emitted `sma.length` (root segment now + present, as the spec inverts the old bare-root rule); un-named `Exposure` → + `exposure.scale`; no `sim_broker` snake_case appeared; both default names bound + and ran. Two un-named **paramless** same-type (`Add`) legs under a fan-in + compiled cleanly — the spec's "paramless interchangeable stays legal" clause + holds. +- Why working: the default-name mechanism and the param-bearing-vs-paramless + distinction behave exactly as specified. +- Note (not a defect): `SimBroker` exposes **no** tunable knob (its spread is a + constructor value), so the `"simbroker"` vs `"sim_broker"` verbatim-lowercase + rule could not be exercised on an actually-emitted knob; `sma`/`exposure` + confirm the no-snake-case rule on real knobs. +- Recommended action: carry-on. + +### [spec_gap] The headline `IndistinguishableFanIn` diagnostic is unreachable from the canonical by-name authoring flow +- Example: c0031_2 (blocks [0], [1], [1a], [1b]). +- What happened: the spec headlines `IndistinguishableFanIn { node }` as the + forcing-function diagnostic that tells the author "name the colliding legs" + (acceptance #3, ledger C9 amendment). But a downstream author using the + **canonical single-run flow** (`.with(...).bootstrap()`, the spec-0030 surface + this cycle builds on) never sees it: + - guessing the old collision name `sma_cross.length` → + `UnknownKnob("sma_cross.length")`; + - binding the name `param_space()` **actually** emits — `sma_cross.sma.length` + — → `AmbiguousKnob("sma_cross.sma.length")`; + - `IndistinguishableFanIn { node: 2 }` only appears via the **positional** + `compile_with_params(&[...])`, which a by-name author has no reason to call. +- Why spec_gap: acceptance #3 says "an unnamed same-type param-bearing fan-in is + rejected with `IndistinguishableFanIn`, and the rejection names the node; + naming the legs resolves it." It IS rejected — correctly — but through + `.bootstrap()` the rejection is a **binder** error (`UnknownKnob` / + `AmbiguousKnob`) that points at a knob *name*, not at "give your nodes distinct + names." The author whose mental model is "I bound a name that doesn't resolve" + is led to fiddle with the bind string, not to `.named()` the legs — the exact + recovery the cycle is built to teach. Two readings are equally plausible from + the surface: (i) "`IndistinguishableFanIn` is the contract; the binder errors + are an incidental earlier wall" vs (ii) "the by-name flow should surface the + fan-in/naming guidance too." I asserted neither in the fixture — I recorded + both walls verbatim. +- Recommended action: ratify-or-tighten. Either (a) document on + `param_space()` / `Composite::with` that a duplicate-named (default-collision) + knob is the *symptom* and node-naming is the *cure* — i.e. make `AmbiguousKnob` + / `UnknownKnob` text (or rustdoc) point at `.named()`; or (b) have the + by-name `.bootstrap()` path surface `IndistinguishableFanIn` (or fold its + guidance into the binder error) so the canonical flow reaches the diagnostic + the spec promises. No engine behaviour is wrong; the guidance is mis-routed. + +### [friction] `param_space()` advertises a literal duplicate knob name for an un-named same-type fan-in +- Example: c0031_2 (block [0]). +- What happened: the un-named cross's `param_space()` emits + `sma_cross.sma.length` **twice** — two `ParamSpec` entries with byte-identical + names, neither individually bindable (`AmbiguousKnob`). A value-empty blueprint + (C11) thus publicly advertises two knobs the consumer cannot address. +- Why friction: the task (bind the cross) still completes once the author names + the legs, but the public `param_space()` projection contains an un-actionable + duplicate — a consumer enumerating it (e.g. to build a sweep grid or a UI) sees + a degenerate entry with no surface signal that the cure is `.named()`. The old + pre-0031 collision was `sma_cross.length` (×2); 0031 changes the string but not + the duplicate-ness in the default case. +- Recommended action: plan (tidy) — consider whether `param_space()` should + de-dup, error, or annotate a collision-named entry, so the flat projection is + never silently degenerate. At minimum a rustdoc note on `param_space()` that a + duplicate entry signals an un-named same-type sibling. + +### [friction] `FlatGraph` / `Harness` lack `Debug`, so a `compile`/`bootstrap` `Result` cannot be `{:?}`-printed +- Example: c0031_2, c0031_3 (compile-time, worked around). +- What happened: `compile_with_params` returns `Result` + and `bootstrap` returns `Result`; printing either with + `{:?}` fails to compile (`FlatGraph`/`Harness` don't implement `Debug`). A + consumer probing diagnostics must `.err()`/`.is_ok()` the Result rather than + print it whole. +- Why friction: not new to 0031 and not wrong, but it surfaced immediately when + field-testing the very errors this cycle re-keys — the natural first reflex + (`println!("{:?}", result)`) does not compile, forcing a `.err()` dance. +- Recommended action: carry-on (or plan, low priority) — deriving `Debug` on the + success types would let consumers print a compile/bootstrap result directly; + orthogonal to 0031's contract. + +## Recommendation summary + +| Finding | Class | Action | +|---|---|---| +| Named single-run path uniform `.`, bind, run, deterministic | working | carry-on | +| Naming the colliding legs is the single recovering act | working | carry-on | +| Default names verbatim-lowercase, root-qualified, paramless legal | working | carry-on | +| `IndistinguishableFanIn` unreachable from the canonical by-name flow | spec_gap | ratify / tighten (route the naming guidance into the binder error or rustdoc) | +| `param_space()` advertises a literal duplicate knob in the default case | friction | plan (tidy) | +| `FlatGraph`/`Harness` not `Debug` → can't print a compile/bootstrap Result | friction | carry-on / plan (low) | + +## Verdict + +Cycle 0031 delivers its core promise cleanly — `.named()`, uniform +`.` paths, and the one-act fan-in/param-path fix all work first try +from the public surface; the only real gap is that the spec's headline +`IndistinguishableFanIn` diagnostic never reaches an author using the canonical +`.with(...).bootstrap()` flow (they hit `UnknownKnob`/`AmbiguousKnob` instead), +so the cure (`.named()`) is under-signposted exactly where the cycle most wants +to teach it. diff --git a/fieldtests/cycle-0031-node-naming/Cargo.lock b/fieldtests/cycle-0031-node-naming/Cargo.lock new file mode 100644 index 0000000..a8363af --- /dev/null +++ b/fieldtests/cycle-0031-node-naming/Cargo.lock @@ -0,0 +1,99 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aura-core" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "aura-engine" +version = "0.1.0" +dependencies = [ + "aura-core", + "serde", +] + +[[package]] +name = "aura-std" +version = "0.1.0" +dependencies = [ + "aura-core", +] + +[[package]] +name = "c0031-fieldtest" +version = "0.0.0" +dependencies = [ + "aura-core", + "aura-engine", + "aura-std", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/fieldtests/cycle-0031-node-naming/Cargo.toml b/fieldtests/cycle-0031-node-naming/Cargo.toml new file mode 100644 index 0000000..c15de37 --- /dev/null +++ b/fieldtests/cycle-0031-node-naming/Cargo.toml @@ -0,0 +1,34 @@ +# Standalone downstream-consumer crate for the cycle-0031 fieldtest +# (node-instance naming: `.named()`, uniform `.` param paths, +# IndistinguishableFanIn re-keyed onto the node name, ParamAlias retired). +# +# Like the prior cycle-0006..0011 fixtures, this is NOT a member of the aura +# workspace — it path-depends on the engine crates exactly as a real research +# project (C16) would. Built/run via +# cargo run --manifest-path fieldtests/cycle-0031-node-naming/Cargo.toml --bin +# so HEAD source is always what runs. No crates/*/src was read; the API was +# discovered from the ledger (C9/C23), spec 0031, and `cargo doc`. +[workspace] + +[package] +name = "c0031-fieldtest" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +aura-core = { path = "../../crates/aura-core" } +aura-engine = { path = "../../crates/aura-engine" } +aura-std = { path = "../../crates/aura-std" } + +[[bin]] +name = "c0031_1_named_single_run" +path = "c0031_1_named_single_run.rs" + +[[bin]] +name = "c0031_2_forcing_function" +path = "c0031_2_forcing_function.rs" + +[[bin]] +name = "c0031_3_default_names" +path = "c0031_3_default_names.rs" diff --git a/fieldtests/cycle-0031-node-naming/c0031_1_named_single_run.rs b/fieldtests/cycle-0031-node-naming/c0031_1_named_single_run.rs new file mode 100644 index 0000000..bd0bb83 --- /dev/null +++ b/fieldtests/cycle-0031-node-naming/c0031_1_named_single_run.rs @@ -0,0 +1,147 @@ +// Cycle-0031 fieldtest — scenario 1 (axis a): the named single-run authoring path. +// +// Promise probed (spec 0031 acceptance #1, #2, #4): a downstream author writes +// `Sma::builder().named("fast")`, inspects `param_space()` (now uniformly +// `.` at every level, including the root `exposure.scale`), binds +// `sma_cross.fast.length` BY NAME with NO ParamAlias and NO index counting, and +// runs. This is the exact pain the milestone-0030 fixture (mw_1) had to work +// around with two hand-counted ParamAlias overlays. +// +// Public interface only: API discovered from the ledger (C9/C23), spec 0031, +// and `cargo doc`. No crates/*/src was read. +// +// The harness: source -> sma_cross composite { Sma("fast"), Sma("slow"), Sub } +// -> Exposure -> SimBroker -> Recorder. + +use std::sync::mpsc; + +use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; +use aura_engine::{BlueprintNode, Composite, Edge, OutField, Role, Target}; +use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub}; + +/// The reusable inner 2/4-SMA-cross composite. The two SMA legs are now named +/// inline via `.named()` — the single act that (a) distinguishes the fan-in and +/// (b) qualifies the two param paths. No ParamAlias argument exists anymore +/// (Composite::new is 4-arg). +fn sma_cross() -> Composite { + Composite::new( + "sma_cross", + vec![ + Sma::builder().named("fast").into(), + Sma::builder().named("slow").into(), + Sub::builder().into(), + ], + vec![ + Edge { from: 0, to: 2, slot: 0, from_field: 0 }, + Edge { from: 1, to: 2, slot: 1, from_field: 0 }, + ], + vec![Role { + name: "price".into(), + targets: vec![ + Target { node: 0, slot: 0 }, + Target { node: 1, slot: 0 }, + ], + source: None, + }], + vec![OutField { node: 2, field: 0, name: "out".into() }], + ) +} + +/// The root harness over the named cross. The root-level Exposure node now also +/// carries a name (default "exposure"), so its knob surfaces as `exposure.scale`. +fn harness(tx: mpsc::Sender<(Timestamp, Vec)>) -> Composite { + Composite::new( + "harness", + vec![ + BlueprintNode::Composite(sma_cross()), + Exposure::builder().into(), + SimBroker::builder(1e-4).into(), + Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(), + ], + vec![ + Edge { from: 0, to: 1, slot: 0, from_field: 0 }, + Edge { from: 1, to: 2, slot: 0, from_field: 0 }, + Edge { from: 2, to: 3, slot: 0, from_field: 0 }, + ], + vec![Role { + name: "price".into(), + targets: vec![ + Target { node: 0, slot: 0 }, + Target { node: 2, slot: 1 }, + ], + source: Some(ScalarKind::F64), + }], + vec![OutField { node: 0, field: 0, name: "exposure".into() }], + ) +} + +fn synthetic_prices() -> Vec<(Timestamp, Scalar)> { + let prices = [ + 1.00, 1.01, 1.02, 1.03, 1.05, 1.08, 1.06, 1.04, 1.02, 1.01, 1.03, 1.07, + ]; + prices + .iter() + .enumerate() + .map(|(i, &p)| (Timestamp(60_000_000_000 * i as i64), Scalar::F64(p))) + .collect() +} + +fn main() { + // --- 1. Inspect the param-space: are the names what the spec promises? ----- + let (tx0, _rx0) = mpsc::channel(); + let space = harness(tx0).param_space(); + println!("param_space() knobs (name : kind):"); + for p in &space { + println!(" {} : {:?}", p.name, p.kind); + } + let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect(); + // spec 0031 acceptance #1/#4: uniform ., distinct named legs. + assert!( + names.contains(&"sma_cross.fast.length"), + "expected sma_cross.fast.length, got {names:?}" + ); + assert!( + names.contains(&"sma_cross.slow.length"), + "expected sma_cross.slow.length, got {names:?}" + ); + assert!( + names.contains(&"exposure.scale"), + "expected root-level exposure.scale, got {names:?}" + ); + + // --- 2. Bind BY NAME, with no ParamAlias and no index counting. ------------ + let (tx, rx) = mpsc::channel::<(Timestamp, Vec)>(); + let mut h = harness(tx) + .with("sma_cross.fast.length", 2) + .with("sma_cross.slow.length", 4) + .with("exposure.scale", 0.5) + .bootstrap() + .expect("named harness binds by the uniform . names and bootstraps"); + + h.run(vec![synthetic_prices()]); + drop(h); + + let rows: Vec<(Timestamp, Vec)> = rx.iter().collect(); + println!("\nnamed-bound run — recorded rows: {}", rows.len()); + for (ts, row) in &rows { + if let Scalar::F64(x) = row[0] { + println!(" ts={:>14} exposure={:+.4}", ts.0, x); + } + } + assert!(!rows.is_empty(), "named-bound harness must record a populated trace"); + + // --- 3. Determinism (C1): a disjoint second run is bit-identical. ---------- + let (tx2, rx2) = mpsc::channel::<(Timestamp, Vec)>(); + let mut h2 = harness(tx2) + .with("sma_cross.fast.length", 2) + .with("sma_cross.slow.length", 4) + .with("exposure.scale", 0.5) + .bootstrap() + .expect("second disjoint bootstrap"); + h2.run(vec![synthetic_prices()]); + drop(h2); + let rows2: Vec<(Timestamp, Vec)> = rx2.iter().collect(); + assert_eq!(rows, rows2, "two disjoint named runs must be bit-identical (C1)"); + + println!("\nOK: param-space uniform ., named bind ran, deterministic."); +} diff --git a/fieldtests/cycle-0031-node-naming/c0031_2_forcing_function.rs b/fieldtests/cycle-0031-node-naming/c0031_2_forcing_function.rs new file mode 100644 index 0000000..33b2359 --- /dev/null +++ b/fieldtests/cycle-0031-node-naming/c0031_2_forcing_function.rs @@ -0,0 +1,160 @@ +// Cycle-0031 fieldtest — scenario 2 (axis b): the forcing function. +// +// Promise probed (spec 0031 acceptance #3): author the 2-SMA cross WITHOUT +// names -> both legs default to "sma" -> they collide and the fan-in is +// indistinguishable -> `IndistinguishableFanIn { node }`. The recovery is the +// single act of naming the colliding legs. The question this fixture answers as +// a downstream consumer: is the diagnostic DISCOVERABLE and HELPFUL from the +// public surface alone — does it name the node, and does the error type/text +// point the author at "give the legs distinct names"? +// +// Public interface only (ledger C9/C23 + spec 0031 + cargo doc). No src read. + +use std::sync::mpsc; + +use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; +use aura_engine::{ + BlueprintNode, CompileError, Composite, Edge, OutField, Role, Target, +}; +use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub}; + +/// Build the cross with the two SMA legs given (caller decides whether to name +/// them). `name_them = false` reproduces the minimal un-named authoring. +fn sma_cross(name_them: bool) -> Composite { + let (fast, slow) = if name_them { + (Sma::builder().named("fast"), Sma::builder().named("slow")) + } else { + (Sma::builder(), Sma::builder()) + }; + Composite::new( + "sma_cross", + vec![fast.into(), slow.into(), Sub::builder().into()], + vec![ + Edge { from: 0, to: 2, slot: 0, from_field: 0 }, + Edge { from: 1, to: 2, slot: 1, from_field: 0 }, + ], + vec![Role { + name: "price".into(), + targets: vec![ + Target { node: 0, slot: 0 }, + Target { node: 1, slot: 0 }, + ], + source: None, + }], + vec![OutField { node: 2, field: 0, name: "out".into() }], + ) +} + +fn harness( + name_them: bool, + tx: mpsc::Sender<(Timestamp, Vec)>, +) -> Composite { + Composite::new( + "harness", + vec![ + BlueprintNode::Composite(sma_cross(name_them)), + Exposure::builder().into(), + SimBroker::builder(1e-4).into(), + Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(), + ], + vec![ + Edge { from: 0, to: 1, slot: 0, from_field: 0 }, + Edge { from: 1, to: 2, slot: 0, from_field: 0 }, + Edge { from: 2, to: 3, slot: 0, from_field: 0 }, + ], + vec![Role { + name: "price".into(), + targets: vec![ + Target { node: 0, slot: 0 }, + Target { node: 2, slot: 1 }, + ], + source: Some(ScalarKind::F64), + }], + vec![OutField { node: 0, field: 0, name: "exposure".into() }], + ) +} + +fn main() { + // --- 0. What does param_space() emit for the UN-NAMED cross? -------------- + // Both legs default to "sma" -> sma_cross.sma.length appears TWICE + // (a collision). This is what makes a by-name bind ambiguous/unknown. + let (txs, _rxs) = mpsc::channel(); + let unnamed_space = harness(false, txs).param_space(); + println!("[0] un-named cross param_space():"); + for p in &unnamed_space { + println!(" {} : {:?}", p.name, p.kind); + } + + // --- 1. The minimal un-named authoring is correctly REJECTED. ------------- + let (txu, _rxu) = mpsc::channel(); + let unnamed = harness(false, txu) + .with("sma_cross.length", 2) // best guess at the colliding bare name + .bootstrap(); + // Harness (Ok branch) may not implement Debug; inspect the error only. + println!("[1] un-named cross bootstrap is_err: {}", unnamed.is_err()); + match unnamed.err() { + // The binder may reject first (AmbiguousKnob on the colliding + // sma_cross.length), OR the compile may reject first + // (IndistinguishableFanIn). Record whichever wall the author hits. + Some(other) => println!(" -> diagnostic the author sees: {other:?}"), + None => panic!("BUG: un-named param-bearing fan-in should be rejected"), + } + + // --- 1a. Bind the name param_space() ACTUALLY emits (the duplicate). ------ + // sma_cross.sma.length is emitted TWICE -> binding it is AmbiguousKnob, NOT + // the spec's headline IndistinguishableFanIn. So the by-name author never + // sees "name the colliding legs"; they see a knob-name diagnostic. + let (txa, _rxa) = mpsc::channel(); + let ambiguous = harness(false, txa) + .with("sma_cross.sma.length", 2) + .bootstrap(); + println!( + "[1a] bind the real emitted (duplicate) name -> {:?}", + ambiguous.err() + ); + + // --- 1b. The bare bootstrap (no binds at all) isolates the COMPILE wall. --- + // This removes the binder from the picture so we see the fan-in error itself. + let (txc, _rxc) = mpsc::channel(); + let compiled = harness(false, txc).compile_with_params(&[ + Scalar::I64(2), + Scalar::I64(4), + Scalar::F64(0.5), + ]); + // FlatGraph (the Ok branch) does not implement Debug, so we cannot + // `{:?}`-print the whole Result; inspect via .err() instead. + println!("\n[1b] un-named cross compile_with_params err: {:?}", compiled.as_ref().err()); + match compiled.err() { + Some(CompileError::IndistinguishableFanIn { node }) => { + println!( + " -> IndistinguishableFanIn at interior node index {node} \ + (the Sub that fans in both un-named SMAs)" + ); + } + other => println!( + " -> NOTE: expected IndistinguishableFanIn, got {other:?}" + ), + } + + // --- 2. The recovery: name the legs -> the SAME topology now compiles. ----- + let (tx, rx) = mpsc::channel::<(Timestamp, Vec)>(); + let named = harness(true, tx) + .with("sma_cross.fast.length", 2) + .with("sma_cross.slow.length", 4) + .with("exposure.scale", 0.5) + .bootstrap(); + println!("\n[2] named cross bootstrap ok: {}", named.is_ok()); + let mut h = named.expect("naming the colliding legs resolves the fan-in"); + let prices: Vec<(Timestamp, Scalar)> = (0..12) + .map(|i| (Timestamp(60_000_000_000 * i), Scalar::F64(1.0 + 0.01 * i as f64))) + .collect(); + h.run(vec![prices]); + drop(h); + let rows: Vec<_> = rx.iter().collect(); + println!("[2] named run recorded {} rows", rows.len()); + assert!(!rows.is_empty(), "named cross must run and record"); + + println!( + "\nOK: un-named fan-in rejected, naming the legs is the single recovering act." + ); +} diff --git a/fieldtests/cycle-0031-node-naming/c0031_3_default_names.rs b/fieldtests/cycle-0031-node-naming/c0031_3_default_names.rs new file mode 100644 index 0000000..5f46ba8 --- /dev/null +++ b/fieldtests/cycle-0031-node-naming/c0031_3_default_names.rs @@ -0,0 +1,130 @@ +// Cycle-0031 fieldtest — scenario 3 (axis c): default names + the paramless +// "interchangeable stays legal" corner + node-name visibility. +// +// Probes (spec 0031 §"Default name", §"Paramless interchangeable stays legal", +// and the carrier's optional 3rd axis): +// (a) a single un-named primitive emits `.` — `sma.length`; +// (b) a root-level Exposure (un-named) emits `exposure.scale`; +// (c) the default lowercasing is verbatim, no snake_case insertion +// ("SimBroker" -> "simbroker", not "sim_broker"); +// (d) two un-named PARAMLESS same-type siblings under a fan-in stay legal +// (no IndistinguishableFanIn), as the spec preserves. +// +// Public interface only (ledger C9/C23 + spec 0031 + cargo doc). No src read. + +use std::sync::mpsc; + +use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; +use aura_engine::{Composite, Edge, OutField, Role, Target}; +use aura_std::{Add, Exposure, Recorder, SimBroker, Sma, Sub}; + +/// A trivial harness: one un-named SMA feeding Exposure -> SimBroker -> Recorder. +/// No composite nesting, so every param surfaces at the ROOT level — the case +/// that exercises the "root segment is now present too" rule. +fn single_unnamed_harness(tx: mpsc::Sender<(Timestamp, Vec)>) -> Composite { + Composite::new( + "harness", + vec![ + Sma::builder().into(), // un-named -> default "sma" + Exposure::builder().into(), // un-named -> default "exposure" + SimBroker::builder(1e-4).into(), // un-named -> default "simbroker" + Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(), + ], + vec![ + Edge { from: 0, to: 1, slot: 0, from_field: 0 }, + Edge { from: 1, to: 2, slot: 0, from_field: 0 }, + Edge { from: 2, to: 3, slot: 0, from_field: 0 }, + ], + vec![Role { + name: "price".into(), + targets: vec![ + Target { node: 0, slot: 0 }, + Target { node: 2, slot: 1 }, + ], + source: Some(ScalarKind::F64), + }], + vec![OutField { node: 1, field: 0, name: "exposure".into() }], + ) +} + +/// Two un-named PARAMLESS same-type siblings (Add has no params) under a fan-in +/// (Sub). Spec: this stays LEGAL — interchangeable paramless legs do not fire +/// IndistinguishableFanIn. Built as a self-contained ROOT (the single price +/// source feeds both Adds' slots) so the role is bound and the only thing under +/// test is the paramless same-name fan-in rule, not role-binding. +fn paramless_fanin() -> Composite { + Composite::new( + "paramless", + vec![ + Add::builder().into(), // un-named -> "add" + Add::builder().into(), // un-named -> "add" — same name, but paramless + Sub::builder().into(), + ], + vec![ + Edge { from: 0, to: 2, slot: 0, from_field: 0 }, + Edge { from: 1, to: 2, slot: 1, from_field: 0 }, + ], + vec![Role { + name: "x".into(), + targets: vec![ + Target { node: 0, slot: 0 }, + Target { node: 0, slot: 1 }, + Target { node: 1, slot: 0 }, + Target { node: 1, slot: 1 }, + ], + // bound root source — keeps the role satisfied so the compile only + // exercises the paramless fan-in rule. + source: Some(ScalarKind::F64), + }], + vec![OutField { node: 2, field: 0, name: "out".into() }], + ) +} + +fn main() { + // --- (a)(b)(c) default names appear in param_space at the root level. ------ + let (tx0, _rx0) = mpsc::channel(); + let space = single_unnamed_harness(tx0).param_space(); + println!("default-name param_space():"); + for p in &space { + println!(" {} : {:?}", p.name, p.kind); + } + let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"sma.length"), "expected sma.length, got {names:?}"); + assert!(names.contains(&"exposure.scale"), "expected exposure.scale, got {names:?}"); + // SimBroker's spread arg is a constructor value, not a knob; if SimBroker has + // a param it must surface lowercased verbatim. Record whatever it emits. + let simbroker_knobs: Vec<&&str> = + names.iter().filter(|n| n.starts_with("sim")).collect(); + println!("simbroker-prefixed knobs (verbatim-lowercase check): {simbroker_knobs:?}"); + assert!( + !names.iter().any(|n| n.starts_with("sim_broker")), + "default name must be verbatim-lowercased 'simbroker', never snake_cased 'sim_broker' ({names:?})" + ); + + // Bind the default names and run, to prove they are real bindable knobs. + let (tx, rx) = mpsc::channel::<(Timestamp, Vec)>(); + let mut h = single_unnamed_harness(tx) + .with("sma.length", 3) + .with("exposure.scale", 1.0) + .bootstrap() + .expect("default-named knobs bind by their lowercased-type paths"); + let prices: Vec<(Timestamp, Scalar)> = (0..10) + .map(|i| (Timestamp(60_000_000_000 * i), Scalar::F64(1.0 + 0.02 * i as f64))) + .collect(); + h.run(vec![prices]); + drop(h); + let rows: Vec<_> = rx.iter().collect(); + println!("\ndefault-named run recorded {} rows", rows.len()); + assert!(!rows.is_empty()); + + // --- (d) paramless interchangeable same-name fan-in stays LEGAL. ---------- + let compiled = paramless_fanin().compile_with_params(&[]); + println!("\nparamless same-name fan-in compile ok: {:?}", compiled.is_ok()); + assert!( + compiled.is_ok(), + "two un-named PARAMLESS same-type legs must stay legal (no IndistinguishableFanIn): err={:?}", + compiled.err() + ); + + println!("\nOK: default names = lowercased type label, root-level qualified, paramless legal."); +} diff --git a/fieldtests/cycle-0031-node-naming/out_1_named_single_run.txt b/fieldtests/cycle-0031-node-naming/out_1_named_single_run.txt new file mode 100644 index 0000000..e73af94 --- /dev/null +++ b/fieldtests/cycle-0031-node-naming/out_1_named_single_run.txt @@ -0,0 +1,20 @@ +param_space() knobs (name : kind): + sma_cross.fast.length : I64 + sma_cross.slow.length : I64 + exposure.scale : F64 + +named-bound run — recorded rows: 12 + ts= 0 exposure=+0.0000 + ts= 60000000000 exposure=+0.0000 + ts= 120000000000 exposure=+0.0000 + ts= 180000000000 exposure=+0.0000 + ts= 240000000000 exposure=+4.0000 + ts= 300000000000 exposure=+11.5000 + ts= 360000000000 exposure=+3.5000 + ts= 420000000000 exposure=-2.5000 + ts= 480000000000 exposure=+0.5000 + ts= 540000000000 exposure=+4.5000 + ts= 600000000000 exposure=-2.5000 + ts= 660000000000 exposure=-6.5000 + +OK: param-space uniform ., named bind ran, deterministic. diff --git a/fieldtests/cycle-0031-node-naming/out_2_forcing_function.txt b/fieldtests/cycle-0031-node-naming/out_2_forcing_function.txt new file mode 100644 index 0000000..4f32d26 --- /dev/null +++ b/fieldtests/cycle-0031-node-naming/out_2_forcing_function.txt @@ -0,0 +1,15 @@ +[0] un-named cross param_space(): + sma_cross.sma.length : I64 + sma_cross.sma.length : I64 + exposure.scale : F64 +[1] un-named cross bootstrap is_err: true + -> diagnostic the author sees: UnknownKnob("sma_cross.length") +[1a] bind the real emitted (duplicate) name -> Some(AmbiguousKnob("sma_cross.sma.length")) + +[1b] un-named cross compile_with_params err: Some(IndistinguishableFanIn { node: 2 }) + -> IndistinguishableFanIn at interior node index 2 (the Sub that fans in both un-named SMAs) + +[2] named cross bootstrap ok: true +[2] named run recorded 12 rows + +OK: un-named fan-in rejected, naming the legs is the single recovering act. diff --git a/fieldtests/cycle-0031-node-naming/out_3_default_names.txt b/fieldtests/cycle-0031-node-naming/out_3_default_names.txt new file mode 100644 index 0000000..f0e5566 --- /dev/null +++ b/fieldtests/cycle-0031-node-naming/out_3_default_names.txt @@ -0,0 +1,10 @@ +default-name param_space(): + sma.length : I64 + exposure.scale : F64 +simbroker-prefixed knobs (verbatim-lowercase check): [] + +default-named run recorded 10 rows + +paramless same-name fan-in compile ok: true + +OK: default names = lowercased type label, root-level qualified, paramless legal.