diff --git a/docs/specs/fieldtest-milestone-construction-layer.md b/docs/specs/fieldtest-milestone-construction-layer.md new file mode 100644 index 0000000..c5051fe --- /dev/null +++ b/docs/specs/fieldtest-milestone-construction-layer.md @@ -0,0 +1,246 @@ +# Fieldtest — milestone "Construction layer" — 2026-06-05 + +**Status:** Draft — awaiting orchestrator triage +**Author:** fieldtester (dispatched by fieldtest skill, milestone-scope variant) +**Scope mode:** MILESTONE (closing functional gate), not per-cycle. + +## Scope + +The "Construction layer" milestone (cycles 0012 composites + 0013 render) +promises that a downstream researcher can **build a graph fractally from a Rust +builder** — author leaf nodes and reusable *named composites* (a sub-graph +exposing one output), nest them arbitrarily, and `compile()` / `bootstrap()` the +param-generic `Blueprint` down to the flat, deterministic runnable instance +(C9/C19/C23). They can **introspect** the built graph as data and **render** it +as an ASCII DAG (`aura graph` clustered authored view, `aura graph --compiled` +flat inlined view) so the structure — and any mis-wiring — is visible +(C14/C22, #13). The composite boundary dissolves at inline (C23); a +composite-authored graph runs bit-identical to the hand-wired flat equivalent +(C1). + +This fieldtest derives four end-to-end scenarios top-down from that promise, one +per carrier axis (a–d), and drives them from the **public interface only**: the +design ledger (`docs/design/INDEX.md`), the crate doc-comments (rendered via +`cargo doc --no-deps`), the public re-export lists, and the `aura` CLI. **No +`crates/*/src` was read.** The construction-layer API was discovered entirely +from the ledger + rustdoc. The artefact under test was built from HEAD +(`cargo build --workspace`; the consumer crate compiled against the current +path-dep'd engine crates; the CLI driven via `cargo run -p aura-cli`, so HEAD is +always what ran). + +## Examples + +### fieldtests/milestone-construction-layer/mc_1_composite_build_run.rs — author + compile + run a named composite +- A standalone consumer crate (own `[workspace]`, path-deps on the engine + crates — the shape a C16 project takes) authors an SMA(2)/SMA(4)-cross as a + reusable named `Composite` (`sma_cross`, one price role fanning into both + SMAs, Sub's output as the exposed port), wires it into a harness `Blueprint` + (`source → composite → Exposure(0.5) → SimBroker(1e-4) → Recorder`), + `bootstrap()`s, and `run()`s it over a synthetic price ramp. +- Fits the milestone: this is the headline authoring story — build a graph + fractally from a Rust builder via a named composite, then compile/bootstrap/run + it. +- Outcome: built? **yes, first try.** ran? yes. matched expected? **yes** — 12 + recorded equity rows, non-degenerate (`equity_pips` ramps 0 → 11.5 → −6.5), + the composite-authored harness produces a populated trace. + +### fieldtests/milestone-construction-layer/mc_2_miswire_render.rs — a fast/slow swap is observable in graph-as-data + labels +- Builds the `sma_cross` composite twice — correct (fast→Sub.slot0, + slow→Sub.slot1) and swapped — and asserts the per-node `label()`s carry the + disambiguating params (`SMA(2)` / `SMA(4)`) and that the interior edge table + **differs** between the two. The property under test is the one the render + relies on: if a swap produced identical graph-as-data, no renderer could + surface it. +- Fits the milestone: the headline promise is "the render visibly surfaces the + mistake." This verifies the consumer-side substrate of that promise (labels + + edge table), complementing the `aura graph` CLI render exercised below. +- Outcome: built? yes. ran? yes. matched expected? **yes** — labels read + `["SMA(2)", "SMA(4)", "Sub"]`; correct edges `[{0→2.s0},{1→2.s1}]` vs swapped + `[{1→2.s0},{0→2.s1}]` — observably different. + +### fieldtests/milestone-construction-layer/mc_3_nested_composite.rs — composite-inside-composite inlines, runs +- Nests the `sma_cross` composite inside an outer `strategy` composite (which + maps it through `Exposure(0.5)`), wires `strategy → SimBroker → Recorder`, + `bootstrap()`s and `run()`s. +- Fits the milestone: "nest composites arbitrarily" + "the compiled/flat view + handles nesting." This verifies the recursive-inline run-path from the + consumer side (the known sharp edge #26 is about the *clustered render*, not + the run path — see findings). +- Outcome: built? yes. ran? yes. matched expected? **yes** — the nested + composite recursively inlined to a flat runnable instance; 12 recorded rows. + +### fieldtests/milestone-construction-layer/mc_4_introspect_graph.rs — walk the graph-as-data via read-only accessors +- Builds a `Blueprint` (composite + Exposure) and walks it purely through the + public accessors: `Blueprint::nodes()/sources()/edges()` and + `Composite::name()/nodes()/edges()/input_roles()/output()`, plus `label()` on + each boxed leaf, recursing into the composite interior. +- Fits the milestone: "introspect the built graph as data" (C9/C14) — can a + consumer walk it without engine internals? +- Outcome: built? yes (after one import fix — see finding). ran? yes. matched + expected? **yes** — the full interior (3 items, 1 role fanning to 2 targets, + Sub's output port, 2 interior edges) is reachable through the public accessors + alone. + +### Render artefacts (CLI, built-in sample) — render_clustered.txt / render_compiled.txt +- `aura graph` and `aura graph --compiled` run live (via `cargo run -p + aura-cli`), captured as artefacts. Both render the engine's **built-in sample + blueprint** (the same SMA-cross harness). Byte-identical across two runs (C1 + for the render). + +## Findings + +### [working] The full composite authoring → compile → bootstrap → run thread is authorable from the public interface +- Examples: mc_1, mc_3. +- What happened: the entire construction-layer authoring story — + `Composite::new(name, nodes, edges, input_roles, output)`, + `BlueprintNode::from(node)` / `BlueprintNode::Composite(..)`, + `Blueprint::new(..).bootstrap()`, `Harness::run` — compiled on the **first + attempt** from rustdoc signatures + the ledger (C9/C19/C23) alone, for both a + single composite and a composite-in-composite. Both produced populated, + non-degenerate recorded traces. +- Why working: the milestone's central promise — build a graph fractally from a + Rust builder and compile/bootstrap/run it — is delivered, and the public + surface (ledger + rustdoc) is self-sufficient for a downstream author. The + recursive inline of a nested composite works end to end on the run path. +- Recommended action: carry-on. + +### [working] The graph-as-data accessors let a consumer walk the built graph (incl. composite interiors) with no engine internals +- Example: mc_4. +- What happened: `Blueprint::{nodes,sources,edges}` and + `Composite::{name,nodes,edges,input_roles,output}` together expose the whole + graph as read-only data; a recursive walk reaches every leaf label, every + role fan-out, the output port, and every interior edge. `node.label()` + dispatches off the boxed leaf without importing the `Node` trait. +- Why working: C9 "the built graph is introspectable runtime data" and the #13 + graph-as-data promise hold from the consumer side. +- Recommended action: carry-on. + +### [working] Per-node labels disambiguate identical node types, and a mis-wire changes the graph-as-data +- Examples: mc_2, render_clustered.txt. +- What happened: `label()` overrides carry params (`SMA(2)` vs `SMA(4)`), so the + two SMAs are distinguishable; a fast/slow swap changes the interior edge + table. The `aura graph` clustered render shows both SMAs feeding `Sub` with + the labels present, so a reader can trace which leg goes where. Render is + byte-identical across runs (C1). +- Why working: the C8-refinement label mechanism does exactly what the milestone + needs — it makes a swap *readable*. This is the substrate of the headline + "render surfaces a mis-wiring" promise. +- Recommended action: carry-on. + +### [friction] `aura graph` renders ONLY the built-in sample — a consumer cannot render the graph they authored +- Examples: mc_1/mc_2/mc_3 (authored blueprints) vs the CLI render artefacts. +- What happened: `aura graph` and `aura graph --compiled` are hard-wired to one + built-in sample blueprint. Any extra argument is silently ignored: + `aura graph --bogus`, `aura graph mc_1_composite_build_run.rs`, and even + `aura graph --help` all render the **same** built-in sample and exit 0. There + is no path (flag, file, stdin) to point the renderer at a consumer's own + `Blueprint`. +- Why friction: the milestone's headline payoff — "render the structure so any + **mis-wiring** is visible before/around a run" — is, at the CLI level, only + available for the engine's own sample. A downstream researcher who authored + `mc_2`'s *swapped* cross (the literal mis-wire the feature exists to catch) + cannot feed it to `aura graph`; they fall back to introspecting the graph-as-data + by hand (mc_2/mc_4) or writing their own ascii-dag adapter (which lives only in + `aura-cli`, not a reusable surface). The render-as-a-library affordance that + would let a consumer render their own graph is absent. Task completed (the + render exists and is correct), but the consumer is forced into a hand-rolled + workaround to apply it to their own work. +- Recommended action: plan — expose a render entry point a consumer can call on + their own `Blueprint` (a library `render(&Blueprint) -> String` in a face + crate, or `aura graph` accepting a project/blueprint selector). Pairs naturally + with the deferred `aura new` / experiment-builder surface. + +### [friction] The construction surface (aura-engine) does not re-export the core scalar vocabulary a graph-builder needs +- Example: mc_4 (initial `use aura_engine::{… ScalarKind …}` failed to compile: + "no `ScalarKind` in the root … consider importing `aura_core::ScalarKind`"). +- What happened: building a `Blueprint`/`SourceSpec`/`Recorder` requires + `ScalarKind` (and, for sources/recorders, `Scalar` / `Firing` / `Timestamp`), + but `aura-engine` — the crate that owns `Blueprint`, `Composite`, `Edge`, + `Target`, `SourceSpec` — re-exports none of them. `SourceSpec { kind: + ScalarKind, .. }` is an `aura-engine` struct whose own field type must be + imported from a *different* crate (`aura-core`). A downstream author must know + to reach across crate boundaries for the vocabulary the engine's own public + types demand. +- Why friction: it is a paper cut, not a blocker — the compiler error names the + right import — but it makes the "build a graph from a Rust builder" surface + more than one `use aura_engine::*`. The construction API and the scalar + vocabulary it consumes live in two crates with no convenience re-export. +- Recommended action: plan (tidy) — re-export the core scalar vocabulary + (`Scalar`, `ScalarKind`, `Firing`, `Timestamp`) from `aura-engine` (or a + prelude) so a graph-builder has a single import surface. Low priority. + +### [spec_gap] `aura graph --help` / unknown args → render the sample, exit 0 (no help, args silently swallowed) +- Examples: render probes — `aura graph --help` and `aura graph --bogus` both + print the clustered sample and exit 0. +- What happened: the `graph` subcommand keys only on the presence of the exact + `--compiled` token; every other argument (including `--help`) falls through to + the clustered view. No usage/help text, no rejection of unknown flags. +- Why spec_gap: the milestone/CLI contract does not pin how `aura graph` handles + unknown or `--help` arguments; the binary chose the lenient "ignore and render + the default view" reading. An equally plausible reading rejects unknown flags + (exit 2) and prints help on `--help`. This re-confirms, on the new `graph` + subcommand, the same lenient-arg gap the walking-skeleton fieldtest recorded + for `aura run ` and `aura --help`. +- Recommended action: ratify-or-tighten — decide a single CLI arg policy + (reject unknown flags + `--help`→stdout/exit 0) across **both** `run` and + `graph`, rather than per-subcommand. Low priority; carried/expanded from the + walking-skeleton finding. + +### [spec_gap] The known nested-composite render panic (#26) is unreachable from the public CLI today — its consumer impact is latent, not live +- Example: mc_3 (authored a nested composite; could not feed it to `aura graph`). +- What happened: the carrier flags #26 — the clustered blueprint view has an + `unimplemented!` on nested composites — as a sharp edge to classify. As a + downstream consumer I **could not reach it**: `aura graph` renders only the + built-in single-level sample (see the friction above), so there is no public + surface that feeds a *nested* blueprint into the clustered renderer. The panic + is real in the code but **structurally unreachable** from the shipped CLI; the + run-path nesting (mc_3) works fine. +- Why spec_gap: the milestone promise says the render makes nesting visible, but + the only render entry point is the fixed sample, so "render a nested composite" + is not actually exercisable by a consumer — the gap and the latent panic are + two faces of the missing "render my own graph" affordance. Severity of #26 as a + *consumer-facing* bug is currently **low/latent** (no public trigger), but it + becomes **live** the moment the render is made parameterizable (the friction + above) — at which point a consumer rendering a nested clustered view would + panic. +- Recommended action: tighten the design ledger / sequence #26 — record that the + "render my own graph" affordance (the friction above) and the #26 clustered + nesting fix are **coupled**: shipping the former without the latter exposes the + panic to consumers. The audit's own note ("resolve #26 before the next + render-touching cycle") already points this way; this fieldtest confirms the + coupling empirically. Classify #26 as the blocker *for the parameterizable-render + cycle*, not for this milestone (which ships only the sample render). + +## Recommendation summary + +| Finding | Class | Action | +|---|---|---| +| Composite authoring → compile → bootstrap → run thread authorable, populated | working | carry-on | +| Graph-as-data accessors walk the built graph (incl. interiors) | working | carry-on | +| Labels disambiguate; a swap changes graph-as-data; render deterministic | working | carry-on | +| `aura graph` renders only the built-in sample (no render-my-own-graph) | friction | plan | +| aura-engine doesn't re-export the core scalar vocabulary | friction | plan (tidy) | +| `aura graph --help` / unknown args → render sample, exit 0 | spec_gap | ratify-or-tighten (unify CLI arg policy) | +| #26 nested-render panic unreachable from CLI today (latent, coupled to render-my-own-graph) | spec_gap | tighten ledger / sequence #26 with the parameterizable-render cycle | + +## Milestone roll-up + +**The "Construction layer" milestone DELIVERS its core promise.** A downstream +researcher can author leaf nodes and reusable named composites, nest them +arbitrarily, and `compile()`/`bootstrap()`/`run()` the param-generic `Blueprint` +down to a flat, deterministic, populated-trace instance (C9/C19/C23) — all from +the public interface, first try, including a composite-in-composite. The +graph-as-data accessors let a consumer walk the built graph without engine +internals, and the C8-refinement labels make a mis-wire readable in both the +graph-as-data and the `aura graph` render. + +The gap is in the **render's reach**, not its correctness: `aura graph` is +hard-wired to one built-in sample, so the headline "render any mis-wiring before +a run" payoff is, at the CLI level, only demonstrated on the engine's own +example — a consumer cannot point it at the graph they authored, which is exactly +where a mis-wire would live. That single missing affordance ("render my own +graph") subsumes both the friction and the latent reachability of the #26 +nested-render panic: making the render parameterizable is the next step, and #26 +must land with it. **No bugs found** (the #26 panic is real in code but +structurally unreachable from the public surface today). Three `working`, two +`friction`, two `spec_gap` — none blocking the milestone gate. diff --git a/fieldtests/milestone-construction-layer/Cargo.lock b/fieldtests/milestone-construction-layer/Cargo.lock new file mode 100644 index 0000000..993e347 --- /dev/null +++ b/fieldtests/milestone-construction-layer/Cargo.lock @@ -0,0 +1,30 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aura-core" +version = "0.1.0" + +[[package]] +name = "aura-engine" +version = "0.1.0" +dependencies = [ + "aura-core", +] + +[[package]] +name = "aura-std" +version = "0.1.0" +dependencies = [ + "aura-core", +] + +[[package]] +name = "ms-construction-layer-fieldtest" +version = "0.0.0" +dependencies = [ + "aura-core", + "aura-engine", + "aura-std", +] diff --git a/fieldtests/milestone-construction-layer/Cargo.toml b/fieldtests/milestone-construction-layer/Cargo.toml new file mode 100644 index 0000000..d5bbb45 --- /dev/null +++ b/fieldtests/milestone-construction-layer/Cargo.toml @@ -0,0 +1,39 @@ +# Milestone fieldtest consumer crate — Construction layer. +# +# A standalone downstream project (the shape a C16 research project takes): +# path-deps on the engine crates only. It drives the construction layer +# (Blueprint / Composite / compile / bootstrap / run) and the graph-as-data +# accessors from the PUBLIC interface only — no crates/*/src was read; the +# API was discovered from the design ledger (docs/design/INDEX.md) + rustdoc +# doc-comments + the public re-exports. +# +# Empty [workspace] table: this fixture crate is its OWN workspace root, so it +# is not pulled into the aura engine workspace. +[workspace] + +[package] +name = "ms-construction-layer-fieldtest" +edition = "2024" +version = "0.0.0" +publish = false + +[[bin]] +name = "composite_build_run" +path = "mc_1_composite_build_run.rs" + +[[bin]] +name = "miswire_render" +path = "mc_2_miswire_render.rs" + +[[bin]] +name = "nested_composite" +path = "mc_3_nested_composite.rs" + +[[bin]] +name = "introspect_graph" +path = "mc_4_introspect_graph.rs" + +[dependencies] +aura-core = { path = "../../crates/aura-core" } +aura-std = { path = "../../crates/aura-std" } +aura-engine = { path = "../../crates/aura-engine" } diff --git a/fieldtests/milestone-construction-layer/mc_1_composite_build_run.rs b/fieldtests/milestone-construction-layer/mc_1_composite_build_run.rs new file mode 100644 index 0000000..2355da1 --- /dev/null +++ b/fieldtests/milestone-construction-layer/mc_1_composite_build_run.rs @@ -0,0 +1,111 @@ +// Milestone fieldtest — axis (a): author a small signal as a reusable NAMED +// Composite, build a Blueprint that uses it, compile() + bootstrap() + run(), +// and confirm the recorded trace is non-empty. +// +// Public interface only: Blueprint / Composite / BlueprintNode / Edge / Target / +// OutPort / SourceSpec from aura-engine; Sma / Sub / Exposure / SimBroker / +// Recorder from aura-std; ScalarKind / Scalar / Firing / Timestamp from +// aura-core. No crates/*/src was read. +// +// The downstream task: package an SMA(2)/SMA(4)-cross into a reusable +// `sma_cross` composite exposing one output (the cross spread), then wire that +// composite into a harness blueprint (source -> composite -> Exposure -> +// SimBroker -> Recorder), bootstrap it, and run it over a tiny synthetic price +// ramp. This is the milestone's headline authoring story: build a graph +// fractally from a Rust builder via a named composite. + +use std::sync::mpsc; + +use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; +use aura_engine::{Blueprint, BlueprintNode, Composite, Edge, OutPort, SourceSpec, Target}; +use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub}; + +fn main() { + // --- The reusable composite: a 2/4 SMA cross. ---------------------------- + // Interior layout (local indices): + // 0: Sma(2) (fast) 1: Sma(4) (slow) 2: Sub (fast - slow) + // One input role (the price) fans into BOTH SMAs' slot 0. + // The exposed output port is Sub's single output field. + let sma_cross = Composite::new( + "sma_cross", + vec![ + BlueprintNode::from(Sma::new(2)), + BlueprintNode::from(Sma::new(4)), + BlueprintNode::from(Sub::new()), + ], + // interior edges (local indices): fast -> Sub.slot0, slow -> Sub.slot1 + vec![ + Edge { from: 0, to: 2, slot: 0, from_field: 0 }, + Edge { from: 1, to: 2, slot: 1, from_field: 0 }, + ], + // input role 0 (price) fans into Sma(2).slot0 AND Sma(4).slot0 + vec![vec![ + Target { node: 0, slot: 0 }, + Target { node: 1, slot: 0 }, + ]], + // single exposed output: Sub's output field 0 + OutPort { node: 2, field: 0 }, + ); + + // --- The harness blueprint that USES the composite. ---------------------- + // Top-level items: + // 0: sma_cross (Composite) 1: Exposure(0.5) 2: SimBroker(1e-4) + // 3: Recorder over [F64] (taps the broker equity) + let (tx, rx) = mpsc::channel::<(Timestamp, Vec)>(); + let blueprint = Blueprint::new( + vec![ + BlueprintNode::Composite(sma_cross), + BlueprintNode::from(Exposure::new(0.5)), + BlueprintNode::from(SimBroker::new(1e-4)), + BlueprintNode::from(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)), + ], + // one f64 price source: feeds the composite (item 0, role 0) AND the + // broker's price leg (item 2, slot 1). + vec![SourceSpec { + kind: ScalarKind::F64, + targets: vec![ + Target { node: 0, slot: 0 }, // composite price role + Target { node: 2, slot: 1 }, // broker price leg + ], + }], + // top-level edges: composite-out -> Exposure -> broker.exposure(slot0) + // -> Recorder + vec![ + Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // sma_cross -> Exposure + Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // Exposure -> SimBroker.exposure + Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // SimBroker -> Recorder + ], + ); + + // --- compile() + bootstrap() + run() ------------------------------------- + let mut harness = blueprint + .bootstrap() + .expect("composite-authored blueprint should bootstrap"); + + // a synthetic upward price ramp then a dip, enough to warm SMA(4) and flip + let prices: Vec = vec![ + 1.00, 1.01, 1.02, 1.03, 1.05, 1.08, 1.06, 1.04, 1.02, 1.01, 1.03, 1.07, + ]; + let stream: Vec<(Timestamp, Scalar)> = prices + .iter() + .enumerate() + .map(|(i, &p)| (Timestamp(60_000_000_000 * i as i64), Scalar::F64(p))) + .collect(); + harness.run(vec![stream]); + drop(harness); // drop the recorder's tx clone held inside + + let rows: Vec<(Timestamp, Vec)> = rx.iter().collect(); + println!("recorded equity rows: {}", rows.len()); + for (ts, row) in &rows { + let v = match row[0] { + Scalar::F64(x) => x, + other => panic!("expected f64 equity, got {other:?}"), + }; + println!(" ts={:>14} equity_pips={:.4}", ts.0, v); + } + assert!( + !rows.is_empty(), + "the composite-authored harness must record a populated equity trace" + ); + println!("OK: composite authored, compiled, bootstrapped, ran, recorded."); +} diff --git a/fieldtests/milestone-construction-layer/mc_2_miswire_render.rs b/fieldtests/milestone-construction-layer/mc_2_miswire_render.rs new file mode 100644 index 0000000..2e599ad --- /dev/null +++ b/fieldtests/milestone-construction-layer/mc_2_miswire_render.rs @@ -0,0 +1,90 @@ +// Milestone fieldtest — axis (b): deliberately mis-wire a graph (swap the +// fast/slow SMA legs of the cross) and confirm the wiring reads back +// differently — the milestone's headline promise that a render visibly +// surfaces a mis-wiring. +// +// The headline render is the `aura graph` CLI (exercised separately, in the +// accompanying .sh-free flow: see the spec). HERE we test the property the +// render RELIES ON from the consumer side: that the labels + graph-as-data of a +// correctly-wired composite differ observably from a swapped one. If a swap +// produced an identical graph-as-data, no renderer could ever surface it. +// +// Public interface only: Composite / Blueprint accessors + Node::label() via +// the boxed node; ledger C8-refinement (label is the disambiguating symbol). + +use aura_engine::{BlueprintNode, Composite, Edge, OutPort, Target}; +// NB: `node.label()` on a `&Box` leaf dispatches via the trait object +// without an explicit `use aura_core::Node` (the method is in scope through the +// boxed trait object). A consumer does not need to import the Node trait to read +// labels off a built graph. +use aura_std::{Sma, Sub}; + +/// Build an SMA-cross composite. `swap` flips which SMA feeds Sub's slot 0. +/// Correct: fast(SMA2) -> minuend(slot0), slow(SMA4) -> subtrahend(slot1). +/// Swapped: slow -> slot0, fast -> slot1 (the classic fast/slow mis-wire). +fn cross(swap: bool) -> Composite { + let (e_fast, e_slow) = if swap { + // slow(node 1) into slot0, fast(node 0) into slot1 + ( + Edge { from: 1, to: 2, slot: 0, from_field: 0 }, + Edge { from: 0, to: 2, slot: 1, from_field: 0 }, + ) + } else { + ( + Edge { from: 0, to: 2, slot: 0, from_field: 0 }, + Edge { from: 1, to: 2, slot: 1, from_field: 0 }, + ) + }; + Composite::new( + "sma_cross", + vec![ + BlueprintNode::from(Sma::new(2)), + BlueprintNode::from(Sma::new(4)), + BlueprintNode::from(Sub::new()), + ], + vec![e_fast, e_slow], + vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]], + OutPort { node: 2, field: 0 }, + ) +} + +fn node_labels(c: &Composite) -> Vec { + c.nodes() + .iter() + .map(|n| match n { + BlueprintNode::Leaf(node) => node.label(), + BlueprintNode::Composite(inner) => format!("composite:{}", inner.name()), + }) + .collect() +} + +fn main() { + let correct = cross(false); + let swapped = cross(true); + + // The per-node labels carry identifying params (SMA(2) vs SMA(4)) so two + // SMAs are distinguishable in a render. + println!("correct labels: {:?}", node_labels(&correct)); + println!("swapped labels: {:?}", node_labels(&swapped)); + + // The mis-wire lives in the EDGE table; the graph-as-data must reflect it. + let ce: Vec = correct.edges().to_vec(); + let se: Vec = swapped.edges().to_vec(); + println!("correct interior edges: {ce:?}"); + println!("swapped interior edges: {se:?}"); + + // Headline property: the two graphs-as-data differ. A renderer reading the + // edge table + labels can therefore make the swap visible. + assert_ne!( + ce, se, + "a fast/slow swap MUST change the graph-as-data, else no render could surface it" + ); + + // And the disambiguating labels are present (SMA(2)/SMA(4) distinguishable). + let labels = node_labels(&correct); + assert!( + labels.iter().any(|l| l.contains("SMA(2)")) && labels.iter().any(|l| l.contains("SMA(4)")), + "labels must carry SMA params so a swapped leg reads differently: {labels:?}" + ); + println!("OK: a fast/slow swap is observable in graph-as-data + labels."); +} diff --git a/fieldtests/milestone-construction-layer/mc_3_nested_composite.rs b/fieldtests/milestone-construction-layer/mc_3_nested_composite.rs new file mode 100644 index 0000000..5902e64 --- /dev/null +++ b/fieldtests/milestone-construction-layer/mc_3_nested_composite.rs @@ -0,0 +1,100 @@ +// Milestone fieldtest — axis (c): nest a composite INSIDE another composite, +// build a Blueprint, compile() + bootstrap() + run(). The milestone promises +// composites "nest arbitrarily" and that the compiled/flat view handles +// nesting (#26 notes the CLUSTERED blueprint render has an `unimplemented!` on +// nested composites — but that path is only reachable through `aura graph` on +// the built-in sample, which a downstream consumer cannot point at their own +// graph; see the spec finding). +// +// Here we verify the run-path promise from the consumer side: a composite that +// CONTAINS a composite lowers (inlines, recursively) to a flat runnable +// instance that produces a populated trace. +// +// Public interface only. + +use std::sync::mpsc; + +use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; +use aura_engine::{Blueprint, BlueprintNode, Composite, Edge, OutPort, SourceSpec, Target}; +use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub}; + +/// Inner composite: the SMA(2)/SMA(4) cross (one price role -> spread output). +fn inner_cross() -> Composite { + Composite::new( + "sma_cross", + vec![ + BlueprintNode::from(Sma::new(2)), + BlueprintNode::from(Sma::new(4)), + BlueprintNode::from(Sub::new()), + ], + vec![ + Edge { from: 0, to: 2, slot: 0, from_field: 0 }, + Edge { from: 1, to: 2, slot: 1, from_field: 0 }, + ], + vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]], + OutPort { node: 2, field: 0 }, + ) +} + +/// Outer composite: wraps inner_cross then maps it through Exposure(0.5). +/// Interior: 0 = inner_cross (Composite), 1 = Exposure(0.5). +/// One price role fans into the inner composite's role 0. +/// Output: Exposure's output field. +fn strategy() -> Composite { + Composite::new( + "strategy", + vec![ + BlueprintNode::Composite(inner_cross()), + BlueprintNode::from(Exposure::new(0.5)), + ], + vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], // cross -> Exposure + vec![vec![Target { node: 0, slot: 0 }]], // price -> inner role 0 + OutPort { node: 1, field: 0 }, + ) +} + +fn main() { + let (tx, rx) = mpsc::channel::<(Timestamp, Vec)>(); + // Top-level: 0 = strategy (nested Composite), 1 = SimBroker, 2 = Recorder. + let blueprint = Blueprint::new( + vec![ + BlueprintNode::Composite(strategy()), + BlueprintNode::from(SimBroker::new(1e-4)), + BlueprintNode::from(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)), + ], + vec![SourceSpec { + kind: ScalarKind::F64, + targets: vec![ + Target { node: 0, slot: 0 }, // strategy price role + Target { node: 1, slot: 1 }, // broker price leg + ], + }], + vec![ + Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // strategy -> broker.exposure + Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // broker -> Recorder + ], + ); + + let mut harness = blueprint + .bootstrap() + .expect("nested-composite blueprint should bootstrap"); + + let prices: Vec = vec![ + 1.00, 1.01, 1.02, 1.03, 1.05, 1.08, 1.06, 1.04, 1.02, 1.01, 1.03, 1.07, + ]; + let stream: Vec<(Timestamp, Scalar)> = prices + .iter() + .enumerate() + .map(|(i, &p)| (Timestamp(60_000_000_000 * i as i64), Scalar::F64(p))) + .collect(); + harness.run(vec![stream]); + drop(harness); + + let rows: Vec<(Timestamp, Vec)> = rx.iter().collect(); + println!("nested-composite recorded rows: {}", rows.len()); + assert!( + !rows.is_empty(), + "a composite-in-composite must inline to a flat runnable instance" + ); + println!("OK: composite-inside-composite inlined, bootstrapped, ran, recorded."); +} diff --git a/fieldtests/milestone-construction-layer/mc_4_introspect_graph.rs b/fieldtests/milestone-construction-layer/mc_4_introspect_graph.rs new file mode 100644 index 0000000..a123f28 --- /dev/null +++ b/fieldtests/milestone-construction-layer/mc_4_introspect_graph.rs @@ -0,0 +1,112 @@ +// Milestone fieldtest — axis (d): introspect a built Blueprint via the +// read-only graph-as-data accessors — can a consumer walk the graph as data +// WITHOUT reading engine internals? +// +// Accessors under test (all public, from rustdoc): +// Blueprint::nodes() / sources() / edges() +// Composite::name() / nodes() / edges() / input_roles() / output() +// BlueprintNode::{Leaf, Composite}; Node::label() on a boxed leaf +// +// Public interface only. + +use aura_core::ScalarKind; +use aura_engine::{Blueprint, BlueprintNode, Composite, Edge, OutPort, SourceSpec, Target}; +// NB (fieldtest finding): `aura_engine::ScalarKind` does NOT resolve — the +// construction surface (aura-engine) does not re-export the core scalar +// vocabulary (ScalarKind / Scalar / Firing) that a graph-builder needs. A +// downstream author building a Blueprint must reach into aura_core directly. +// Recorded as friction in the spec. `node.label()` below dispatches on the +// boxed leaf via the Node trait object without an explicit `use` of Node. + +fn sma_cross() -> Composite { + Composite::new( + "sma_cross", + vec![ + BlueprintNode::from(aura_std::Sma::new(2)), + BlueprintNode::from(aura_std::Sma::new(4)), + BlueprintNode::from(aura_std::Sub::new()), + ], + vec![ + Edge { from: 0, to: 2, slot: 0, from_field: 0 }, + Edge { from: 1, to: 2, slot: 1, from_field: 0 }, + ], + vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]], + OutPort { node: 2, field: 0 }, + ) +} + +fn describe_node(prefix: &str, n: &BlueprintNode) { + match n { + BlueprintNode::Leaf(node) => { + let sch = node.schema(); + println!( + "{prefix}LEAF {:<14} inputs={} output_fields={}", + node.label(), + sch.inputs.len(), + sch.output.len() + ); + } + BlueprintNode::Composite(c) => { + println!( + "{prefix}COMPOSITE name={:?} interior_items={} roles={} out=({},{})", + c.name(), + c.nodes().len(), + c.input_roles().len(), + c.output().node, + c.output().field, + ); + for inner in c.nodes() { + describe_node(&format!("{prefix} "), inner); + } + for (r, targets) in c.input_roles().iter().enumerate() { + println!("{prefix} role[{r}] fans into {targets:?}"); + } + for e in c.edges() { + println!("{prefix} interior edge {e:?}"); + } + } + } +} + +fn main() { + let bp = Blueprint::new( + vec![ + BlueprintNode::Composite(sma_cross()), + BlueprintNode::from(aura_std::Exposure::new(0.5)), + ], + vec![SourceSpec { + kind: ScalarKind::F64, + targets: vec![Target { node: 0, slot: 0 }], + }], + vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], + ); + + println!("== Blueprint graph-as-data walk =="); + println!("sources: {}", bp.sources().len()); + for (i, s) in bp.sources().iter().enumerate() { + println!(" source[{i}] kind={:?} -> {:?}", s.kind, s.targets); + } + println!("top-level items: {}", bp.nodes().len()); + for n in bp.nodes() { + describe_node(" ", n); + } + println!("top-level edges: {}", bp.edges().len()); + for e in bp.edges() { + println!(" edge {e:?}"); + } + + // The walk must reach the interior of the composite purely through the + // public accessors — no engine internals. + let first = &bp.nodes()[0]; + if let BlueprintNode::Composite(c) = first { + assert_eq!(c.name(), "sma_cross"); + assert_eq!(c.nodes().len(), 3, "composite interior should have 3 items"); + assert_eq!(c.input_roles().len(), 1, "one price role"); + assert_eq!(c.input_roles()[0].len(), 2, "price fans into both SMAs"); + let out = c.output(); + assert_eq!((out.node, out.field), (2, 0), "Sub output is the port"); + println!("OK: walked the composite interior via public accessors only."); + } else { + panic!("expected composite as first item"); + } +} diff --git a/fieldtests/milestone-construction-layer/render_clustered.txt b/fieldtests/milestone-construction-layer/render_clustered.txt new file mode 100644 index 0000000..cf53bec --- /dev/null +++ b/fieldtests/milestone-construction-layer/render_clustered.txt @@ -0,0 +1,24 @@ + [source:F64] + ┌─────────└┐────────┐ + │ │ │ + ╔═════╪══════════╪════╗ │ + ║ sma_cross │ ║ │ + ║ ↓ ↓ ║ │ + ║ [SMA(2)] [SMA(4)] ║ │ + ║ └──┌───────┘ ║ │ + ║ ↓ ┌─╫───┘ + ║ [Sub] │ ║ + ║ │ │ ║ + ╚════════╪══════════╪═╝ + │ │ + ↓ │ + [Exposure(0.5)] │ + ┌───────┘────────┐ │ + ↓ ↓─┘ +[Recorder] [SimBroker(0.0001)] + │ + ↓ + [Recorder] + + + diff --git a/fieldtests/milestone-construction-layer/render_compiled.txt b/fieldtests/milestone-construction-layer/render_compiled.txt new file mode 100644 index 0000000..8eacdc1 --- /dev/null +++ b/fieldtests/milestone-construction-layer/render_compiled.txt @@ -0,0 +1,19 @@ + [source:F64] + ┌────────└─┐──────┐ + ↓ ↓ │ + [SMA(2)] [SMA(4)] │ + └────┌─────┘ │ + ↓ ┌──────┘ + [Sub] │ + │ │ + ↓ └────┐ + [Exposure(0.5)] │ + ┌──────┘─────────┐│ + ↓ ↓┘ + [Recorder] [SimBroker(0.0001)] + ┌─────┘ + ↓ + [Recorder] + + +