fix(aura-research, aura-cli, aura-runner): milestone-fieldtest bugs — the unwired ramp hole + three diagnostics

RED-first fixes for the four M42 A/B findings. campaign introspect
--unwired now drills into a present-but-empty presentation section
exactly as it drills into data, listing persist_taps/emit as open slots
— the miss caused three of the cold start's five dead ends (the process
sibling has no analogous nested-required shape, verified). The override
panic catch wraps known non-diagnostic payloads (capacity overflow) in
domain prose while passing real constructor messages through
byte-identical; a kind-mismatched override renders as prose naming
path, expected and got kinds with a write-this example instead of the
ParamKindMismatch debug struct; chart's unknown-handle refusal names
the current trace surface (exec --tap / presentation.persist_taps)
instead of the retired --trace flag.

closes #319
This commit is contained in:
2026-07-26 00:37:28 +02:00
parent e6b60bf680
commit 77ad0465cb
5 changed files with 183 additions and 8 deletions
+20 -2
View File
@@ -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 <NODE.FIELD>=<FOLD>` 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, &params, 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());
+25
View File
@@ -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
+42
View File
@@ -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 (`<rootname>.<node>.<param>`) panics at the
/// params-zip `.expect` in `exec_blueprint_leg` instead of refusing:
+50 -3
View File
@@ -1309,14 +1309,31 @@ pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, 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::<Vec<_>>().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::<Vec<_>>().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).
+46 -3
View File
@@ -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("<unknown>");
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