diff --git a/docs/specs/0092-run-from-blueprint.md b/docs/specs/0092-run-from-blueprint.md new file mode 100644 index 0000000..7812860 --- /dev/null +++ b/docs/specs/0092-run-from-blueprint.md @@ -0,0 +1,215 @@ +# Run a harness from a serialized blueprint (`aura run `) — Design Spec + +**Date:** 2026-06-30 +**Status:** Draft — awaiting grounding-check + sign-off +**Authors:** orchestrator + Claude +**Parent:** World/C21 milestone (run + orchestrate harness families from blueprint-data); reference issue #165 + +## Goal + +Turn a serialized C24 signal blueprint into an actual run from the CLI: + +``` +aura run [--params ] [--real [--from ] [--to ]] [--seed ] [--pip-size ] +``` + +loads the blueprint (`blueprint_from_json`), wraps the loaded **signal** in the +existing Stage-1-R run scaffolding (broker + equity/exposure recording sinks + +data binding), compiles, bootstraps, runs, and prints a `RunReport` whose +manifest carries a new `topology_hash`. The run is **bit-identical** to the +Rust-builder path for the same signal + params + seed + data (C1). + +This closes the data → bootstrap → run loop the cycle-0090 milestone fieldtest +flagged: the CLI could author and emit topology (`aura graph build`, #157) but +could not run an emitted blueprint. + +## Architecture + +A serialized blueprint is a **signal** — a strategy `Composite` (≤1 output, C8). +Recording sinks (`Recorder`, a runtime `mpsc::Sender`), brokers (`SimBroker`, a +construction-arg builder), the data source, and the clock are deliberately **out** +of the round-trippable set (C24 ledger, "Out of the first cut's round-trippable +set"). So `aura run` does not "just run" a blueprint — it **wraps** the loaded +signal in a runnable harness exactly as the Rust path does. + +The load-bearing move is a **shared wrapping seam**: the signal → Stage-1-R +harness → run → `RunReport` assembly currently inlined in `run_stage1_r` +(`crates/aura-cli/src/main.rs`) is extracted into a reusable function that takes a +**signal `Composite`** plus its run bindings (params, data, seed, pip). Both +`run_stage1_r` (with its hard-wired `stage1_r_graph()` signal) and the new +`aura run ` (with the loaded signal) call it — so "bit-identical +to the Rust path" is, by construction, *the same function over the same signal*, +not two implementations that must be kept in step. + +The round-trip property itself is already proven: `blueprint_serde_e2e.rs` builds +a signal, serializes it, loads it, wraps it with a `Recorder`, runs both twins, +and asserts bit-identical output (the C24 acceptance). Cycle 1 lifts that property +to the CLI and adds the manifest `topology_hash`. + +The existing harness-kind dispatch (`HarnessKind`, `run_sample`, `run_stage1_r`, +the `*_sweep_family` verbs) stays untouched this cycle (its retirement is #159, +gated on the project-as-crate layer). `aura run` distinguishes a blueprint path by +its `.json` extension / file existence; a non-path argument keeps the existing +harness-kind behaviour. + +## Concrete code shapes + +### The user-facing program (the criterion's evidence) + +Author + serialize a signal once (via the #157 op-script or a Rust builder), then: + +```console +$ aura graph build < sma_cross_ops.json > sma_cross.json # an existing #157 path, or any serialized signal +$ aura run sma_cross.json --params '[{"I64":2},{"I64":4},{"F64":0.5}]' +{"manifest":{"commit":"","params":[["sma_fast",{"I64":2}],["sma_slow",{"I64":4}],["bias_scale",{"F64":0.5}]], + "window":[,],"seed":0,"broker":"sim-optimal@pip=

","topology_hash":"<64-hex>"}, + "metrics":{...R metrics...}} + +$ aura run sma_cross.json --real GER40 # same signal, real M1 data +{"manifest":{...,"instrument":"GER40","topology_hash":""},"metrics":{...}} +``` + +The blueprint carries the **topology** (which nodes, how wired, bound params); the +broker, sinks, data, and seed are supplied at run — node logic stays Rust (C17). + +### The shared wrapping seam (before → after) + +Before — the wrapping is inlined in `run_stage1_r` (`main.rs`): + +```rust +fn run_stage1_r(pip_size: f64, source: ...) { + let graph = stage1_r_graph(/* hard-wired signal + scaffolding */); + let flat = graph.compile_with_params(&[]).expect("compiles"); + let h = Harness::bootstrap(flat).expect("bootstraps"); + let report = /* run, drain sinks, build manifest */; + println!("{}", report.to_json()); +} +``` + +After — a reusable seam takes a **signal** + bindings; `run_stage1_r` and +`aura run` both call it: + +```rust +/// Wrap a signal Composite in the Stage-1-R run scaffolding (broker + +/// equity/exposure sinks), compile, bootstrap, run, and build the RunReport. +/// The single construction path shared by the hard-wired and data-driven runs. +fn run_signal_stage1r(signal: Composite, bind: RunBindings) -> RunReport { + let topology_hash = sha256_hex(&blueprint_to_json(&signal).expect("serializes")); + let harness_graph = wrap_stage1r(signal, bind.pip_size); // adds broker + sinks (run-time, not serialized) + let flat = harness_graph.compile_with_params(&bind.params)?; + let h = Harness::bootstrap(flat)?; + // ... run over bind.sources, drain sinks, build R metrics ... + let mut manifest = sim_optimal_manifest(named_params, window, bind.seed, bind.pip_size); + manifest.broker = stage1_r_broker_label(bind.pip_size); + manifest.topology_hash = Some(topology_hash); // NEW + RunReport { manifest, metrics } +} + +// aura run : +let signal = blueprint_from_json(&fs::read_to_string(path)?, &std_vocabulary)?; // existing public API +let report = run_signal_stage1r(signal, RunBindings::from_cli(args)); +println!("{}", report.to_json()); +``` + +### The new manifest field (before → after, Tier-1 optional) + +`crates/aura-engine/src/report.rs` — `RunManifest`: + +```rust + pub struct RunManifest { + pub commit: String, + pub params: Vec<(String, Scalar)>, + pub window: (Timestamp, Timestamp), + pub seed: u64, + pub broker: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selection: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instrument: Option, ++ /// SHA256 (hex) of the canonical `blueprint_to_json` of the run's signal ++ /// topology (#158 reproducibility anchor). Tier-1 optional: absent on old ++ /// manifests, which deserialize unchanged (#156). ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ pub topology_hash: Option, + } +``` + +## Components + +- **CLI surface** (`crates/aura-cli/src/main.rs`): `aura run ` — when `` + is a `.json` file, blueprint mode; else the existing harness-kind path. New flags + `--params`, `--seed`, `--pip-size` (the `--real`/`--from`/`--to` data flags already + exist on the run path). `--params` parses a JSON array of typed `Scalar` cells + (`[{"I64":2},{"F64":0.5}]`, the #155 representation) into `Vec`. +- **The shared seam** (`run_signal_stage1r` + `wrap_stage1r` + `RunBindings`): the + extracted signal → harness → run → report assembly. Behaviour-preserving for + `run_stage1_r` (same output). +- **Topology hash** (`sha256_hex(blueprint_to_json(&signal))`): a small helper; + `sha2` is the dependency (per-case review — a vetted standard crate entering the + bot, C16 dependency policy; SHA256 is the user-settled algorithm). +- **Manifest field** (`RunManifest.topology_hash`): the Tier-1 optional addition. + +## Data flow + +`` → `blueprint_from_json` → `Composite` (signal) → +`wrap_stage1r` → `Composite` (harness, broker + sinks) → `compile_with_params` → +`FlatGraph` → `Harness::bootstrap` → `Harness::run(sources)` → drain sinks → R +metrics + `RunManifest{…, topology_hash}` → `RunReport` → stdout JSON. Window is +probed from the sources post-run (`window_of`), as today. + +## Error handling + +- Missing / unreadable file → `aura: : `, exit non-zero. +- `blueprint_from_json` `LoadError` (malformed JSON / `UnknownNodeType` / + `UnsupportedVersion`) → printed by-identifier, exit non-zero. An unknown type + fails clean (the #160 closed-set guard at the data-plane face; invariant 9). +- `--params` arity / kind mismatch surfaces from `compile_with_params` as the + existing by-identifier `CompileError`. +- Compile / bootstrap faults (`UnconnectedPort`, `WouldCycle`, …) reported as today. + +## Testing strategy + +- **Bit-identical run (the keystone property, C1):** in a test, build a signal + `Composite` in Rust; run it via `run_signal_stage1r(signal.clone(), b)` → + `report_A`; serialize → `blueprint_to_json` → `blueprint_from_json` → `signal'`; + run `run_signal_stage1r(signal', b)` → `report_B`; assert `report_A == report_B` + (metrics, traces, AND `topology_hash`). This rides the existing + `blueprint_serde_e2e.rs` round-trip discipline. +- **topology_hash determinism:** the same signal yields the same hash across two + serializations; two structurally-different signals yield different hashes. +- **Tier-1 forward-compat:** a `RunManifest` JSON without `topology_hash` + deserializes (field `None`); a manifest with it round-trips. Rides the existing + `report.rs` serde tests + the `#156` discipline. +- **E2E (binary):** `aura run .json` over synthetic data exits 0 and prints a + `RunReport` whose manifest contains a 64-hex `topology_hash`; an unknown-type + blueprint exits non-zero with an `UnknownNodeType` message. A demo signal + blueprint is shipped in-repo (e.g. `crates/aura-cli/blueprints/sma_cross.json`). + +## Acceptance criteria + +- [ ] A blueprint serialized from a Rust-built signal, then loaded and run via the + shared seam, produces a **bit-identical** `RunReport` (metrics + traces + + `topology_hash`) to the Rust-built twin run through the same seam (C1). +- [ ] The manifest carries `topology_hash` = SHA256 (hex) of the canonical + (`#164`, no-trailing-newline) `blueprint_to_json` of the loaded signal. +- [ ] `topology_hash` is a Tier-1 optional `RunManifest` field: existing manifests + without it deserialize unchanged (#156). +- [ ] `aura run ` runs an in-repo demo blueprint end-to-end (exit 0, + `RunReport` on stdout); an unknown node type fails clean (`UnknownNodeType`). +- [ ] `run_stage1_r` is behaviour-preserving: its output is unchanged after the + wrapping seam is extracted (the existing stage1-R tests stay green). + +## Feature-acceptance (project criterion, applied) + +- **Audience reaches for it:** a researcher who serialized a strategy (via the + #157 op-script or the builder) naturally wants to *run* it without re-authoring + Rust — the worked `aura run sma_cross.json` above is exactly that, and the + cycle-0090 fieldtest named its absence as friction. +- **Improves correctness / removes redundancy:** it makes a serialized topology + executable and self-identifying (`topology_hash`), the reproducibility anchor + #158 builds on; the shared seam removes the would-be redundancy of a second run + path. +- **Reintroduces no failure class:** node logic stays Rust (C17); the resolver + resolves only compiled-in types (invariant 9, fails clean as `UnknownNodeType`); + the run is deterministic (C1) and bit-identical to the Rust path; the deploy + artifact stays frozen (this is a research-plane load path, invariant 8).