fieldtest: cycle 0031 node-naming — naming cure under-signposted on the by-name flow
Per-cycle fieldtest of node-instance naming (#56), driven as a downstream consumer from the public interface only (ledger + spec + rustdoc + CLI; no crates/*/src read), three fixtures built and run from HEAD. Verdict: the core 0031 promise holds first-try. A consumer authors Sma::builder().named("fast"), inspects param_space() (sma_cross.fast.length), binds by name and runs; the default-name case (sma.length / exposure.scale, verbatim lowercase) and paramless-interchangeable-stays-legal both hold. 0 bugs, 3 working, 2 friction, 1 spec_gap. The one real gap (verified by the orchestrator against the fixture output): the spec's headline forcing function IndistinguishableFanIn does NOT reach an author on the canonical .with(...).bootstrap() flow. An un-named 2-SMA cross emits a literal DUPLICATE knob (sma_cross.sma.length x2); the binder resolves names before the compile fan-in check, so the author hits UnknownKnob / AmbiguousKnob("sma_cross.sma.length") — which point at the knob, not at the cure "name your nodes". IndistinguishableFanIn only surfaces via the positional compile_with_params path. Rejection still happens (no invalid blueprint runs), so it is an ergonomic/signposting gap, not a correctness bug. Routed to the backlog (relates to #58); 0031 stays audit-closed. Minor: FlatGraph/Harness lack Debug, so a bootstrap Result can't be {:?}-printed.
This commit is contained in:
@@ -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
|
||||
`<node>.<param>` 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 <name>` 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 `<node>.<param>` → 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<FlatGraph, CompileError>`
|
||||
and `bootstrap` returns `Result<Harness, CompileError>`; 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 `<node>.<param>`, 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
|
||||
`<node>.<param>` 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.
|
||||
+99
@@ -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"
|
||||
@@ -0,0 +1,34 @@
|
||||
# Standalone downstream-consumer crate for the cycle-0031 fieldtest
|
||||
# (node-instance naming: `.named()`, uniform `<node>.<param>` 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 <name>
|
||||
# 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"
|
||||
@@ -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
|
||||
// `<node>.<param>` 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<Scalar>)>) -> 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 <node>.<param>, 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<Scalar>)>();
|
||||
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 <node>.<param> names and bootstraps");
|
||||
|
||||
h.run(vec![synthetic_prices()]);
|
||||
drop(h);
|
||||
|
||||
let rows: Vec<(Timestamp, Vec<Scalar>)> = 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<Scalar>)>();
|
||||
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<Scalar>)> = rx2.iter().collect();
|
||||
assert_eq!(rows, rows2, "two disjoint named runs must be bit-identical (C1)");
|
||||
|
||||
println!("\nOK: param-space uniform <node>.<param>, named bind ran, deterministic.");
|
||||
}
|
||||
@@ -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<Scalar>)>,
|
||||
) -> 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<Scalar>)>();
|
||||
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."
|
||||
);
|
||||
}
|
||||
@@ -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 `<lowercased-type>.<param>` — `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<Scalar>)>) -> 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<Scalar>)>();
|
||||
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.");
|
||||
}
|
||||
@@ -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 <node>.<param>, named bind ran, deterministic.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user