diff --git a/crates/aura-cli/examples/r_channel.json b/crates/aura-cli/examples/r_channel.json new file mode 100644 index 0000000..9579508 --- /dev/null +++ b/crates/aura-cli/examples/r_channel.json @@ -0,0 +1 @@ +{"format_version":1,"blueprint":{"name":"hl_channel","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}]}} \ No newline at end of file diff --git a/crates/aura-cli/examples/r_channel_open.json b/crates/aura-cli/examples/r_channel_open.json new file mode 100644 index 0000000..52942b1 --- /dev/null +++ b/crates/aura-cli/examples/r_channel_open.json @@ -0,0 +1 @@ +{"format_version":1,"blueprint":{"name":"hl_channel","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":2,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}} \ No newline at end of file diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index d3f4dfd..a068dc4 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -2081,6 +2081,62 @@ fn r_meanrev_signal(window: Option, band_k: Option) -> Composite { g.build().expect("r_meanrev signal wiring resolves") } +/// The channel length for the canonical closed r_channel example. Single +/// source for the emitter and the proof tests that must agree with the baked +/// `examples/r_channel.json` (mirrors [`R_BREAKOUT_CHANNEL`]). +#[cfg(test)] +const R_CHANNEL_LENGTH: i64 = 3; + +/// The OHLC high/low-channel (Donchian-shape) signal leg — the harness-input- +/// binding acceptance strategy (#231): bias goes long when the CLOSE breaks +/// the rolling max of the previous `n` HIGHS, short when it breaks the rolling +/// min of the previous `n` LOWS. Three input roles (`high`, `low`, `close` — +/// the role names ARE the column binding), declared in canonical column order. +/// `channel = Some(n)` binds both rolling nodes (closed); `None` leaves them +/// open, ganged into the single `channel_length` knob (#61). `#[cfg(test)]`: +/// production loads the shipped JSON; this builder only regenerates + pins it +/// (the r_breakout/r_meanrev carve pattern). +#[cfg(test)] +fn r_channel_signal(channel: Option) -> Composite { + let mut g = GraphBuilder::new("hl_channel"); + let delay_hi = g.add(Delay::builder().named("prev_high").bind("lag", Scalar::i64(1))); + let delay_lo = g.add(Delay::builder().named("prev_low").bind("lag", Scalar::i64(1))); + let mut mx_b = RollingMax::builder().named("channel_hi"); + let mut mn_b = RollingMin::builder().named("channel_lo"); + if let Some(n) = channel { + mx_b = mx_b.bind("length", Scalar::i64(n)); + mn_b = mn_b.bind("length", Scalar::i64(n)); + } + let mx = g.add(mx_b); + let mn = g.add(mn_b); + if channel.is_none() { + g.gang("channel_length", [mx.param("length"), mn.param("length")]); + } + let gt_up = g.add(Gt::builder()); + let gt_down = g.add(Gt::builder()); + let up_latch = g.add(Latch::builder()); + let down_latch = g.add(Latch::builder()); + let exposure = g.add(Sub::builder()); // up_latch - down_latch -> bias in {-1,0,+1} + let high = g.source_role("high", ScalarKind::F64); + let low = g.source_role("low", ScalarKind::F64); + let close = g.source_role("close", ScalarKind::F64); + g.feed(high, [delay_hi.input("series")]); + g.feed(low, [delay_lo.input("series")]); + g.feed(close, [gt_up.input("a"), gt_down.input("b")]); + g.connect(delay_hi.output("value"), mx.input("series")); + g.connect(delay_lo.output("value"), mn.input("series")); + g.connect(mx.output("value"), gt_up.input("b")); + g.connect(mn.output("value"), gt_down.input("a")); + g.connect(gt_up.output("value"), up_latch.input("set")); + g.connect(gt_down.output("value"), up_latch.input("reset")); + g.connect(gt_down.output("value"), down_latch.input("set")); + g.connect(gt_up.output("value"), down_latch.input("reset")); + g.connect(up_latch.output("value"), exposure.input("lhs")); + g.connect(down_latch.output("value"), exposure.input("rhs")); + g.expose(exposure.output("value"), "bias"); + g.build().expect("hl_channel signal wiring resolves") +} + /// Parse the `--params` value: a JSON array of externally-tagged `Scalar` cells /// (`[{"I64":2},{"F64":0.5}]`, the #155 wire form `Scalar` derives via serde). The cells /// bind positionally against the loaded signal's `param_space`. A malformed array is @@ -3446,6 +3502,106 @@ mod tests { .expect("write examples/r_meanrev_open.json"); } + /// Regenerates the shipped r_channel examples from the carved signal. Run by hand: + /// `cargo test --bin aura emit_r_channel_examples -- --ignored`. The examples are + /// green-by-construction (serialised from the builder, never hand-authored). + #[test] + #[ignore = "regenerates crates/aura-cli/examples/r_channel{,_open}.json; run by hand"] + fn emit_r_channel_examples() { + std::fs::create_dir_all("examples").expect("examples dir"); + std::fs::write( + "examples/r_channel.json", + blueprint_to_json(&r_channel_signal(Some(R_CHANNEL_LENGTH))).expect("serialize closed r_channel"), + ) + .expect("write examples/r_channel.json"); + std::fs::write( + "examples/r_channel_open.json", + blueprint_to_json(&r_channel_signal(None)).expect("serialize open r_channel"), + ) + .expect("write examples/r_channel_open.json"); + } + + /// The shipped examples are a faithful serialisation of the carved signal + /// (the r_breakout pin pattern): re-serialising the carve equals the + /// checked-in bytes; a drift between builder and file breaks it. + #[test] + fn shipped_r_channel_examples_serialize_the_carved_signal() { + assert_eq!( + blueprint_to_json(&r_channel_signal(Some(R_CHANNEL_LENGTH))).expect("serialize closed"), + include_str!("../examples/r_channel.json"), + ); + assert_eq!( + blueprint_to_json(&r_channel_signal(None)).expect("serialize open"), + include_str!("../examples/r_channel_open.json"), + ); + } + + /// A 10-bar high/low/close fixture (canonical column order: high, low, + /// close) that warms the hl_channel graph (Delay(1) + Rolling(3)) and + /// breaks out upward then downward. + fn hlc_sources() -> Vec> { + let high = [10.5_f64, 10.6, 10.4, 10.8, 11.5, 12.0, 12.2, 11.0, 10.2, 9.8]; + let low = [9.5_f64, 9.7, 9.6, 9.9, 10.8, 11.4, 11.6, 10.1, 9.4, 9.0]; + let close = [10.0_f64, 10.2, 10.0, 10.5, 11.3, 11.8, 12.0, 10.4, 9.6, 9.2]; + [&high[..], &low[..], &close[..]] + .into_iter() + .map(|col| { + let series: Vec<(Timestamp, Scalar)> = col + .iter() + .enumerate() + .map(|(i, &v)| (Timestamp(i as i64 + 1), Scalar::f64(v))) + .collect(); + Box::new(VecSource::new(series)) as Box + }) + .collect() + } + + /// The shipped closed example, reloaded through the data plane and run + /// through the MULTI-COLUMN wrap, emits bit-identical bias rows to the + /// carved signal — r_channel's durable equivalence anchor (the r_breakout + /// idiom, over three VecSource columns instead of RunData::Synthetic, + /// which honestly refuses multi-column signals). `wrap_r`'s ex tap reads + /// `sig.output("bias")` directly, so `rx_ex` carries the raw signal bias. + #[test] + fn r_channel_example_loaded_runs_identically_to_the_carved_signal() { + let run = |signal: Composite| -> Vec<(Timestamp, Vec)> { + let binding = + binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) + .expect("high/low/close roles resolve"); + let (tx_eq, _rx_eq) = mpsc::channel(); + let (tx_ex, rx_ex) = mpsc::channel(); + let (tx_r, _rx_r) = mpsc::channel(); + let (tx_req, _rx_req) = mpsc::channel(); + let flat = wrap_r( + signal, + tx_eq, + tx_ex, + tx_r, + tx_req, + StopRule::Vol { length: 3, k: 2.0 }, + false, + SYNTHETIC_PIP_SIZE, + &binding, + ) + .compile_with_params(&[]) + .expect("closed hl_channel wraps to a valid harness"); + let mut h = Harness::bootstrap(flat).expect("hl_channel harness bootstraps"); + h.run(hlc_sources()); + rx_ex.try_iter().collect() + }; + let loaded = blueprint_from_json(include_str!("../examples/r_channel.json"), &|t| std_vocabulary(t)) + .expect("shipped r_channel example loads"); + let via_file = run(loaded); + let via_carve = run(r_channel_signal(Some(R_CHANNEL_LENGTH))); + assert!(!via_carve.is_empty(), "the channel must emit bias rows over the fixture"); + assert!( + via_carve.iter().any(|(_, row)| row.iter().any(|s| s.as_f64() != 0.0)), + "the fixture must drive a non-zero bias (an all-zero run would make \ + the equivalence vacuous)" + ); + assert_eq!(via_file, via_carve, "loaded example emits the carve's bias rows"); + } + /// The shipped examples are a faithful serialisation of the carved signal (#159 cut 3): /// re-serialising the carve equals the checked-in bytes. Green-by-construction with the /// emitter; a drift between builder and file breaks it. diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 7ede2cd..1b70856 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -1010,6 +1010,125 @@ fn sweep_real_blueprint_member_manifest_stamps_project_provenance() { } } +/// Acceptance (harness-input-binding spec, #231): an OHLC-consuming strategy +/// blueprint runs end to end on real archive data through `aura run` — the +/// role names bind columns, three real sources open in canonical order, the +/// close column feeds broker/executor, and the report carries the R summary. +/// Gated on the local GER40 archive; skips cleanly when absent. +#[test] +fn run_real_ohlc_channel_example_end_to_end() { + if !local_data_present() { + eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); + return; + } + let cwd = temp_cwd("ohlc-run-real"); + let fixture = format!("{}/examples/r_channel.json", env!("CARGO_MANIFEST_DIR")); + let out = std::process::Command::new(BIN) + .args([ + "run", &fixture, + "--real", "GER40", + "--from", GER40_SEPT2024_FROM_MS, + "--to", GER40_SEPT2024_TO_MS, + ]) + .current_dir(&cwd) + .output() + .unwrap(); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("run report parses as JSON"); + assert!( + v["manifest"]["topology_hash"].as_str().is_some(), + "report carries the signal hash: {stdout}" + ); + assert!(v["metrics"]["r"].is_object(), "the R summary is computed: {stdout}"); + let _ = std::fs::remove_dir_all(&cwd); +} + +/// The synthetic walk generates a close series only: a multi-column blueprint +/// without `--real` refuses honestly (exit 1, columns + remedy named). NOT +/// gated — the refusal precedes any data access. +#[test] +fn run_synthetic_refuses_a_multi_column_blueprint() { + let fixture = format!("{}/examples/r_channel.json", env!("CARGO_MANIFEST_DIR")); + let out = std::process::Command::new(BIN) + .args(["run", &fixture]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("consumes columns beyond close"), "names the shape: {stderr}"); + assert!(stderr.contains("--real"), "names the remedy: {stderr}"); +} + +/// The dissolved real-data sweep + reproduce over the OHLC example (#231 +/// acceptance): `--list-axes` pins the ganged channel knob's wrapped axis +/// name, the sweep runs one member per grid point over three real columns, +/// and every member re-derives bit-identically from the store (#229 real-data +/// reproduce, now multi-column). Gated on the local GER40 archive. +#[test] +fn sweep_real_ohlc_channel_members_run_and_reproduce() { + if !local_data_present() { + eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); + return; + } + let (dir, _g) = fresh_project(); + let fixture = format!("{}/examples/r_channel_open.json", env!("CARGO_MANIFEST_DIR")); + let axes = std::process::Command::new(BIN) + .args(["sweep", &fixture, "--list-axes"]) + .current_dir(dir) + .output() + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&axes.stdout).trim(), + "hl_channel.channel_length:I64", + "the ganged channel knob is the one sweepable axis; stderr: {}", + String::from_utf8_lossy(&axes.stderr) + ); + let out = std::process::Command::new(BIN) + .args([ + "sweep", &fixture, + "--real", "GER40", + "--from", GER40_SEPT2024_FROM_MS, + "--to", GER40_SEPT2024_TO_MS, + "--axis", "hl_channel.channel_length=3,5", + "--name", "chan", + ]) + .current_dir(dir) + .output() + .unwrap(); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + assert_eq!(stdout.lines().count(), 2, "one member line per channel length: {stdout}"); + for line in stdout.lines() { + let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON"); + assert_eq!( + v["report"]["manifest"]["instrument"].as_str(), + Some("GER40"), + "campaign member manifest stamps the instrument: {line}" + ); + } + let family_id = serde_json::from_str::(stdout.lines().next().unwrap()) + .expect("first member line parses")["family_id"] + .as_str() + .expect("member line carries family_id") + .to_string(); + let rep = std::process::Command::new(BIN) + .args(["reproduce", &family_id]) + .current_dir(dir) + .output() + .unwrap(); + assert!( + rep.status.success(), + "reproduce stderr: {}", + String::from_utf8_lossy(&rep.stderr) + ); + assert!( + String::from_utf8_lossy(&rep.stdout).contains("reproduced 2/2 members bit-identically"), + "stdout: {}", + String::from_utf8_lossy(&rep.stdout) + ); +} + /// Property: the real-data blueprint sweep path (`aura sweep /// --real SYM --axis …`, dispatched as sugar over the one campaign executor) /// prints one member line per grid point, IN AXIS ORDER (odometer: the first diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 5956609..2a42a57 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -1726,7 +1726,11 @@ not a hand-coded blueprint nor engine-baked source: a structural axis selects am structural variation **first-class and searchable** rather than a hand-enumerated menu — `HarnessKind` and the per-strategy `*_sweep_family` functions in `aura-cli` are the pre-C24 scaffolding (topology-as-engine-source, a C16 tension), retired as -C24 + the project-as-crate layer land. +C24 + the project-as-crate layer land. [C26 realization, 2026-07-10 (#231): the +scaffolding's last data weld — `wrap_r`'s hard-wired `price`←close role and the +`M1Field::Close`-only open sites — is retired; a strategy's input roles now bind +archive columns by name (C26). `wrap_r`'s remaining R-scaffolding retirement +stays #159.] **Refinement (2026-07-03, #188/#189 — experiment INTENT is campaign-document data).** The "ordinary Rust control flow, not a config schema" clause and the forbids-clause "declarative experiment mini-DSL" are scoped by the #188 role-model pass: they forbid an @@ -2028,7 +2032,9 @@ authoring brain** (the format is required by C21/C18 at the CLI level, independe any UI); a **generated / searched run whose topology is not recoverable from its manifest** (C18 reproduction); baking topology as **engine source** compiled into the binary (the hard-wired `aura-cli` harnesses are pre-C24 scaffolding, a C16 tension, -retired as this lands). +retired as this lands). [C26, 2026-07-10 (#231): the single-price data weld inside +the surviving `wrap_r` scaffolding is retired — input roles bind archive columns +by name; the wrapper's remaining R-scaffolding retirement stays #159.] **Why.** The World (C21) is the product, and it cannot orchestrate, **structurally search**, or **reproduce** *families that vary topology* while topology stays opaque Rust source baked into the engine. The **game-engine principle** (C16's engine / game @@ -2324,6 +2330,49 @@ artifacts (process/campaign documents, C18 realization note); roles 4/7/8 remain technically present but faceless (no addressed verb families yet); role homes in the project layout and docs-by-role are open (#192 context). +### C26 — Harness input binding: role names bind archive columns +**Guarantee.** A strategy blueprint declares WHICH data it consumes as the NAMES +of its root input roles: a role whose name is in the **closed column +vocabulary** — `open`, `high`, `low`, `close`, `spread`, `volume`, plus `price` +as the backward-compatible alias of `close` — binds by default to that column of +the campaign cell's (or run invocation's) instrument. A campaign document may +**override** the name default per role via the additive `data.bindings` block +(`role name → column name`; serde `default` + skip-if-empty, so binding-less +documents keep their content ids) — the 6b rebind seam: the blueprint's content +identity never changes. Resolution (`aura-cli`'s `binding` module, +`resolve_binding`) produces ONE ordered plan consumed by BOTH halves of the old +weld: the columns are opened (`aura_ingest::open_columns`, the generalization of +`open_ohlc`/#92) and the wrapped root roles declared (`wrap_r`) in the **same +canonical order** — `M1Field` declaration order (open, high, low, close, spread, +volume), filtered to the consumed set — which is the C4 merge tie-break order, +so role i receives source i by construction. A close column is always part of +the plan (shared when the strategy consumes `close`/`price`, else opened for the +broker/executor pair alone). The vocabulary is **Blockly-litmus-clean** (C25): +role-name slots draw from a closed enum, the bindings block is typed +`role → column` with no free-form holes, and validation is two-tier — values +against the column vocabulary in the intrinsic tier (`validate_campaign`), keys +against the strategies' `input_roles()` in the resolver tier +(`validate_campaign_refs`). +**Forbids.** Guessing a column for an unknown role name (refuse with the +vocabulary + the override remedy, never default); opening columns in any order +other than the canonical declaration order (the C4 tie-break contract); +free-form or logic-bearing binding values (the C17/C25 line — a binding value is +a typed column reference, nothing else); fabricating synthetic OHLC when a +multi-column strategy meets synthetic data (the walk generates a close series +only — refuse with the `--real` remedy). +**Extension point.** When recorded non-price sources land (the #71 Source seam), +the binding VALUE-space grows additively from archive columns to recorded-stream +references — a root role like `sentiment` becomes bindable to a recorded stream +then, and until then refuses with the vocabulary-naming message. The role-name +key-space and the campaign `bindings` carrier are unchanged by that growth. +**Why.** The single-price weld (`wrap_r`'s hard-wired `price`←close and the six +`M1Field::Close`-only open sites) made every strategy monocular regardless of +what its blueprint declared; binding by role name keeps the blueprint the single +source of its own data needs (C24: topology-as-data carries its input contract), +lets a campaign re-aim a strategy without touching its content id (C18 +identity), and pins opening and declaring to one shared, canonically-ordered +plan so the two halves cannot drift (C1/C4 determinism). + --- ## Open architectural threads not yet resolved