diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 50d0d68..fc1abce 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -1153,20 +1153,20 @@ fn version_string() -> &'static str { const CONCEPTS_HELP: &str = "\ Author, backtest, and validate trading strategies — research CLI. -Two layers, one vocabulary: the research verbs are the convenience surface — -over --real data, sweep, walkforward, mc, and generalize desugar to -registered process/campaign documents and execute them (run is their -single-backtest sibling; synthetic runs execute in-process). That document -data plane is directly authorable: `aura process` / `aura campaign` -(validate | introspect | register | run), growing a document from a bare {} -via `introspect --unwired`. +Two layers, one vocabulary: `aura exec` is the one executor over both document classes +— a registered campaign (file or content id) or a signal blueprint (single +run; synthetic runs execute in-process). That document data plane is +directly authorable: `aura process` / `aura campaign` (validate | introspect +| register), growing a document from a bare {} via `introspect --unwired`; +axis discovery is `aura graph introspect --params`. Execution model: a strategy emits a bias in [-1,+1] per cycle, held as the continuously-tracked target position; a protective stop defines the risk unit R, and signal quality is measured in R. -Traces: `sweep --real --trace` / `walkforward --real --trace` record per-cycle -taps under runs/, consumed by `aura chart` and `aura measure`."; +Traces: `exec --tap NAME=FOLD` (or a campaign document's +`presentation.persist_taps`) record per-cycle taps under runs/, consumed by +`aura chart` and `aura measure`."; #[derive(Parser)] #[command( diff --git a/crates/aura-cli/src/scaffold.rs b/crates/aura-cli/src/scaffold.rs index b892ab4..fdbb690 100644 --- a/crates/aura-cli/src/scaffold.rs +++ b/crates/aura-cli/src/scaffold.rs @@ -231,12 +231,13 @@ const CLAUDE_MD_PROJECT: &str = r#"# __NAME__ — an aura research project (data This directory is an aura project: blueprints + research documents over the std vocabulary, anchored by `Aura.toml`. There is no crate and no build step. -- Run: `aura run blueprints/signal.json` (the starter is closed — all - params bound; bound values are defaults — any `--axis` may override them - (#246)) -- Sweep: `aura sweep blueprints/signal.json --axis fast.length=2,4,8` - (`aura sweep --list-axes` lists the open + bound-overridable axes, RAW - `.` names — #328) +- Run: `aura exec blueprints/signal.json` (single smoke run, synthetic stream + — the starter is closed; all params bound; bound values are defaults — + `--override NODE.PARAM=VALUE` may override one for this run (#246)) +- Campaign: `aura exec ` executes a registered campaign + document (file or content id) — the multi-cell/axis surface +- Axes: `aura graph introspect --params blueprints/signal.json` lists the + open + bound-overridable axes, RAW `.` names (#328) - Native nodes: when the project needs its first project-specific node, `aura nodes new ` scaffolds a node crate beside this project and attaches it via `[nodes]` in `Aura.toml` (build it with `cargo build`). @@ -459,12 +460,12 @@ mod tests { } let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab"); assert!(claude.contains("demo-lab")); - // The CLAUDE.md sweep quickstart targets the closed starter itself - // with the RAW axis name (#246: a bound param is a default an axis - // overrides; #328: the axis namespace is raw, no blueprint-name - // prefix). + // The CLAUDE.md quickstart targets the closed starter itself through + // the surviving surface (#319): exec for both document classes, the + // #246 override residue, and raw-namespace axis discovery (#328). assert!(claude.contains("blueprints/signal.json")); - assert!(claude.contains("--axis fast.length")); + assert!(claude.contains("--override NODE.PARAM=VALUE")); + assert!(claude.contains("graph introspect --params")); } /// #315: the scaffolded project CLAUDE.md teaches the execution semantics diff --git a/crates/aura-cli/tests/cli_broken_pipe.rs b/crates/aura-cli/tests/cli_broken_pipe.rs index cbdd9c4..8a4ecdc 100644 --- a/crates/aura-cli/tests/cli_broken_pipe.rs +++ b/crates/aura-cli/tests/cli_broken_pipe.rs @@ -5,47 +5,146 @@ //! handled cleanly, never a panic. use std::io::Read; +use std::path::Path; use std::process::{Command, Stdio}; +mod common; +use common::{fresh_project_with_data, ScratchGuard, ScratchPath}; + /// Path to the freshly-built `aura` binary (Cargo sets this env var for the test /// crate; the binary is named `aura` in `Cargo.toml`). const BIN: &str = env!("CARGO_BIN_EXE_aura"); -/// A fresh, unique working directory so the spawned `aura sweep` writes its -/// `./runs/runs.jsonl` into a throwaway dir, never dirtying the repo. Unique -/// per test + per process; no external tempfile dependency (mirrors `cli_run.rs`). -fn temp_cwd(name: &str) -> std::path::PathBuf { - let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-{name}")); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).expect("create temp cwd"); - dir +fn run_code_in(dir: &Path, args: &[&str]) -> (String, Option) { + let out = Command::new(BIN).args(args).current_dir(dir).output().expect("binary runs"); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + (text, out.status.code()) +} + +fn write_doc(dir: &Path, name: &str, text: &str) -> std::path::PathBuf { + let p = dir.join(name); + std::fs::write(&p, text).expect("write doc"); + p +} + +/// The minimal executable pipeline (one sweep stage) — copied verbatim from +/// `research_docs.rs`'s constant of the same name. +const SWEEP_ONLY_PROCESS_DOC: &str = r#"{ + "format_version": 1, + "kind": "process", + "name": "sweep-only", + "pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ] +}"#; + +/// Seed one blueprint (its params bound, reopened by the sweep axes per +/// #246) into the built demo project's store via a real sweep and return +/// its content id — copied verbatim from `research_docs.rs`'s recipe of the +/// same name. +fn seed_blueprint(dir: &Path, name: &str) -> String { + let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); + let (out, code) = run_code_in( + dir, + &[ + "sweep", + &closed_bp, + "--axis", + "fast.length=2,4", + "--axis", + "slow.length=8,16", + "--name", + name, + ], + ); + assert_eq!(code, Some(0), "seed sweep failed: {out}"); + std::fs::read_dir(dir.join("runs").join("blueprints")) + .expect("blueprints dir") + .next() + .expect("one stored blueprint") + .expect("dir entry") + .path() + .file_stem() + .expect("stem") + .to_string_lossy() + .into_owned() +} + +/// Register `doc` as a process document in the project store; returns its +/// id — copied verbatim from `research_docs.rs`'s recipe of the same name. +fn register_process_doc(dir: &Path, file: &str, doc: &str) -> String { + write_doc(dir, file, doc); + let (out, code) = run_code_in(dir, &["process", "register", file]); + assert_eq!(code, Some(0), "process register failed: {out}"); + out.lines() + .find(|l| l.starts_with("registered process ")) + .expect("register line") + .trim_start_matches("registered process ") + .split(' ') + .next() + .expect("id") + .trim_start_matches("content:") + .to_string() +} + +/// A one-strategy, two-axis (2×2) campaign document over `bp_id`/`proc_id` — +/// copied verbatim from `exec.rs`'s `campaign_doc_json_for` of the same +/// shape, which yields exactly four family members (the four-line stream +/// this test's property needs). +fn campaign_doc_json_for(instrument: &str, bp_id: &str, proc_id: &str, window: (i64, i64)) -> String { + format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "broken-pipe", + "data": {{ "instruments": ["{instrument}"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }}, + "slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + from = window.0, + to = window.1, + ) } /// Property: a family-emitting command whose stdout reader closes early exits on /// the EPIPE cleanly — it does NOT panic (`failed printing to stdout: Broken -/// pipe`) and does NOT exit 101. `aura sweep` streams four family-member JSON -/// lines via `println!`; when the reader closes stdout after the first line, the -/// next write hits a closed pipe. Rust's runtime sets SIGPIPE to SIG_IGN at -/// startup, so that write returns EPIPE and `println!` panics rather than the -/// process terminating quietly — exactly the behaviour a CLI must not have. This -/// is the contract: any aura subcommand can be piped into `| head` / `| less` / +/// pipe`) and does NOT exit 101. `aura exec ` streams four +/// family-member JSON lines via `println!` (a 2×2 axis grid — #319: the +/// surviving surface for what a bare `aura sweep` demo grid used to stream); +/// when the reader closes stdout after the first line, the next write hits a +/// closed pipe. Rust's runtime sets SIGPIPE to SIG_IGN at startup, so that +/// write returns EPIPE and `println!` panics rather than the process +/// terminating quietly — exactly the behaviour a CLI must not have. This is +/// the contract: any aura subcommand can be piped into `| head` / `| less` / /// a UI pane that goes away, and it must finish quietly, not panic. -/// -/// Autonomous: constructs its own early-closing reader inline (no `head`, no -/// shell, no fixture files) by spawning the binary with a piped stdout and -/// dropping the read handle after a single short read, which closes the pipe's -/// read end and makes the writer's next write hit EPIPE. #[test] fn family_emitting_command_survives_early_reader_close() { - let cwd = temp_cwd("broken-pipe-sweep"); + let (dir, _fixture) = fresh_project_with_data(); + let runs_dir = dir.join("runs"); + std::fs::remove_dir_all(&runs_dir).ok(); + let _cleanup = ScratchGuard(vec![ + ScratchPath::Dir(runs_dir), + ScratchPath::File(dir.join("broken-pipe.process.json")), + ScratchPath::File(dir.join("broken-pipe.campaign.json")), + ]); + let bp_id = seed_blueprint(&dir, "broken-pipe-seed"); + let proc_id = register_process_doc(&dir, "broken-pipe.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = campaign_doc_json_for("SYMA", &bp_id, &proc_id, (1709251200000, 1719791999999)); + write_doc(&dir, "broken-pipe.campaign.json", &doc); let mut child = Command::new(BIN) - .arg("sweep") - .current_dir(&cwd) + .args(["exec", "broken-pipe.campaign.json"]) + .current_dir(&dir) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() - .expect("spawn aura sweep"); + .expect("spawn aura exec"); // Read just enough to guarantee the writer is mid-stream, then drop the read // handle. Dropping it closes the read end of the pipe; the next `println!` @@ -63,9 +162,7 @@ fn family_emitting_command_survives_early_reader_close() { if let Some(mut err) = child.stderr.take() { let _ = err.read_to_string(&mut stderr); } - let status = child.wait().expect("reap aura sweep"); - - let _ = std::fs::remove_dir_all(&cwd); + let status = child.wait().expect("reap aura exec"); // The defect: stderr carries the broken-pipe panic. assert!( diff --git a/crates/aura-cli/tests/common/mod.rs b/crates/aura-cli/tests/common/mod.rs index 8b3697b..fb17e43 100644 --- a/crates/aura-cli/tests/common/mod.rs +++ b/crates/aura-cli/tests/common/mod.rs @@ -1,7 +1,9 @@ //! Shared fixture helpers for the `aura-cli` end-to-end test binaries -//! (`research_docs.rs`, `cli_run.rs`, `project_load.rs`), all of which build -//! and drive the `tests/fixtures/demo-project` fixture through the real -//! `aura` binary (#223). +//! (`research_docs.rs`, `cli_run.rs`, `project_load.rs`, `exec.rs`, and — +//! for [`synthetic_data`] alone — `project_new.rs`, `cli_broken_pipe.rs`, +//! `graph_construct.rs`), all of which build and drive the +//! `tests/fixtures/demo-project` fixture through the real `aura` binary +//! (#223). //! //! **Per-test project directories, no shared store.** [`fresh_project`] mints //! a unique tempdir per test, wired to the shared, once-built fixture crate @@ -9,10 +11,10 @@ //! no two tests ever race on the same `runs/` store — libtest's default //! thread parallelism applies without a serializing lock (#250). //! -//! Each of the three test binaries compiles this file as its own `mod -//! common`, so an item unused by one binary trips that binary's `-D -//! warnings` `dead_code` lint; see the `#[allow(dead_code)]` markers below -//! rather than deleting a helper just because one binary doesn't need it. +//! Each consuming test binary compiles this file as its own `mod common`, so +//! an item unused by one binary trips that binary's `-D warnings` `dead_code` +//! lint; see the `#[allow(dead_code)]` markers below rather than deleting a +//! helper just because one binary doesn't need it. use std::path::{Path, PathBuf}; use std::sync::OnceLock; @@ -182,7 +184,7 @@ impl Drop for ScratchGuard { /// the two no-window `generalize` E2E tests get a real, tiny, hostless /// archive instead of streaming the 6.6 GB host archive (#250). #[allow(dead_code)] -mod synthetic_data { +pub mod synthetic_data { use std::io::Write; use std::path::Path; diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index 7aba864..a4ed787 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -5,6 +5,9 @@ use std::io::Write; use std::process::{Command, Stdio}; +mod common; +use common::{fresh_project_with_data, ScratchGuard, ScratchPath}; + const BIN: &str = env!("CARGO_BIN_EXE_aura"); /// Run `aura ` with `stdin_doc` piped in; return (stdout, stderr, success). @@ -714,26 +717,25 @@ fn graph_params_lists_raw_axis_namespace() { ); } -/// Property (#328, cycle-close tidy fix): `graph introspect --params` and -/// `aura sweep --list-axes` are two independently-dispatched discovery -/// surfaces over the SAME raw axis namespace — the open pass shares one -/// wrap-strip convention, the bound pass shares one `bound_param_space()` + -/// `render_value` lexicon (`list_blueprint_axes`, main.rs; `params_lines`, -/// this crate). Pinned here as a same-fixture LINE EQUALITY (not just two -/// independently-asserted shapes) so the duplicated formatting cannot -/// silently drift apart across an edit to either surface. +/// Property (#328, #319 retirement residue): `graph introspect --params` +/// prints the raw axis namespace — one `{name}:{kind:?}` line per open param, +/// then each bound param with its default (`list_blueprint_axes`, main.rs; +/// `params_lines`, this crate). This test used to also pin `aura sweep +/// --list-axes` as a byte-identical SECOND discovery surface over the same +/// namespace (guarding the two independently-dispatched surfaces against +/// drift); `sweep` is retired, so only `graph introspect --params`'s own +/// literal output survives here — see `graph_params_lists_raw_axis_namespace` +/// above for the sibling pin over the same fixture. #[test] -fn graph_params_and_sweep_list_axes_are_line_identical() { - let dir = temp_cwd("params-vs-list-axes"); +fn graph_params_prints_raw_axis_names() { + let dir = temp_cwd("params-raw-axis-names"); let bp = fixture("r_sma_open.json"); let (params_out, params_err, params_code) = run_in(&dir, &["graph", "introspect", "--params", &bp]); assert_eq!(params_code, Some(0), "graph introspect --params: {params_out} {params_err}"); - let (axes_out, axes_err, axes_code) = run_in(&dir, &["sweep", &bp, "--list-axes"]); - assert_eq!(axes_code, Some(0), "sweep --list-axes: {axes_out} {axes_err}"); assert_eq!( - params_out, axes_out, - "the two discovery surfaces must print byte-identical lines for the same blueprint" + params_out, "fast.length:I64\nslow.length:I64\nbias.scale:F64 default=0.5\n", + "the raw open params, then the raw bound param with its default" ); } @@ -1262,13 +1264,85 @@ fn open_r_breakout_fixture_lists_its_axis_namespace() { ); } +/// The minimal executable pipeline (one sweep stage) — copied verbatim from +/// `research_docs.rs`'s constant of the same name. +const SWEEP_ONLY_PROCESS_DOC: &str = r#"{ + "format_version": 1, + "kind": "process", + "name": "sweep-only", + "pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ] +}"#; + +/// Register `doc` as a process document in `dir`'s project store; returns its +/// id — copied verbatim from `research_docs.rs`'s recipe of the same name. +fn register_process_doc(dir: &std::path::Path, file: &str, doc: &str) -> String { + std::fs::write(dir.join(file), doc).expect("write process doc"); + let (out, err, code) = run_in(dir, &["process", "register", file]); + assert_eq!(code, Some(0), "process register failed: {out} {err}"); + out.lines() + .find(|l| l.starts_with("registered process ")) + .expect("register line") + .trim_start_matches("registered process ") + .split(' ') + .next() + .expect("id") + .trim_start_matches("content:") + .to_string() +} + +/// A one-strategy campaign document over `bp_id`/`proc_id`, with a raw +/// `axes_json` object literal spliced in verbatim — a campaign document +/// refuses an empty `axes` map ("axes is empty"), so a "closed" comparison +/// cell (nothing to vary) passes a single-value axis over one of its OWN +/// already-bound params at its own default (a no-op reopen, #246: every +/// bound param is an equally re-openable axis). #319: campaigns are `exec`'s +/// surviving surface for binding a genuinely OPEN param to a value — +/// `--override` only reopens an already-BOUND one +/// (`aura_runner::member::override_paths` skips a name already in the open +/// space), so the gang-axis parity tests below run both the closed example +/// and its open-plus-axis twin through this SAME campaign machinery, over +/// the same synthetic archive window, keeping their metrics directly +/// comparable. +fn one_cell_campaign_doc(bp_id: &str, proc_id: &str, window: (i64, i64), axes_json: &str) -> String { + format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "gang-axis-vehicle", + "data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, "axes": {{{axes_json}}} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + from = window.0, + to = window.1, + ) +} + +/// Execs a one-cell campaign document (written to `dir.join(file)`) and +/// returns its single member's `report.metrics`. +fn one_cell_campaign_metrics(dir: &std::path::Path, file: &str) -> serde_json::Value { + let (out, err, code) = run_in(dir, &["exec", file]); + assert_eq!(code, Some(0), "campaign exec failed: {out} {err}"); + let line = out + .lines() + .find(|l| l.starts_with(r#"{"family_id":"#)) + .unwrap_or_else(|| panic!("no member line: {out} {err}")); + let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON"); + v["report"]["metrics"].clone() +} + /// Property (#61 + #159 cut 2): binding the shipped open example's ganged -/// `channel_length` axis through the REAL `aura sweep` CLI pipeline (argv -> -/// `parse_axes` -> `compile_with_cells`'s gang expansion) reproduces the exact -/// metrics of running the shipped CLOSED example, whose `channel_hi`/ -/// `channel_lo` are separately hardwired to the same value. The equivalence -/// between a gang and its member-bound twin is already pinned at the builder/ -/// engine level (aura-engine's `gang_e2e.rs`, and this crate's +/// `channel_length` axis through a REAL campaign axis (#319: the surviving +/// surface — `compile_with_cells`'s gang expansion, same as the retired +/// `aura sweep --axis`) reproduces the exact metrics of running the shipped +/// CLOSED example, whose `channel_hi`/`channel_lo` are separately hardwired +/// to the same value. Both legs run as one-cell campaigns over the same +/// synthetic archive window, so the comparison is data-source-agnostic (only +/// the axis-vs-hardwire binding differs). The equivalence between a gang and +/// its member-bound twin is already pinned at the builder/engine level +/// (aura-engine's `gang_e2e.rs`, and this crate's /// `r_breakout_example_loaded_runs_identically_to_the_carved_signal`, which /// calls `run_signal_r` directly); this instead drives the actual public /// binary end-to-end on the actual shipped files, so a regression in the @@ -1278,25 +1352,53 @@ fn open_r_breakout_fixture_lists_its_axis_namespace() { /// equivalence still holds. #[test] fn open_r_breakout_fixture_gang_axis_matches_the_closed_example() { - let closed_dir = temp_cwd("r-breakout-gang-axis-closed"); - let (closed_stdout, closed_stderr, closed_code) = run_in(&closed_dir, &["exec", &example("r_breakout.json")]); - assert_eq!(closed_code, Some(0), "closed run stderr: {closed_stderr}"); - let closed: serde_json::Value = - serde_json::from_str(closed_stdout.trim()).expect("closed run report parses as JSON"); + let (dir, _fixture) = fresh_project_with_data(); + let runs_dir = dir.join("runs"); + std::fs::remove_dir_all(&runs_dir).ok(); + let _cleanup = ScratchGuard(vec![ + ScratchPath::Dir(runs_dir), + ScratchPath::File(dir.join("breakout-gang.process.json")), + ScratchPath::File(dir.join("breakout-gang-closed.campaign.json")), + ScratchPath::File(dir.join("breakout-gang-open.campaign.json")), + ]); - let sweep_dir = temp_cwd("r-breakout-gang-axis-sweep"); - let (sweep_stdout, sweep_stderr, sweep_code) = run_in( - &sweep_dir, - &["sweep", &fixture("r_breakout_open.json"), "--axis", "channel_length=3"], - ); - assert_eq!(sweep_code, Some(0), "sweep stderr: {sweep_stderr}"); - let lines: Vec<&str> = sweep_stdout.lines().collect(); - assert_eq!(lines.len(), 1, "one grid point for a single-value axis: {sweep_stdout}"); - let member: serde_json::Value = serde_json::from_str(lines[0]).expect("member line parses as JSON"); + let (reg_closed_out, reg_closed_err, reg_closed_code) = + run_in(&dir, &["graph", "register", &example("r_breakout.json")]); + assert_eq!(reg_closed_code, Some(0), "register closed: {reg_closed_out} {reg_closed_err}"); + let closed_id = registered_id(®_closed_out); + let (reg_open_out, reg_open_err, reg_open_code) = + run_in(&dir, &["graph", "register", &fixture("r_breakout_open.json")]); + assert_eq!(reg_open_code, Some(0), "register open: {reg_open_out} {reg_open_err}"); + let open_id = registered_id(®_open_out); + + let proc_id = register_process_doc(&dir, "breakout-gang.process.json", SWEEP_ONLY_PROCESS_DOC); + + // The shared SYMA window used throughout the suite's campaign vehicles + // (inside `fresh_project_with_data`'s Jan-Aug 2024 synthetic archive). + let window = (1709251200000, 1719791999999); + std::fs::write( + dir.join("breakout-gang-closed.campaign.json"), + // No-op axis: `delay.lag` reopened at its own bound default (1). + one_cell_campaign_doc(&closed_id, &proc_id, window, r#""delay.lag": { "kind": "I64", "values": [1] }"#), + ) + .expect("write closed campaign doc"); + std::fs::write( + dir.join("breakout-gang-open.campaign.json"), + one_cell_campaign_doc( + &open_id, + &proc_id, + window, + r#""channel_length": { "kind": "I64", "values": [3] }"#, + ), + ) + .expect("write open campaign doc"); + + let closed_metrics = one_cell_campaign_metrics(&dir, "breakout-gang-closed.campaign.json"); + let open_metrics = one_cell_campaign_metrics(&dir, "breakout-gang-open.campaign.json"); assert_eq!( - member["report"]["metrics"], closed["metrics"], - "channel_length=3, bound via the real sweep CLI, must fan out to both channel_hi \ + open_metrics, closed_metrics, + "channel_length=3, bound via the campaign axis, must fan out to both channel_hi \ and channel_lo identically to the closed example's hardwired channel=3" ); } @@ -1330,39 +1432,69 @@ fn open_r_meanrev_fixture_lists_its_axis_namespace() { ); } -/// Property (#61 + #159 cut 3): a sweep over TWO axes at once — the ganged -/// `window` knob (fusing `mean_window.length`/`var_window.length`) alongside -/// the independent, un-ganged `band.factor` knob — binds both correctly -/// through the real CLI grid, matching the shipped closed example's hardwired -/// window=3/band=2.0. This is a distinct risk from the r_breakout gang test -/// (a lone gang axis): a mis-scoped gang-expansion map that shifts sibling -/// param positions could silently mis-bind the co-present un-ganged axis -/// instead (or vice-versa) even though each axis alone still resolves. +/// Property (#61 + #159 cut 3): a campaign over TWO axes at once (#319: the +/// surviving surface for what `aura sweep --axis` used to do inline) — the +/// ganged `window` knob (fusing `mean_window.length`/`var_window.length`) +/// alongside the independent, un-ganged `band.factor` knob — binds both +/// correctly through the real CLI grid, matching the shipped closed +/// example's hardwired window=3/band=2.0. This is a distinct risk from the +/// r_breakout gang test (a lone gang axis): a mis-scoped gang-expansion map +/// that shifts sibling param positions could silently mis-bind the +/// co-present un-ganged axis instead (or vice-versa) even though each axis +/// alone still resolves. Both legs run as one-cell campaigns over the same +/// synthetic archive window, so the comparison is data-source-agnostic. #[test] fn open_r_meanrev_fixture_gang_plus_plain_axis_matches_the_closed_example() { - let closed_dir = temp_cwd("r-meanrev-gang-axis-closed"); - let (closed_stdout, closed_stderr, closed_code) = run_in(&closed_dir, &["exec", &example("r_meanrev.json")]); - assert_eq!(closed_code, Some(0), "closed run stderr: {closed_stderr}"); - let closed: serde_json::Value = - serde_json::from_str(closed_stdout.trim()).expect("closed run report parses as JSON"); + let (dir, _fixture) = fresh_project_with_data(); + let runs_dir = dir.join("runs"); + std::fs::remove_dir_all(&runs_dir).ok(); + let _cleanup = ScratchGuard(vec![ + ScratchPath::Dir(runs_dir), + ScratchPath::File(dir.join("meanrev-gang.process.json")), + ScratchPath::File(dir.join("meanrev-gang-closed.campaign.json")), + ScratchPath::File(dir.join("meanrev-gang-open.campaign.json")), + ]); - let sweep_dir = temp_cwd("r-meanrev-gang-axis-sweep"); - let (sweep_stdout, sweep_stderr, sweep_code) = run_in( - &sweep_dir, - &[ - "sweep", &fixture("r_meanrev_open.json"), - "--axis", "window=3", - "--axis", "band.factor=2.0", - ], - ); - assert_eq!(sweep_code, Some(0), "sweep stderr: {sweep_stderr}"); - let lines: Vec<&str> = sweep_stdout.lines().collect(); - assert_eq!(lines.len(), 1, "one grid point for two single-value axes: {sweep_stdout}"); - let member: serde_json::Value = serde_json::from_str(lines[0]).expect("member line parses as JSON"); + let (reg_closed_out, reg_closed_err, reg_closed_code) = + run_in(&dir, &["graph", "register", &example("r_meanrev.json")]); + assert_eq!(reg_closed_code, Some(0), "register closed: {reg_closed_out} {reg_closed_err}"); + let closed_id = registered_id(®_closed_out); + let (reg_open_out, reg_open_err, reg_open_code) = + run_in(&dir, &["graph", "register", &fixture("r_meanrev_open.json")]); + assert_eq!(reg_open_code, Some(0), "register open: {reg_open_out} {reg_open_err}"); + let open_id = registered_id(®_open_out); + + let proc_id = register_process_doc(&dir, "meanrev-gang.process.json", SWEEP_ONLY_PROCESS_DOC); + + let window = (1709251200000, 1719791999999); + std::fs::write( + dir.join("meanrev-gang-closed.campaign.json"), + // No-op axis: `mean_window.length` reopened at its own bound default (3). + one_cell_campaign_doc( + &closed_id, + &proc_id, + window, + r#""mean_window.length": { "kind": "I64", "values": [3] }"#, + ), + ) + .expect("write closed campaign doc"); + std::fs::write( + dir.join("meanrev-gang-open.campaign.json"), + one_cell_campaign_doc( + &open_id, + &proc_id, + window, + r#""window": { "kind": "I64", "values": [3] }, "band.factor": { "kind": "F64", "values": [2.0] }"#, + ), + ) + .expect("write open campaign doc"); + + let closed_metrics = one_cell_campaign_metrics(&dir, "meanrev-gang-closed.campaign.json"); + let open_metrics = one_cell_campaign_metrics(&dir, "meanrev-gang-open.campaign.json"); assert_eq!( - member["report"]["metrics"], closed["metrics"], - "window=3 (ganged) + band.factor=2.0 (plain), bound via the real sweep CLI, must \ + open_metrics, closed_metrics, + "window=3 (ganged) + band.factor=2.0 (plain), bound via the campaign axes, must \ match the closed example's hardwired window=3/band=2.0" ); } @@ -2076,10 +2208,10 @@ fn graph_build_use_docless_source_refuses_with_the_c29_shape_exit_1() { /// The worked acceptance flow's last leg (spec §Concrete code shapes): a /// pattern registered with an open param splices under an instance, and -/// `aura sweep --list-axes` on the CONSUMER shows the path-qualified bare -/// (unbound) axis `graph...:` — the existing -/// nested-composite param-prefix discipline, reached through `use` this -/// time, not a Rust-authored nested composite. +/// `aura graph introspect --params` on the CONSUMER shows the path-qualified +/// bare (unbound) axis `graph...:` — the +/// existing nested-composite param-prefix discipline, reached through `use` +/// this time, not a Rust-authored nested composite. #[test] fn graph_build_use_end_to_end_axes() { let dir = temp_cwd("use-end-to-end-axes"); @@ -2114,8 +2246,8 @@ fn graph_build_use_end_to_end_axes() { std::fs::write(&consumer_bp, &build.stdout).expect("write consumer envelope"); let (axes_out, axes_err, axes_code) = - run_in(&dir, &["sweep", consumer_bp.to_str().unwrap(), "--list-axes"]); - assert_eq!(axes_code, Some(0), "list-axes: {axes_out} {axes_err}"); + run_in(&dir, &["graph", "introspect", "--params", consumer_bp.to_str().unwrap()]); + assert_eq!(axes_code, Some(0), "graph introspect --params: {axes_out} {axes_err}"); assert_eq!( axes_out, "trend.sma.length:I64\n", "the spliced instance's open param surfaces path-qualified, bare (unbound) RAW form (#328)" diff --git a/crates/aura-cli/tests/help_self_description.rs b/crates/aura-cli/tests/help_self_description.rs index 72ab508..c213a70 100644 --- a/crates/aura-cli/tests/help_self_description.rs +++ b/crates/aura-cli/tests/help_self_description.rs @@ -1,7 +1,7 @@ -//! End-to-end pins for the self-description surfaces (#315, #323): the help -//! opens with the two-layer concept, the sugar verbs name the document shape -//! they desugar to, every introspection roster carries per-entry meanings, -//! and `graph build --help` carries the op-list reference. Driven over the +//! End-to-end pins for the self-description surfaces (#315, #323, #319): the +//! help opens with the two-layer concept, `exec` names both document classes +//! it executes, every introspection roster carries per-entry meanings, and +//! `graph build --help` carries the op-list reference. Driven over the //! built binary — the zero-setup surface a reader without repo access gets. use std::process::Command; @@ -26,7 +26,10 @@ fn run(args: &[&str]) -> (String, String, bool) { fn top_level_help_opens_with_the_two_layer_concept() { let (out, err, ok) = run(&["--help"]); assert!(ok, "aura --help failed: {err}"); - assert!(out.contains("research verbs"), "names the sugar layer: {out}"); + assert!( + out.contains("the one executor over both document classes"), + "names exec + the document classes: {out}" + ); assert!(out.contains("directly authorable"), "names the document data plane: {out}"); assert!(out.contains("bias in [-1,+1]"), "names the bias output: {out}"); assert!(out.contains("defines the risk unit"), "names the protective stop / R: {out}"); @@ -34,26 +37,16 @@ fn top_level_help_opens_with_the_two_layer_concept() { assert!(out.contains("aura chart"), "names the trace consumers: {out}"); } -/// #315: each document-bridged sugar verb's long help names the process -/// shape it desugars to and points at the document layer. `run` (not -/// document-bridged) points at the canonical document-first form instead. +/// #319: `exec --help` names both document classes it accepts — the sugar +/// verbs' retirement leaves `exec` as the one executor over a campaign +/// document (file or content id) or a signal blueprint (single run). #[test] -fn sugar_verbs_name_their_document_shape() { - for (verb, needle) in [ - ("sweep", "std::sweep"), - ("walkforward", "std::walk_forward"), - ("mc", "std::monte_carlo"), - ("generalize", "std::generalize"), - ] { - let (out, err, ok) = run(&[verb, "--help"]); - assert!(ok, "aura {verb} --help failed: {err}"); - assert!(out.contains("Sugar"), "{verb} --help names the sugar relation: {out}"); - assert!(out.contains(needle), "{verb} --help names {needle}: {out}"); - assert!(out.contains("aura campaign"), "{verb} --help points at the documents: {out}"); - } - let (out, err, ok) = run(&["run", "--help"]); - assert!(ok, "aura run --help failed: {err}"); - assert!(out.contains("document-first"), "run --help names the canonical form: {out}"); +fn exec_help_names_both_document_classes() { + let (out, err, ok) = run(&["exec", "--help"]); + assert!(ok, "aura exec --help failed: {err}"); + assert!(out.contains("campaign"), "names the campaign document class: {out}"); + assert!(out.contains("blueprint"), "names the blueprint document class: {out}"); + assert!(out.contains("single run"), "names the blueprint leg's single-run shape: {out}"); } /// #323: `graph build --help` carries the op-list reference — the op kinds diff --git a/crates/aura-cli/tests/project_new.rs b/crates/aura-cli/tests/project_new.rs index 66f89e5..c813ad4 100644 --- a/crates/aura-cli/tests/project_new.rs +++ b/crates/aura-cli/tests/project_new.rs @@ -1,11 +1,12 @@ //! E2E: the `aura new` scaffolder (#241 T4). Proves the data-only authoring -//! loop — scaffold → `aura run`/`aura sweep`, zero Rust toolchain interaction -//! — plus the refusal contract. Scaffolded projects live in per-test temp -//! dirs. +//! loop — scaffold → `aura exec`, zero Rust toolchain interaction — plus the +//! refusal contract. Scaffolded projects live in per-test temp dirs. use std::path::{Path, PathBuf}; use std::process::{Command, Output}; +mod common; + fn aura(args: &[&str], cwd: &Path) -> Output { Command::new(env!("CARGO_BIN_EXE_aura")) .args(args) @@ -61,17 +62,17 @@ fn init_and_commit(dir: &Path) { /// /// Documented deviation from the task text: the plan's wording asks this /// headline to also assert that the run "writes a manifest under `x/runs/`". -/// Verified against `dispatch_run`/`run_signal_r` (main.rs, around the -/// `tx_req`/`rx_req` channel comment): plain `aura run` never touches the -/// trace store — it only prints the `RunReport` to stdout; only -/// `sweep`/`mc`/campaign verbs persist (`env.trace_store()`), a pre-#241 -/// split this task's file list does not touch (giving `run` a store write -/// would widen scope beyond `scaffold.rs`/`main.rs`'s `NewCmd`/`dispatch_new` -/// and this test file). There is thus no on-disk manifest for THIS verb to -/// assert against; the assertion below proves that absence directly instead -/// of only asserting it in prose, and the persisted-store property the plan -/// wanted is covered by `data_only_project_sweeps_without_any_build` below, -/// which does assert `proj.join("runs").exists()`. +/// Verified against `dispatch_exec`/`exec_blueprint_leg`/`run_signal_r` +/// (main.rs): a single-blueprint `aura exec` never touches the trace store — +/// it only prints the `RunReport` to stdout; only exec's campaign leg +/// persists (`env.trace_store()`), a pre-#241 split this task's file list +/// does not touch (giving the blueprint leg a store write would widen scope +/// beyond `scaffold.rs`/`main.rs`'s `NewCmd`/`dispatch_new` and this test +/// file). There is thus no on-disk manifest for THIS leg to assert against; +/// the assertion below proves that absence directly instead of only +/// asserting it in prose, and the persisted-store property the plan wanted +/// is covered by `data_only_project_execs_a_campaign_without_any_build` +/// below, which does assert `proj.join("runs").exists()`. #[test] fn new_scaffolds_and_runs_the_data_only_loop() { let base = tmp("loop"); @@ -102,13 +103,13 @@ fn new_scaffolds_and_runs_the_data_only_loop() { assert!(!proj.join("Cargo.toml").exists(), "data-only project must have no Cargo.toml"); assert!(!proj.join("src/lib.rs").exists(), "data-only project must have no src/lib.rs"); - let a = aura(&["run", "blueprints/signal.json"], &proj); + let a = aura(&["exec", "blueprints/signal.json"], &proj); assert!( a.status.success(), - "run failed: {}", + "exec failed: {}", String::from_utf8_lossy(&a.stderr) ); - let b = aura(&["run", "blueprints/signal.json"], &proj); + let b = aura(&["exec", "blueprints/signal.json"], &proj); assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)"); let v: serde_json::Value = serde_json::from_slice(&a.stdout).expect("report is JSON"); let p = &v["manifest"]["project"]; @@ -116,50 +117,140 @@ fn new_scaffolds_and_runs_the_data_only_loop() { assert!(p.get("namespace").is_none(), "no crate was loaded, namespace must be absent: {p}"); assert!( !proj.join("runs").exists(), - "plain `aura run` must not persist a trace store (only sweep/mc/campaign do) — \ - the task text's \"writes a manifest under x/runs/\" does not hold for this verb" + "a plain single-blueprint `aura exec` must not persist a trace store (only exec's \ + campaign leg does) — the task text's \"writes a manifest under x/runs/\" does not \ + hold for this leg" ); let _ = std::fs::remove_dir_all(&base); } -/// #241 headline: a fresh data-only project sweeps a dissolved verb (#218's -/// gate) with zero build step — using the scaffolded (closed) starter -/// blueprint and the exact axis the scaffolded CLAUDE.md advertises. +/// The minimal executable pipeline (one sweep stage) — copied verbatim from +/// `research_docs.rs`'s constant of the same name. +const SWEEP_ONLY_PROCESS_DOC: &str = r#"{ + "format_version": 1, + "kind": "process", + "name": "sweep-only", + "pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ] +}"#; + +/// Writes a tiny, self-contained January-2024 `SYMA` archive into +/// `/data` and points the scaffolded `Aura.toml`'s `[paths]` table at +/// it. #319: `exec`'s campaign leg (the surviving surface for what `aura +/// sweep --axis` used to do inline) needs a registered instrument, unlike a +/// plain blueprint run — this keeps the scaffolder's "zero host setup" +/// headline intact (no real archive mount) by minting the archive straight +/// into the scaffold instead. +fn seed_synthetic_archive(proj: &Path) { + let data_dir = proj.join("data"); + std::fs::create_dir_all(&data_dir).expect("create synthetic archive dir"); + common::synthetic_data::write_symbol_archive(&data_dir, "SYMA", (2024, 1), (2024, 1)); + let toml_path = proj.join("Aura.toml"); + let toml = std::fs::read_to_string(&toml_path).expect("read scaffolded Aura.toml"); + let toml = toml.replacen("runs = \"runs\"\n", "runs = \"runs\"\ndata = \"data\"\n", 1); + std::fs::write(&toml_path, toml).expect("point Aura.toml at the synthetic archive"); +} + +/// The id extracted from a `registered ()` line (#194: bare +/// id, no `content:` prefix — tolerant of the pre-#194 prefix all the same). +fn registered_id(stdout: &str, kind: &str) -> String { + let needle = format!("registered {kind} "); + stdout + .lines() + .find(|l| l.starts_with(&needle)) + .unwrap_or_else(|| panic!("no \"{needle}\" line: {stdout}")) + .trim_start_matches(&needle) + .split(' ') + .next() + .expect("id") + .trim_start_matches("content:") + .to_string() +} + +/// A one-strategy, one-axis campaign document over `bp_id`/`proc_id` — the +/// surviving surface for what `aura sweep --axis` used to do inline over the +/// scaffolded starter's bound `fast.length` (#246: a bound param is a default +/// an axis overrides). Windowed fully inside `seed_synthetic_archive`'s +/// January-2024 `SYMA` archive. +fn one_axis_campaign_doc(bp_id: &str, proc_id: &str, axis_name: &str, axis_values: &str) -> String { + format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "quickstart", + "data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1704067200000, "to_ms": 1706745599999 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "{axis_name}": {{ "kind": "I64", "values": [{axis_values}] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 1, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"# + ) +} + +/// #241 headline: a fresh data-only project executes a small campaign — #319's +/// surviving surface for the retired `aura sweep` — with zero build step, +/// using the scaffolded (closed) starter blueprint and the exact axis the +/// scaffolded CLAUDE.md advertises. Registering the blueprint straight (`aura +/// graph register`, not a sweep side-effect) and seeding a tiny synthetic +/// archive (`seed_synthetic_archive`) keeps the loop free of any host data +/// mount, matching the pre-retirement headline. /// -/// #246: the closed starter IS the sweep target — a bound param is a default -/// an axis overrides. +/// #246: the closed starter IS the campaign axis target — a bound param is a +/// default an axis overrides. #[test] -fn data_only_project_sweeps_without_any_build() { +fn data_only_project_execs_a_campaign_without_any_build() { let base = tmp("sweep"); let new = aura(&["new", "scratch"], &base); assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr)); let proj = base.join("scratch"); - let out = aura( - &["sweep", "blueprints/signal.json", "--axis", "fast.length=2,4"], - &proj, - ); + seed_synthetic_archive(&proj); + + let reg = aura(&["graph", "register", "blueprints/signal.json"], &proj); + assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(®.stderr)); + let bp_id = registered_id(&String::from_utf8_lossy(®.stdout), "blueprint"); + + std::fs::write(proj.join("quickstart.process.json"), SWEEP_ONLY_PROCESS_DOC) + .expect("write process doc"); + let preg = aura(&["process", "register", "quickstart.process.json"], &proj); + assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr)); + let proc_id = registered_id(&String::from_utf8_lossy(&preg.stdout), "process"); + + let doc = one_axis_campaign_doc(&bp_id, &proc_id, "fast.length", "2, 4"); + std::fs::write(proj.join("quickstart.campaign.json"), &doc).expect("write campaign doc"); + let out = aura(&["exec", "quickstart.campaign.json"], &proj); assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); assert!(proj.join("runs").exists(), "store must land inside the project"); let _ = std::fs::remove_dir_all(&base); } -/// Property (#246): the scaffold's sweep quickstart genuinely re-opens the +/// Property (#246): the scaffold's campaign quickstart genuinely re-opens the /// bound `fast.length` param — the persisted family has exactly one member /// per axis value, not a single collapsed member. A silent regression where /// the bound-param override is dropped (member always built from the /// blueprint's own default) would still exit 0 and still create a `runs` -/// store, so `data_only_project_sweeps_without_any_build` above cannot catch -/// it; only the member count can. +/// store, so `data_only_project_execs_a_campaign_without_any_build` above +/// cannot catch it; only the member count can. #[test] -fn data_only_project_sweep_over_the_starter_opens_one_member_per_axis_value() { +fn data_only_project_campaign_over_the_starter_opens_one_member_per_axis_value() { let base = tmp("sweep-members"); let new = aura(&["new", "scratch"], &base); assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr)); let proj = base.join("scratch"); - let out = aura( - &["sweep", "blueprints/signal.json", "--axis", "fast.length=2,4,8"], - &proj, - ); + seed_synthetic_archive(&proj); + + let reg = aura(&["graph", "register", "blueprints/signal.json"], &proj); + assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(®.stderr)); + let bp_id = registered_id(&String::from_utf8_lossy(®.stdout), "blueprint"); + + std::fs::write(proj.join("members.process.json"), SWEEP_ONLY_PROCESS_DOC) + .expect("write process doc"); + let preg = aura(&["process", "register", "members.process.json"], &proj); + assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr)); + let proc_id = registered_id(&String::from_utf8_lossy(&preg.stdout), "process"); + + let doc = one_axis_campaign_doc(&bp_id, &proc_id, "fast.length", "2, 4, 8"); + std::fs::write(proj.join("members.campaign.json"), &doc).expect("write campaign doc"); + let out = aura(&["exec", "members.campaign.json"], &proj); assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); let fams = aura(&["runs", "families"], &proj); assert!(fams.status.success(), "runs families exit: {:?}", fams.status); @@ -301,10 +392,10 @@ fn new_outside_a_work_tree_leaves_a_resolvable_head() { // ...so the first quickstart run stamps a commit into the manifest, not // an empty provenance block. - let run = aura(&["run", "blueprints/signal.json"], &proj); + let run = aura(&["exec", "blueprints/signal.json"], &proj); assert!( run.status.success(), - "run failed: {}", + "exec failed: {}", String::from_utf8_lossy(&run.stderr) ); let v: serde_json::Value = serde_json::from_slice(&run.stdout).expect("report is JSON"); diff --git a/crates/aura-cli/tests/project_sweep_campaign.rs b/crates/aura-cli/tests/project_sweep_campaign.rs index ec83377..9c493b8 100644 --- a/crates/aura-cli/tests/project_sweep_campaign.rs +++ b/crates/aura-cli/tests/project_sweep_campaign.rs @@ -149,23 +149,26 @@ fn fresh_project() -> (&'static PathBuf, (RunsCleanup, MutexGuard<'static, ()>)) } /// Acceptance box 1 (#235): a project-authored node with an OPEN param is -/// discoverable as a sweep axis, sweeps over real data into a persisted family, -/// and that family reproduces bit-identically — the defining research loop of a -/// genuine external project ("sweep my OWN node's knob"), proven end to end. +/// discoverable as an axis, execs over real data (a campaign — #319: the +/// surviving surface for what `aura sweep --axis` used to do inline) into a +/// persisted family, and that family reproduces bit-identically — the +/// defining research loop of a genuine external project ("vary my OWN node's +/// knob"), proven end to end. #[test] fn project_node_open_param_sweeps_and_reproduces() { let (dir, _g) = fresh_project(); - // (a) `--list-axes` discovers the PROJECT node's own open knob, followed by - // the fixture's three BOUND params as `default=`-lines (#246: every bound - // param is an equally re-openable `--axis`, listed regardless of how many - // knobs happen to be open alongside it). NOT gated: enumerating axes needs - // no data, so the discoverability half runs on every host. The name is RAW - // `.` (#328: the blueprint name stays out of axis paths). - let axes = aura_in(dir, &["sweep", "blueprints/scaled_open.json", "--list-axes"]); + // (a) `graph introspect --params` discovers the PROJECT node's own open + // knob, followed by the fixture's three BOUND params as `default=`-lines + // (#246: every bound param is an equally re-openable axis, listed + // regardless of how many knobs happen to be open alongside it). NOT + // gated: enumerating axes needs no data, so the discoverability half runs + // on every host. The name is RAW `.` (#328: the blueprint + // name stays out of axis paths). + let axes = aura_in(dir, &["graph", "introspect", "--params", "blueprints/scaled_open.json"]); assert!( axes.status.success(), - "--list-axes stderr: {}", + "graph introspect --params stderr: {}", String::from_utf8_lossy(&axes.stderr) ); assert_eq!( @@ -179,37 +182,70 @@ fn project_node_open_param_sweeps_and_reproduces() { String::from_utf8_lossy(&axes.stderr) ); - // The real-data sweep/reproduce legs are archive-gated. + // The real-data campaign/reproduce legs are archive-gated. if !local_data_present() { eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); return; } - // (b) sweep the project node's knob over real GER40 data -> two members. - let sweep = aura_in( - dir, - &[ - "sweep", - "blueprints/scaled_open.json", - "--real", - "GER40", - "--from", - GER40_FROM_MS, - "--to", - GER40_TO_MS, - "--axis", - "gain.factor=0.5,1.0", - "--name", - "knob", - ], + // (b) exec a campaign over the project node's knob, real GER40 data -> + // two members. Register the blueprint straight (not a sweep side-effect) + // and a minimal sweep-only process, then reference both by content id. + let reg = aura_in(dir, &["graph", "register", "blueprints/scaled_open.json"]); + assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(®.stderr)); + let reg_out = String::from_utf8_lossy(®.stdout).into_owned(); + let bp_id = reg_out + .lines() + .find(|l| l.starts_with("registered blueprint ")) + .expect("register line") + .trim_start_matches("registered blueprint ") + .split(' ') + .next() + .expect("blueprint id") + .to_string(); + + std::fs::write(dir.join("knob-reproduce.process.json"), SWEEP_ONLY_PROCESS) + .expect("write process doc"); + let preg = aura_in(dir, &["process", "register", "knob-reproduce.process.json"]); + assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr)); + let preg_out = String::from_utf8_lossy(&preg.stdout).into_owned(); + let proc_id = preg_out + .lines() + .find(|l| l.starts_with("registered process ")) + .expect("register line") + .trim_start_matches("registered process ") + .split(' ') + .next() + .expect("process id") + .to_string(); + + let campaign = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "knob-reproduce", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp}" }}, + "axes": {{ "gain.factor": {{ "kind": "F64", "values": [0.5, 1.0] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + from = GER40_FROM_MS, + to = GER40_TO_MS, + bp = bp_id, + proc = proc_id, ); + std::fs::write(dir.join("knob-reproduce.campaign.json"), &campaign).expect("write campaign doc"); + + let exec = aura_in(dir, &["exec", "knob-reproduce.campaign.json"]); assert!( - sweep.status.success(), - "sweep stderr: {}", - String::from_utf8_lossy(&sweep.stderr) + exec.status.success(), + "exec stderr: {}", + String::from_utf8_lossy(&exec.stderr) ); - let stdout = String::from_utf8_lossy(&sweep.stdout).into_owned(); - let lines: Vec<&str> = stdout.lines().collect(); + let stdout = String::from_utf8_lossy(&exec.stdout).into_owned(); + let lines: Vec<&str> = stdout.lines().filter(|l| l.starts_with(r#"{"family_id":"#)).collect(); assert_eq!(lines.len(), 2, "one member line per factor point: {stdout}"); // Each member's manifest carries the swept PROJECT-node param binding