diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 4a12411..1f1b074 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -430,7 +430,8 @@ fn render_chart_by_kind( eprintln!( "aura: no recorded run or family '{name}' under runs/traces \ (check the handle a sweep/walk-forward/campaign run printed for a typo — \ - re-running with this handle as `--trace` will not create it)" + a trace is produced by `aura exec --tap =` on a blueprint \ + or a campaign's `presentation.persist_taps` section, not by naming a handle here)" ); std::process::exit(1); } @@ -1069,6 +1070,23 @@ document declares its persisted taps itself" } } +/// Bug 2 (#319 fieldtest cycle 2): `catch_member_panic`'s payload is either a +/// node's own constructor message (e.g. `Sma::new`'s "SMA length must be >= 1") +/// — already a real diagnostic, rendered verbatim (existing tests pin it +/// byte-identical) — or a raw library/allocator panic string that names no +/// domain rule of any node at all (a negative length overflows the ring-buffer +/// allocation before the SMA's own check ever runs). The latter is not a +/// diagnostic; wrap only the known non-diagnostic payloads in prose that +/// tells the caller what actually happened, keeping the raw string as +/// parenthetical detail rather than the whole message. +fn panic_refusal_prose(msg: &str) -> String { + if msg == "capacity overflow" { + format!("param value out of domain for this node (panic: {msg}) — check the param's valid range") + } else { + msg.to_string() + } +} + /// The `exec` blueprint leg (#319): a synthetic single run of a fully-bound /// blueprint envelope, composed 1:1 from `dispatch_run`'s `.json`-branch /// building blocks — same intake gate, same shape dispatch (a `bias` output @@ -1199,7 +1217,7 @@ fn exec_blueprint_leg(path: &str, overrides: &[String], tap: &[String], env: &au run_signal_r(signal, ¶ms, RunData::Synthetic, 0, env, tap_plan) }) .unwrap_or_else(|msg| { - eprintln!("aura: {msg}"); + eprintln!("aura: {}", panic_refusal_prose(&msg)); std::process::exit(1); }); println!("{}", report.to_json()); diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 0929e1a..48d17c1 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -421,6 +421,31 @@ fn chart_unknown_name_refuses_with_exit_1_and_message() { let _ = std::fs::remove_dir_all(&cwd); } +/// BUG 4 (#319 fieldtest cycle 2): the not-found remedy still names `--trace` +/// — a flag no verb carries anymore (verb dissolution, #220). It must point +/// at the CURRENT trace-production surface instead: `exec --tap` for a +/// single blueprint, or a campaign's `presentation.persist_taps` section. +#[test] +fn chart_unknown_name_refusal_names_the_current_trace_surface_not_trace_flag() { + let cwd = temp_cwd("chart-unknown-trace-surface"); + + let out = + Command::new(BIN).args(["chart", "ghost"]).current_dir(&cwd).output().expect("spawn chart ghost"); + assert_eq!(out.status.code(), Some(1), "unknown name must exit 1: {:?}", out.status); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("--trace"), + "the retired --trace flag must not appear anywhere in the refusal: {stderr}" + ); + assert!( + stderr.contains("--tap") && stderr.contains("persist_taps"), + "the refusal must point at the current trace-production surface \ + (exec --tap / a campaign's presentation.persist_taps): {stderr}" + ); + + let _ = std::fs::remove_dir_all(&cwd); +} + /// Property (milestone fieldtest B1): a sweep / walk-forward `--trace` family is /// chartable by the very handle the producing run prints. Those verbs fan a /// selection-free sweep out per member into the #224 depth-2 layout diff --git a/crates/aura-cli/tests/exec.rs b/crates/aura-cli/tests/exec.rs index 77b396e..e8733dc 100644 --- a/crates/aura-cli/tests/exec.rs +++ b/crates/aura-cli/tests/exec.rs @@ -546,6 +546,48 @@ fn exec_override_negative_value_refuses_not_panics() { assert!(out.contains("capacity overflow"), "stdout/stderr: {out}"); } +/// BUG 2 (#319 fieldtest cycle 2): a negative override value still exits 1 +/// (the earlier fix caught the panic), but the message rendered the raw +/// allocator string verbatim (`aura: capacity overflow`) — a library panic +/// payload, not a user-facing diagnostic naming any node's domain rule. The +/// known non-diagnostic payload must be wrapped in domain-refusal prose (the +/// raw string kept as parenthetical detail, never the entire message), while +/// a real constructor message (`SMA length must be >= 1`, +/// `exec_override_out_of_domain_value_refuses_not_panics` above) stays +/// byte-identical. +#[test] +fn exec_override_negative_value_wraps_the_raw_panic_in_domain_prose() { + let (out, code) = run_code(&["exec", "examples/r_sma.json", "--override", "fast.length=-1"]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!( + out.contains("out of domain"), + "the raw allocator string must be wrapped in a domain refusal: {out}" + ); + assert_ne!( + out.trim(), + "aura: capacity overflow", + "the bare allocator string must not be the whole message: {out}" + ); +} + +/// BUG 3 (#319 fieldtest cycle 2): a kind-mismatched override (`bias.scale` +/// is F64, `2` lexes as I64) reaches the exec leg's compile boundary as a +/// raw `CompileError::ParamKindMismatch { .. }` Debug struct. It must render +/// as prose naming the param path and both kinds, mirroring the campaign +/// leg's `ref_fault_prose` treatment of the same fault class. +#[test] +fn exec_override_kind_mismatch_refuses_with_prose_not_the_debug_struct() { + let (out, code) = run_code(&["exec", "examples/r_sma.json", "--override", "bias.scale=2"]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!( + !out.contains("ParamKindMismatch"), + "the Debug struct must not leak: {out}" + ); + assert!(out.contains("bias.scale"), "the param path must be named: {out}"); + assert!(out.contains("F64"), "the expected kind must be named: {out}"); + assert!(out.contains("I64"), "the got kind must be named: {out}"); +} + /// BUG B2 (fieldtest, #319 exec --override leg): the retired pre-#328 /// WRAPPED override form (`..`) panics at the /// params-zip `.expect` in `exec_blueprint_leg` instead of refusing: diff --git a/crates/aura-research/src/lib.rs b/crates/aura-research/src/lib.rs index 34f000f..37e232e 100644 --- a/crates/aura-research/src/lib.rs +++ b/crates/aura-research/src/lib.rs @@ -1309,14 +1309,31 @@ pub fn open_slots_campaign(text: &str) -> Result, DocError> { if v.get("seed").and_then(|s| s.as_u64()).is_none() { slots.push(open("seed", "required, non-negative integer")); } - if v.get("presentation").is_none() { - slots.push(open( + match v.get("presentation") { + None => slots.push(open( "presentation", format!( "required section: persist_taps ({}) + emit", tap_vocabulary().iter().map(|t| t.id).collect::>().join(" | ") ), - )); + )), + Some(p) => { + if p.get("persist_taps").and_then(|x| x.as_array()).is_none() { + slots.push(open( + "presentation.persist_taps", + format!( + "required, list of: {}", + tap_vocabulary().iter().map(|t| t.id).collect::>().join(" | ") + ), + )); + } + if p.get("emit").and_then(|x| x.as_array()).is_none() { + slots.push(open( + "presentation.emit", + format!("required, list of: {}", emit_vocabulary().join(" | ")), + )); + } + } } Ok(slots) } @@ -2294,6 +2311,36 @@ mod tests { ); } + /// #319 fieldtest bug 1: a present-but-empty `presentation: {}` must drill + /// down into its two required sub-slots exactly as a present-but-empty + /// `data: {}` drills into `instruments`/`windows` — the walker must not + /// stop at "the section exists" without checking what's inside it. + #[test] + fn presentation_drills_into_an_empty_section_like_data_does() { + let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft", + "data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] }, + "strategies": [ { "ref": { "content_id": "9f3a" }, + "axes": { "slow": { "kind": "I64", "values": [10] } } } ], + "process": { "ref": { "content_id": "4e2d" } }, + "seed": 1, + "presentation": {} }"#; + let slots = open_slots_campaign(draft).unwrap(); + assert!( + !slots.iter().any(|s| s.path == "presentation"), + "an empty-but-present presentation is not the same open slot as an absent one: {slots:?}" + ); + assert!( + slots.iter().any(|s| s.path == "presentation.persist_taps" + && s.hint == "required, list of: equity | exposure | r_equity | net_r_equity"), + "presentation.persist_taps must be named as its own open slot: {slots:?}" + ); + assert!( + slots.iter().any(|s| s.path == "presentation.emit" + && s.hint == "required, list of: family_table | selection_report"), + "presentation.emit must be named as its own open slot: {slots:?}" + ); + } + /// #256 fork B: the fieldless enumerate-only stage round-trips through /// the schema-strict parser, and any slot on it is refused (its slot /// list is empty, so the generic unknown-slot check covers every key). diff --git a/crates/aura-runner/src/member.rs b/crates/aura-runner/src/member.rs index 69fd3d6..8b4ab83 100644 --- a/crates/aura-runner/src/member.rs +++ b/crates/aura-runner/src/member.rs @@ -15,8 +15,8 @@ use std::sync::{mpsc, Arc, LazyLock}; use aura_composites::{cost_graph, risk_executor, StopRule}; use aura_core::{zip_params, Cell, Firing, ParamSpec, PrimitiveBuilder, Scalar, ScalarKind, Timestamp}; use aura_engine::{ - blueprint_from_json, window_of, BlueprintNode, Composite, GraphBuilder, Harness, RunManifest, - VecSource, + blueprint_from_json, window_of, BlueprintNode, CompileError, Composite, GraphBuilder, Harness, + RunManifest, VecSource, }; use aura_backtest::{ summarize, summarize_r, RunMetrics, RunReport, SimBroker, PM_FIELD_NAMES, PM_RECORD_KINDS, @@ -471,6 +471,49 @@ pub fn resolve_run_data( } } +/// The variant-name string for a `ScalarKind`, matching the wire/CLI spelling +/// used across the codebase (`F64`/`I64`/`Bool`/`Timestamp`). +fn scalar_kind_name(kind: ScalarKind) -> &'static str { + match kind { + ScalarKind::F64 => "F64", + ScalarKind::I64 => "I64", + ScalarKind::Bool => "Bool", + ScalarKind::Timestamp => "Timestamp", + } +} + +/// Bug 3 (#319 fieldtest cycle 2): an `--override` value whose lexed kind +/// does not match the param's declared kind (e.g. the bare literal `2` for +/// an F64 param) reaches this compile boundary as a raw +/// `CompileError::ParamKindMismatch { slot, expected, got }` — `slot` is a +/// flat param-space index, meaningless to a caller. `names`/`params` are the +/// SAME order `compile_with_params` was called with (built from `signal` +/// before it was consumed by `wrap_r`, `C11`: composites inline, preserving +/// order), so `slot` indexes both. This names the param path and both kinds, +/// mirroring the campaign leg's own kind-fault prose +/// (`ref_fault_prose`'s `AxisKindMismatch` arm, `research_docs.rs`). Every +/// other `CompileError` variant keeps the existing Debug fallback — out of +/// this bug's scope, and `names`/`params` only reconstruct the one variant +/// that carries an injected value. +fn compile_error_prose(e: &CompileError, names: &[String], params: &[Scalar]) -> String { + let CompileError::ParamKindMismatch { slot, expected, got } = e else { + return format!("this blueprint does not compile to a runnable harness: {e:?}"); + }; + let path = names.get(*slot).map(String::as_str).unwrap_or(""); + let example = match (expected, params.get(*slot)) { + (ScalarKind::F64, Some(Scalar::I64(v))) => format!("{v}.0"), + (ScalarKind::F64, _) => "a decimal literal, e.g. 2.0".to_string(), + (ScalarKind::I64, _) => "a bare integer literal, e.g. 2".to_string(), + (ScalarKind::Bool, _) => "true or false".to_string(), + (ScalarKind::Timestamp, _) => "an integer epoch-ns literal".to_string(), + }; + format!( + "--override {path}: expects {}, got {} — write {example}", + scalar_kind_name(*expected), + scalar_kind_name(*got) + ) +} + /// Run a signal blueprint through the R scaffolding: hash the signal, /// wrap it (broker + equity/exposure/R sinks), compile with `params`, bootstrap, /// run over `data`, and build the RunReport (manifest carries topology_hash). @@ -514,7 +557,7 @@ pub fn run_signal_r( let (sources, window, pip_size) = resolve_run_data(&data, env, &binding); let wrapped = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, false, pip_size, &binding, None); let mut flat = wrapped.compile_with_params(params).unwrap_or_else(|e| { - eprintln!("aura: this blueprint does not compile to a runnable harness: {e:?}"); + eprintln!("aura: {}", compile_error_prose(&e, &names, params)); std::process::exit(1); }); // Bind each declared tap per the plan's subscription, before bootstrap