From c9d962f0ea03b08edcdad2d1028312874af17979 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 4 Jul 2026 19:48:57 +0200 Subject: [PATCH] =?UTF-8?q?fix(cli):=20sweep-entry=20ergonomics=20?= =?UTF-8?q?=E2=80=94=20honest=20refusals,=20no=20store=20litter=20(#210=20?= =?UTF-8?q?c0110=20fieldtest)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three test-first fixes from the cycle-0110 fieldtest findings: - Axis-name preflight on the dissolved sweep arm: every --axis name is checked against the wrapped probe namespace (the --list-axes names) BEFORE the #203 strip; an unknown or raw-form name is refused house-style echoing exactly what the user typed, with a --list-axes pointer — no more stripped-then-mangled fragments from the deep referential refusal. Data-free, fires before the archive is touched. - Validate-before-register in the sweep sugar: intrinsic doc checks + executor preflight + the pure bind_axes coverage check (reused, not re-inlined; now pub(crate)) all run in-memory before register_generated — a refused invocation leaves no generated document in the content-addressed store and no campaign_runs.jsonl line (previously P2/P3 refusals littered runs/campaigns, one doc malformed). - blueprint_slot_prose: the sweep/walkforward/mc loaded-blueprint slots share one dispatch-boundary presenter — an op-script array gets a targeted 'run aura graph build first' hint, every load error goes through blueprint_load_prose + the missing-project hint, and the raw {e:?} Debug leak (#184's pattern, deliberately left on these three slots back then) is gone. Two stale Debug-leak pins flipped, two new refusal tests. Suite 1060/0, clippy -D warnings clean. refs #210 --- crates/aura-cli/src/campaign_run.rs | 2 +- crates/aura-cli/src/graph_construct.rs | 35 ++++++ crates/aura-cli/src/main.rs | 36 ++++-- crates/aura-cli/src/verb_sugar.rs | 41 +++++++ crates/aura-cli/tests/cli_run.rs | 158 +++++++++++++++++++++++-- 5 files changed, 257 insertions(+), 15 deletions(-) diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index 2f7f1c8..15a823d 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -151,7 +151,7 @@ pub(crate) fn raw_matches_wrapped(raw: &str, wrapped: &str) -> bool { /// node segment yields raw), then require every wrapped slot to be covered. /// Pure and independent of the env/data seam so the three fault arms are /// unit-testable without a project, a store, or a loaded blueprint. -fn bind_axes( +pub(crate) fn bind_axes( space: &[ParamSpec], strategy_id: &str, params: &[(String, Scalar)], diff --git a/crates/aura-cli/src/graph_construct.rs b/crates/aura-cli/src/graph_construct.rs index 8487361..caa05a5 100644 --- a/crates/aura-cli/src/graph_construct.rs +++ b/crates/aura-cli/src/graph_construct.rs @@ -344,6 +344,41 @@ pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &crate::project::Env } } +/// Validate a blueprint-slot document — the `sweep`/`walkforward`/`mc` +/// loaded-blueprint branches' dispatch-boundary check (#210 c0110 fieldtest +/// finding: the sweep slot used to print the raw `{e:?}` Debug leak). Single- +/// sourced here so the three dual-grammar subcommands cannot diverge (#184's +/// convention, extended). Two refusals, checked in order: +/// +/// 1. The document parses as JSON but is an op-script ARRAY (#157), not a +/// built blueprint envelope — refused with a targeted hint pointing at +/// `aura graph build`, since `blueprint_from_json` would otherwise surface +/// a confusing internal serde shape mismatch. +/// 2. `blueprint_from_json` fails to load the envelope — phrased house-style +/// via [`blueprint_load_prose`] (+ [`unresolved_namespace_hint`] where it +/// applies), never the raw `LoadError` Debug form. +/// +/// `Ok(())` means the document loaded cleanly; callers that only need the +/// validation (not the `Composite`) re-parse `doc` themselves afterward +/// (`blueprint_from_json` is cheap and infallible at that point). +pub(crate) fn blueprint_slot_prose(doc: &str, env: &crate::project::Env) -> Result<(), String> { + if matches!(serde_json::from_str::(doc), Ok(serde_json::Value::Array(_))) { + return Err( + "this is an op-script (an op array), not a built blueprint — run \ + 'aura graph build' first" + .to_string(), + ); + } + blueprint_from_json(doc, &|t| env.resolve(t)).map(|_| ()).map_err(|e| { + let mut msg = blueprint_load_prose(&e); + if let Some(hint) = unresolved_namespace_hint(&e, env) { + msg.push_str(" — "); + msg.push_str(&hint); + } + msg + }) +} + /// #196: build a `Composite` from a document that is EITHER a #155 blueprint /// envelope (a JSON object: `format_version` + `blueprint`) OR a construction /// op-list (a JSON array) — shape-discriminated on the top-level JSON type, diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 4004c35..394f6e9 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -4427,9 +4427,10 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) { eprintln!("aura: {path}: {e}"); std::process::exit(2); }); - // Parse-validate the blueprint once at the boundary (with file-path context). - if let Err(e) = blueprint_from_json(&doc, &|t| env.resolve(t)) { - eprintln!("aura: {path}: {e:?}"); + // Parse-validate the blueprint once at the boundary (with file-path context), + // house-style prose (#184's convention, single-sourced — #210 c0110 finding). + if let Err(msg) = graph_construct::blueprint_slot_prose(&doc, env) { + eprintln!("aura: {path}: {msg}"); std::process::exit(2); } // A built-in-only flag with a blueprint file is not in this grammar. @@ -4490,6 +4491,27 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) { // strategy ref resolves against this one write; a second // put under `content_id_of(&canonical)` would target the // identical key and is not needed. + // FIX (#210 c0110 finding "wrapped-name refusal mangles the + // axis"): validate every `--axis` name against the WRAPPED + // probe namespace (`blueprint_axis_probe`/`--list-axes` — + // the same names #203's strip later consumes) BEFORE + // stripping/translating to the raw campaign namespace. A + // raw-form or fat-fingered name is refused here, echoing + // exactly what the user typed, instead of surfacing a + // stripped-then-mangled fragment deep inside a member run. + // Data-free (the probe only reloads the blueprint), so + // this fires before the archive is touched. + let space = blueprint_axis_probe(&doc, env).param_space(); + for (n, _) in &axes { + if !space.iter().any(|p| &p.name == n) { + eprintln!( + "aura: axis \"{n}\" is not one of this blueprint's \ + sweepable axes — run 'aura sweep --list-axes' \ + to see them" + ); + std::process::exit(2); + } + } let symbol = symbol.clone(); let source = DataSource::from_choice(data, env); let (from_ts, to_ts) = source.full_window(env); @@ -4593,8 +4615,8 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { eprintln!("aura: {path}: {e}"); std::process::exit(2); }); - if let Err(e) = blueprint_from_json(&doc, &|t| env.resolve(t)) { - eprintln!("aura: {path}: {e:?}"); + if let Err(msg) = graph_construct::blueprint_slot_prose(&doc, env) { + eprintln!("aura: {path}: {msg}"); std::process::exit(2); } if a.strategy.is_some() @@ -4672,8 +4694,8 @@ fn dispatch_mc(a: McCmd, env: &project::Env) { eprintln!("aura: {path}: {e}"); std::process::exit(2); }); - if let Err(e) = blueprint_from_json(&doc, &|t| env.resolve(t)) { - eprintln!("aura: {path}: {e:?}"); + if let Err(msg) = graph_construct::blueprint_slot_prose(&doc, env) { + eprintln!("aura: {path}: {msg}"); std::process::exit(2); } // A built-in-only flag with a blueprint file is not in this grammar diff --git a/crates/aura-cli/src/verb_sugar.rs b/crates/aura-cli/src/verb_sugar.rs index 6cbc406..3d99db3 100644 --- a/crates/aura-cli/src/verb_sugar.rs +++ b/crates/aura-cli/src/verb_sugar.rs @@ -124,6 +124,47 @@ pub(crate) fn run_sweep_sugar( env: &crate::project::Env, ) -> Result<(), String> { let generated = translate_sweep(axes, name, symbol, from_ms, to_ms, blueprint_canonical)?; + + // Validate BEFORE registering anything (#210 c0110 fieldtest, "store + // litter" finding): a referential refusal must never leave a dead + // generated document in the content-addressed store. Defensive doc-shape + // checks first (generated docs should always pass this — `translate_sweep` + // builds them by construction), then the executable-shape preflight, then + // the axis-binding check against the blueprint's own (wrapped) open param + // space — the referential shape the dispatch-boundary name preflight + // (main.rs) does not catch: a subset of axes that leaves an open param + // unbound (probe P3). + let doc_faults = aura_research::validate_campaign(&generated.campaign); + if !doc_faults.is_empty() { + return Err(crate::research_docs::fault_block( + "generated campaign document invalid:", + doc_faults.iter().map(crate::research_docs::doc_fault_prose).collect(), + )); + } + let process_faults = aura_research::validate_process(&generated.process); + if !process_faults.is_empty() { + return Err(crate::research_docs::fault_block( + "generated process document invalid:", + process_faults.iter().map(crate::research_docs::doc_fault_prose).collect(), + )); + } + aura_campaign::preflight(&generated.process, &generated.campaign) + .map_err(|f| crate::campaign_run::exec_fault_prose(&f))?; + + // The pure `bind_axes` seam (campaign_run.rs), reused rather than + // re-inlined (#210): checks every generated axis name resolves to exactly + // one open slot of the WRAPPED param space and every open slot is + // covered. Any one grid value per axis suffices — this checks NAME + // coverage/uniqueness, never the bound value. + let space = crate::blueprint_axis_probe(blueprint_canonical, env).param_space(); + let strategy_id = content_id_of(blueprint_canonical); + let probe_params: Vec<(String, Scalar)> = axes + .iter() + .filter_map(|(n, vals)| vals.first().map(|v| (n.clone(), *v))) + .collect(); + crate::campaign_run::bind_axes(&space, &strategy_id, &probe_params) + .map_err(|f| crate::campaign_run::exec_fault_prose(&aura_campaign::ExecFault::Member(f)))?; + let reg = env.registry(); let (_process_id, campaign_id) = register_generated(®, &generated)?; crate::campaign_run::run_campaign_by_id(&campaign_id, env, crate::campaign_run::RunPresentation::MemberLinesOnly) diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 50446db..211fd2d 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -1615,6 +1615,96 @@ fn sweep_real_blueprint_member_lines_pin_the_dissolved_contract() { let _ = std::fs::remove_dir_all(&dir); } +/// Property (#210 c0110 fieldtest, "the wrapped-name refusal mangles the axis +/// the user typed"): the dissolved real-data blueprint sweep types `--axis` +/// against the WRAPPED probe namespace (`sma_signal.fast.length`, what +/// `--list-axes` prints), not the raw campaign-document namespace +/// (`fast.length`). A user who types the raw form must be refused up front, +/// echoing exactly what they typed — never a stripped-then-mangled fragment +/// (the old failure mode: `fast.length` -> strip one segment -> the +/// nonsensical bare `"length"`). NOT gated: the preflight is data-free (it +/// only reloads the blueprint to probe its param space), so the refusal +/// fires before the archive is ever touched — this test needs no local data. +#[test] +fn aura_sweep_real_blueprint_refuses_a_raw_form_axis_name_before_data_access() { + let dir = temp_cwd("sweep_real_rawname_refusal"); + let fixture = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let out = Command::new(BIN) + .args([ + "sweep", &fixture, + "--real", "GER40", + "--from", GER40_SEPT2024_FROM_MS, + "--to", GER40_SEPT2024_TO_MS, + "--axis", "fast.length=2,4", + "--name", "c0110-rawname", + ]) + .current_dir(&dir) + .output() + .expect("spawn aura sweep (raw-form axis)"); + assert_eq!(out.status.code(), Some(2), "a raw-form axis name must be refused, not run: status={:?}", out.status); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + assert!( + stderr.contains("axis \"fast.length\""), + "the refusal echoes exactly the name the user typed: {stderr}" + ); + assert!( + !stderr.contains("axis \"length\""), + "must never mangle the typed axis into a stripped fragment: {stderr}" + ); + // Data-free: no archive/store access at all. + assert!(!dir.join("runs").exists(), "a refused axis name must not start a real run"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Property (#210 c0110 fieldtest, "refused dissolved sweeps still register +/// their generated campaign document" — store litter, probe P3): binding only +/// a SUBSET of the blueprint's open axes (here: `fast.length`, leaving +/// `slow.length` unbound) is a referential refusal at member-run time — but +/// the sugar must validate the generated documents (including the +/// axis-binding shape) BEFORE `register_generated`, so the refusal leaves +/// `runs/processes/` and `runs/campaigns/` untouched and appends no +/// `campaign_runs.jsonl` record. Gated on the local GER40 archive (the +/// referential refusal is reached only after the archive is probed for the +/// window, same as the sibling dissolved-sweep pin above). +#[test] +fn aura_sweep_real_blueprint_subset_axes_refuses_without_store_litter() { + if !local_data_present() { + eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); + return; + } + let dir = temp_cwd("sweep_real_subset_refusal"); + let fixture = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let out = Command::new(BIN) + .args([ + "sweep", &fixture, + "--real", "GER40", + "--from", GER40_SEPT2024_FROM_MS, + "--to", GER40_SEPT2024_TO_MS, + "--axis", "sma_signal.fast.length=2,4", + "--name", "c0110-subset", + ]) + .current_dir(&dir) + .output() + .expect("spawn aura sweep (subset axes)"); + assert_ne!( + out.status.code(), + Some(0), + "leaving slow.length unbound must refuse, not run: stdout={}", + String::from_utf8_lossy(&out.stdout) + ); + let count = |sub: &str| { + std::fs::read_dir(dir.join("runs").join(sub)).map(|d| d.count()).unwrap_or(0) + }; + assert_eq!(count("processes"), 0, "a refused sweep must not register a generated process document"); + assert_eq!(count("campaigns"), 0, "a refused sweep must not register a generated campaign document"); + let runs_log = dir.join("runs").join("campaign_runs.jsonl"); + assert!( + !runs_log.exists() || std::fs::read_to_string(&runs_log).unwrap().is_empty(), + "no realization recorded for a refused sweep" + ); + let _ = std::fs::remove_dir_all(&dir); +} + /// The synthetic seed-resweep `mc` is undefined over real bars (one realization -> /// identical members), so a bare `aura mc --real EURUSD` — with no R candidate — is /// REJECTED (usage on stderr, exit 2) at the binary boundary. The real path is @@ -3934,8 +4024,10 @@ fn aura_walkforward_over_a_blueprint_rejects_no_axis() { assert!(stderr.contains("requires >= 1 --axis"), "stderr: {stderr}"); } -/// E2E (#173, safety): a malformed blueprint is rejected at the dispatch boundary -/// (exit 2, UnknownNodeType) rather than panicking. +/// E2E (#173/#184, safety): a malformed blueprint is rejected at the dispatch +/// boundary (exit 2) rather than panicking, phrased house-style (#184's +/// `blueprint_load_prose` convention) — never the raw `UnknownNodeType` Debug +/// leak (c0110 fieldtest finding, mirroring `aura_run_rejects_an_unknown_node_blueprint`). #[test] fn aura_walkforward_over_a_blueprint_rejects_malformed() { let bp = format!("{}/tests/fixtures/unknown_node.json", env!("CARGO_MANIFEST_DIR")); @@ -3945,7 +4037,8 @@ fn aura_walkforward_over_a_blueprint_rejects_malformed() { .expect("spawn aura walkforward (malformed)"); assert_eq!(out.status.code(), Some(2), "malformed must be rejected, not panic"); let stderr = String::from_utf8(out.stderr).expect("utf-8"); - assert!(stderr.contains("UnknownNodeType"), "stderr: {stderr}"); + assert!(stderr.contains(r#"unknown node type "Nope""#), "house-style prose names the type: {stderr}"); + assert!(!stderr.contains("UnknownNodeType"), "does not leak the Debug variant name: {stderr}"); assert!(!stderr.contains("panicked"), "must reject cleanly: {stderr}"); } @@ -4182,9 +4275,11 @@ fn aura_sweep_list_axes_rejects_other_flags() { assert!(stderr.contains("--list-axes lists axes"), "stderr was: {stderr}"); } -/// E2E (#169, safety): `--list-axes` over a malformed blueprint rejects cleanly -/// (exit 2, naming UnknownNodeType) rather than panicking — the axis probe reloads -/// the doc, so the dispatch-boundary validation must run first, never a raw `.expect`. +/// E2E (#169/#184, safety): `--list-axes` over a malformed blueprint rejects +/// cleanly (exit 2), phrased house-style — never the raw `UnknownNodeType` +/// Debug leak (c0110 fieldtest finding) — rather than panicking; the axis +/// probe reloads the doc, so the dispatch-boundary validation must run first, +/// never a raw `.expect`. #[test] fn aura_sweep_list_axes_rejects_malformed_blueprint() { let bp = format!("{}/tests/fixtures/unknown_node.json", env!("CARGO_MANIFEST_DIR")); @@ -4194,10 +4289,59 @@ fn aura_sweep_list_axes_rejects_malformed_blueprint() { .expect("spawn aura sweep --list-axes (malformed)"); assert_eq!(out.status.code(), Some(2), "malformed blueprint must be rejected, not panic"); let stderr = String::from_utf8(out.stderr).expect("utf-8"); - assert!(stderr.contains("UnknownNodeType"), "stderr was: {stderr}"); + assert!(stderr.contains(r#"unknown node type "Nope""#), "house-style prose names the type: {stderr}"); + assert!(!stderr.contains("UnknownNodeType"), "does not leak the Debug variant name: {stderr}"); assert!(!stderr.contains("panicked"), "must reject cleanly, not panic: {stderr}"); } +/// E2E (#210 c0110 fieldtest, "aura sweep rejects the op-script +/// array with a raw serde error"): `aura sweep`'s blueprint slot accepts only +/// the built blueprint envelope; an un-built op-script (a JSON array) is +/// shape-discriminated and refused with a targeted, house-style hint — never +/// the leaked `Json(Error("invalid type: map, expected u32", ...))`. +#[test] +fn aura_sweep_rejects_an_op_script_array_with_a_build_first_hint() { + let dir = temp_cwd("sweep_rejects_op_script"); + let op_script = dir.join("ops.json"); + std::fs::write(&op_script, r#"[{"op":"source","role":"price","kind":"F64"}]"#) + .expect("write op-script fixture"); + let out = Command::new(BIN) + .args(["sweep", op_script.to_str().unwrap(), "--list-axes"]) + .output() + .expect("spawn aura sweep (op-script)"); + assert_eq!(out.status.code(), Some(2), "an op-script array must be refused, not panic"); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + assert!( + stderr.contains("op-script") && stderr.contains("aura graph build"), + "names the op-script cause + the fix: {stderr}" + ); + assert!(!stderr.contains("Json(Error"), "does not leak the raw serde error: {stderr}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// E2E (#210 c0110 fieldtest, house-style loader prose): a genuinely malformed +/// (syntactically invalid) JSON document in the sweep blueprint slot is +/// refused with `blueprint_load_prose`'s wording, never the raw `{e:?}` Debug +/// form the dispatch used to print. +#[test] +fn aura_sweep_rejects_malformed_json_house_style() { + let dir = temp_cwd("sweep_rejects_malformed_json"); + let bad = dir.join("bad.json"); + std::fs::write(&bad, "{not valid json").expect("write malformed fixture"); + let out = Command::new(BIN) + .args(["sweep", bad.to_str().unwrap(), "--list-axes"]) + .output() + .expect("spawn aura sweep (malformed json)"); + assert_eq!(out.status.code(), Some(2), "malformed JSON must be refused, not panic"); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + assert!( + stderr.contains("blueprint document is not valid JSON"), + "house-style prose, not a raw Debug leak: {stderr}" + ); + assert!(!stderr.contains("Json(Error"), "does not leak the raw serde Debug form: {stderr}"); + let _ = std::fs::remove_dir_all(&dir); +} + /// E2E (#169, the load-bearing "listed == swept" invariant): a name emitted by /// `--list-axes` is accepted VERBATIM by `--axis` — the discovered axis namespace /// and the swept axis namespace are the same set (shared-probe construction). The