fix(cli): sweep-entry ergonomics — honest refusals, no store litter (#210 c0110 fieldtest)

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
This commit is contained in:
2026-07-04 19:48:57 +02:00
parent f96796cd08
commit c9d962f0ea
5 changed files with 257 additions and 15 deletions
+1 -1
View File
@@ -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)],
+35
View File
@@ -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::<serde_json::Value>(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,
+29 -7
View File
@@ -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 <bp> --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
+41
View File
@@ -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(&reg, &generated)?;
crate::campaign_run::run_campaign_by_id(&campaign_id, env, crate::campaign_run::RunPresentation::MemberLinesOnly)
+151 -7
View File
@@ -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 <op-script> 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