feat(aura-cli, aura-engine): zero-trade cell + synthetic-smoke notes; pending-builder serialize fence

Harvest sweep, batch 4 of 6.

- present_campaign's cell loop gains the non-walk-forward sibling of
  the #313 note: a cell whose families are all non-wf and whose every
  report traded zero times gets 'aura: note: ... metrics are vacuous,
  not a break-even result' on stderr (emit-independent; wf cells keep
  their window note, gate-truncated cells stay silent).
- exec's single-run bias leg (always RunData::Synthetic — a direct
  blueprint exec never binds real data) notes on a zero-trade run that
  the built-in 18-cycle stream is a smoke fixture and points at a
  campaign document's data section for a meaningful run.
- blueprint_serde refuses to serialize a still-pending arg-bearing
  builder (SerializeError::PendingBuilder naming node type + missing
  args + the try_args remedy) instead of emitting an args-free v1
  document whose fault only surfaces at load as MissingArg — the
  write-side twin of the load-path fence; only the Rust GraphBuilder
  path can reach this state.

closes #324
refs #341
This commit is contained in:
2026-07-26 12:26:27 +02:00
parent 2b692a71e3
commit d2b0cdf64c
5 changed files with 283 additions and 14 deletions
+15
View File
@@ -380,6 +380,21 @@ fn present_campaign(run: CampaignRun, env: &Env) -> Result<usize, String> {
crate::diag::note_zero_trade_windows(
wf.reports.iter().map(|r| r.metrics.r.as_ref().map_or(0, |m| m.n_trades)),
);
} else if !cell_out.families.is_empty() {
// #324 (re-scoped): the non-walk-forward sibling — a cell whose
// families are ALL non-`std::walk_forward` (grid sweep, MC
// bootstrap, …) has no OOS-window vocabulary, so without a note
// here a cell that never traded is indistinguishable from a
// break-even one. Over every report across every family this
// cell ran (a cell with zero families, e.g. gate-truncated,
// emits nothing new here).
let trades: Vec<u64> = cell_out
.families
.iter()
.flat_map(|f| f.reports.iter())
.map(|r| r.metrics.r.as_ref().map_or(0, |m| m.n_trades))
.collect();
crate::diag::note_zero_trade_cell(trades.into_iter());
}
if emit_family {
for fam in &cell_out.families {
+61 -1
View File
@@ -44,9 +44,53 @@ pub(crate) fn note_zero_trade_windows(mut window_trades: impl ExactSizeIterator<
}
}
/// #324 (re-scoped): the non-walk-forward sibling of `note_zero_trade_windows`
/// — a campaign cell whose families are ALL non-walk-forward (grid sweep, MC
/// bootstrap, …) has no per-window vocabulary to note through, yet a
/// consumer still cannot tell "0.0 = break-even" from "0.0 = never traded"
/// without this. Takes the per-report trade counts across every family the
/// cell ran; a no-op unless there is at least one report and every one of
/// them traded zero times.
fn zero_trade_cell_note_text(n_reports: usize) -> String {
if n_reports == 1 {
"the cell's one report recorded zero trades; its metrics are vacuous, not a break-even \
result"
.to_string()
} else {
format!(
"the cell's {n_reports} reports all recorded zero trades; its metrics are vacuous, \
not a break-even result"
)
}
}
/// See `zero_trade_cell_note_text`. Called from the campaign cell loop's
/// non-walk-forward branch (`campaign_run.rs`), independent of `emit`.
pub(crate) fn note_zero_trade_cell(mut report_trades: impl ExactSizeIterator<Item = u64>) {
let n_reports = report_trades.len();
if n_reports > 0 && report_trades.all(|n| n == 0) {
note!("{}", zero_trade_cell_note_text(n_reports));
}
}
/// #324 (comment 4501): `exec <blueprint.json>`'s single-run signal/bias leg
/// always drives the built-in synthetic stream (`RunData::Synthetic`, no
/// direct-blueprint exec ever binds real data — only a campaign document's
/// `data` section does) — a tiny `n_cycles`-bar smoke fixture, not market
/// data. Zero trades on it is expected, not a broken strategy; a caller that
/// validates against it needs telling so. Unconditional — the caller gates
/// on the zero-trade condition before calling.
pub(crate) fn note_synthetic_smoke_zero_trades(n_cycles: usize) {
note!(
"the built-in synthetic stream ({n_cycles} cycles) is a small smoke fixture; zero \
trades is expected here — bind real data via a campaign document's `data` section \
(`aura exec <campaign.json>`) for a meaningful run"
);
}
#[cfg(test)]
mod tests {
use super::zero_trade_note_text;
use super::{zero_trade_cell_note_text, zero_trade_note_text};
#[test]
/// #278 fieldtest friction: the single-window form must read as
@@ -62,4 +106,20 @@ mod tests {
"all 3 walk-forward windows recorded zero trades"
);
}
#[test]
/// #324: the cell-level sibling pluralizes the same way and names the
/// vacuous-metrics property, not just the raw zero count.
fn zero_trade_cell_note_pluralizes() {
assert_eq!(
zero_trade_cell_note_text(1),
"the cell's one report recorded zero trades; its metrics are vacuous, not a \
break-even result"
);
assert_eq!(
zero_trade_cell_note_text(4),
"the cell's 4 reports all recorded zero trades; its metrics are vacuous, not a \
break-even result"
);
}
}
+12
View File
@@ -53,6 +53,11 @@ use aura_runner::member::{
use aura_runner::{TapPlan, TapSubscription};
#[cfg(test)]
use aura_runner::member::{run_blueprint_member, wrap_r, SYNTHETIC_PIP_SIZE};
// `r_sma_prices` sizes the #324 synthetic-smoke-fixture note (`exec`'s
// single-run bias leg names the built-in stream's actual cycle count) —
// production-reached now, not test-only (contrast the `#[cfg(test)]`
// imports around it, which lost their production callers to #295).
use aura_runner::member::r_sma_prices;
// The shared `DataSource` data provider lives in `aura_runner::family`
// (#295 Task 8) — its production caller is `aura_runner::reproduce`;
// `showcase_prices`/`synthetic_walk_sources` are its synthetic stream
@@ -1300,6 +1305,13 @@ fn exec_blueprint_leg(path: &str, overrides: &[String], tap: &[String], env: &au
eprintln!("aura: {}", panic_refusal_prose(&msg));
std::process::exit(1);
});
// #324 (comment 4501): this leg always runs `RunData::Synthetic` (no
// direct-blueprint exec ever binds real data), so a validating
// caller reading zero trades here as "broken strategy" needs telling
// it never touched real data.
if report.metrics.r.as_ref().map_or(0, |m| m.n_trades) == 0 {
crate::diag::note_synthetic_smoke_zero_trades(r_sma_prices().len());
}
println!("{}", report.to_json());
} else if has_tap {
if !overrides.is_empty() {
+105
View File
@@ -359,6 +359,45 @@ fn exec_blueprint_file_emits_the_single_run_record_line() {
assert!(v["manifest"].get("commit").is_some());
}
/// #324 (comment 4501): the built-in synthetic stream `exec`'s bias leg runs
/// on is a small (18-cycle) smoke fixture — a degenerate override (fast and
/// slow SMA lengths coinciding, the same trick the campaign zero-trade
/// fixtures use) makes the bias-difference constantly zero, so the run
/// trades zero times over it. The note names the fixture and its cycle
/// count instead of leaving the all-zero metrics reading as a broken
/// strategy; the record line and exit code are unaffected.
#[test]
fn exec_blueprint_zero_trades_on_synthetic_emits_smoke_fixture_note() {
let (out, code) = run_code(&["exec", "examples/r_sma.json", "--override", "fast.length=4"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
assert!(
out.contains("aura: note: the built-in synthetic stream (18 cycles) is a small smoke \
fixture"),
"the smoke-fixture note must fire, naming the actual cycle count: {out}"
);
let line = out.lines().find(|l| l.starts_with('{')).expect("record line");
let v: serde_json::Value = serde_json::from_str(line).expect("record parses");
assert_eq!(
v["metrics"]["r"]["n_trades"],
serde_json::json!(0),
"the degenerate equal-length override must trade zero times: {out}"
);
}
/// Negative twin: the default (non-degenerate, fast=2/slow=4) run trades at
/// least once over the same synthetic stream, so no smoke-fixture note
/// fires — `exec_blueprint_file_emits_the_single_run_record_line`'s fixture,
/// re-asserted here for the note's absence.
#[test]
fn exec_blueprint_with_trades_on_synthetic_emits_no_smoke_fixture_note() {
let (out, code) = run_code(&["exec", "examples/r_sma.json"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
assert!(
!out.contains("small smoke fixture"),
"a traded synthetic run emits no smoke-fixture note: {out}"
);
}
/// The migrated root-name gate (retirement inventory): exec's blueprint
/// intake refuses a bad root name with `run`'s exact prose, before any
/// trace write — mirrors
@@ -956,6 +995,72 @@ fn exec_campaign_walkforward_with_trades_emits_no_zero_trade_note() {
assert!(!out.contains("recorded zero trades"), "a traded run emits no zero-trade note: {out}");
}
// ---------------------------------------------------------------------------
// #324 (re-scoped): zero-trade note for the non-walk-forward campaign leg —
// a cell whose families are ALL non-walk-forward (grid sweep here) has no
// per-window vocabulary, so it needs its own cell-level sibling note.
// ---------------------------------------------------------------------------
/// E2E (#324, non-wf twin of the #313 wf note): a grid-sweep-only cell (no
/// `std::walk_forward` stage — `SWEEP_ONLY_PROCESS_DOC`) whose every
/// reported member trades zero times emits the cell-level zero-trade note.
/// Same degenerate equal-length point the wf fixture above uses (constant
/// zero SMA-difference, no window trades).
#[test]
fn exec_campaign_grid_cell_all_zero_trades_emits_cell_note() {
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.clone()),
ScratchPath::File(dir.join("gridzero.process.json")),
ScratchPath::File(dir.join("gridzero.campaign.json")),
]);
let bp_id = seed_blueprint(&dir, "exec-grid-zero-seed");
let proc_id = register_process_doc(&dir, "gridzero.process.json", SWEEP_ONLY_PROCESS_DOC);
let doc = campaign_doc_json_walkforward(
"SYMA",
&bp_id,
&proc_id,
(1709251200000, 1719791999999),
"8",
"8",
);
write_doc(&dir, "gridzero.campaign.json", &doc);
let (out, code) = run_code_in(&dir, &["exec", "gridzero.campaign.json"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
assert!(
out.contains("recorded zero trades") && out.contains("its metrics are vacuous"),
"the cell-level zero-trade note must fire: {out}"
);
assert!(out.contains("aura: note: "), "the note carries the class marker: {out}");
}
/// E2E (#324 negative twin): a grid-sweep-only cell with at least one traded
/// member emits no zero-trade note — the fixture is `campaign_doc_json_for`'s
/// known-trading axes (fast∈{2,4}, slow∈{8,16}), the same shape the override
/// tests above already run over `SWEEP_ONLY_PROCESS_DOC`.
#[test]
fn exec_campaign_grid_cell_with_trades_emits_no_zero_trade_note() {
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.clone()),
ScratchPath::File(dir.join("gridtrades.process.json")),
ScratchPath::File(dir.join("gridtrades.campaign.json")),
]);
let bp_id = seed_blueprint(&dir, "exec-grid-trades-seed");
let proc_id = register_process_doc(&dir, "gridtrades.process.json", SWEEP_ONLY_PROCESS_DOC);
let doc = campaign_doc_json_for("SYMA", &bp_id, &proc_id, (1709251200000, 1719791999999));
write_doc(&dir, "gridtrades.campaign.json", &doc);
let (out, code) = run_code_in(&dir, &["exec", "gridtrades.campaign.json"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
assert!(!out.contains("recorded zero trades"), "a traded cell emits no zero-trade note: {out}");
}
// ---------------------------------------------------------------------------
// Task 9: `aura-runner` family-builder retirement — the run-semantics half of
// the retired `blueprint_sweep_family` unit test, ported onto the surviving
+90 -13
View File
@@ -103,9 +103,33 @@ pub struct ArgData {
#[derive(Debug)]
pub enum SerializeError {
Json(serde_json::Error),
/// #341: an arg-bearing (`PrimitiveBuilder::pending`) node reached the
/// serialize seam still unconfigured. Only the Rust `GraphBuilder`/
/// `Composite::new` path can build this state — the data path
/// (`add_node`) always runs `try_args` first — but emitting it as an
/// args-free v1 document would produce a document `blueprint_from_json`
/// can only refuse as `LoadError::BadArg(ArgOpError::MissingArg(..))` at
/// LOAD time; refusing here instead names the fault at its origin.
/// `missing_args` is the pending recipe's full declared `ArgSpec` list
/// (none configured yet, so every declared arg is missing).
PendingBuilder { node_type: String, missing_args: Vec<String> },
}
fn project(c: &Composite) -> CompositeData {
impl std::fmt::Display for SerializeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SerializeError::Json(e) => write!(f, "{e}"),
SerializeError::PendingBuilder { node_type, missing_args } => write!(
f,
"node \"{node_type}\" is an unconfigured arg-bearing builder (missing arg(s): \
{}); call try_args to configure it before it enters a graph",
missing_args.join(", ")
),
}
}
}
fn project(c: &Composite) -> Result<CompositeData, SerializeError> {
// Canonical gang order (bind-order-independent, mirroring `project_node`'s
// bound-param canonicalization below): each gang's members ascending
// `(node, pos)`, then the gangs themselves ordered by their (now-sorted)
@@ -116,21 +140,30 @@ fn project(c: &Composite) -> CompositeData {
}
gangs.sort_by_key(|g| (g.members[0].node, g.members[0].pos));
CompositeData {
Ok(CompositeData {
name: c.name().to_string(),
doc: c.doc().map(str::to_string),
nodes: c.nodes().iter().map(project_node).collect(),
nodes: c.nodes().iter().map(project_node).collect::<Result<Vec<_>, _>>()?,
edges: c.edges().to_vec(),
input_roles: c.input_roles().to_vec(),
output: c.output().to_vec(),
taps: c.taps().to_vec(),
gangs,
}
})
}
fn project_node(n: &BlueprintNode) -> NodeData {
fn project_node(n: &BlueprintNode) -> Result<NodeData, SerializeError> {
match n {
BlueprintNode::Primitive(b) => {
// #341: refuse a still-pending arg-bearing recipe outright — an
// args-free v1 document built from it would only surface its
// fault at load (`LoadError::BadArg(MissingArg)`), never here.
if b.is_pending() {
return Err(SerializeError::PendingBuilder {
node_type: b.label(),
missing_args: b.arg_specs().iter().map(|s| s.name.to_string()).collect(),
});
}
// Canonical: bound params in ascending original-slot order, mirroring
// the loader's re-bind canonicalization, so serialization is
// bind-order-independent (a precondition for content-addressing, #158).
@@ -147,14 +180,14 @@ fn project_node(n: &BlueprintNode) -> NodeData {
.iter()
.map(|ConstructionArg { name, value }| ArgData { name: name.clone(), value: value.clone() })
.collect();
NodeData::Primitive(PrimitiveData {
Ok(NodeData::Primitive(PrimitiveData {
type_id: b.label(),
name: b.instance_name().map(str::to_string),
args,
bound,
})
}))
}
BlueprintNode::Composite(c) => NodeData::Composite(project(c)),
BlueprintNode::Composite(c) => Ok(NodeData::Composite(project(c)?)),
}
}
@@ -180,10 +213,10 @@ fn document_version(b: &CompositeData) -> u32 {
}
}
fn build_doc(c: &Composite) -> BlueprintDoc {
let blueprint = project(c);
fn build_doc(c: &Composite) -> Result<BlueprintDoc, SerializeError> {
let blueprint = project(c)?;
let format_version = document_version(&blueprint);
BlueprintDoc { format_version, blueprint }
Ok(BlueprintDoc { format_version, blueprint })
}
fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
@@ -194,7 +227,7 @@ fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
/// field order, defaults omitted (`skip_serializing_if`). An absent optional is
/// byte-identical to the pre-extension form.
pub fn blueprint_to_json(c: &Composite) -> Result<String, SerializeError> {
serialize_doc(&build_doc(c))
serialize_doc(&build_doc(c)?)
}
/// The identity-canonical form (#171): the canonical document with every
@@ -207,7 +240,7 @@ pub fn blueprint_to_json(c: &Composite) -> Result<String, SerializeError> {
/// form ONLY — never a load path and never the reproduction store's byte form
/// (`reproduce` re-binds params by name, so instance names are load-bearing there).
pub fn blueprint_identity_json(c: &Composite) -> Result<String, SerializeError> {
let mut doc = build_doc(c);
let mut doc = build_doc(c)?;
strip_debug_symbols(&mut doc.blueprint);
serialize_doc(&doc)
}
@@ -1017,4 +1050,48 @@ mod tests {
"an arg-bearing type with no args refuses as MissingArg, got {err:?}"
);
}
/// #341 (refuse at the serialize seam, not just at load): a Rust-built
/// graph holding a still-`Session::builder()`-pending node (no
/// `try_args` applied) refuses to serialize outright — the twin of
/// `loading_arg_bearing_type_without_args_refuses` at the OTHER end of
/// the wire, the fault named at its origin instead of surfacing only
/// once the emitted document is reloaded. Only the Rust `GraphBuilder`
/// path can build this state; `add_node`'s data path always runs
/// `try_args` first (fenced separately).
#[test]
fn project_node_refuses_a_pending_arg_bearing_builder() {
let mut gb = crate::GraphBuilder::new("has_pending");
gb.add(Session::builder());
let composite = gb.build().expect("an unwired single-node graph builds");
let err = blueprint_to_json(&composite).expect_err("a pending builder must not serialize");
assert!(
err.to_string().contains("try_args"),
"the refusal prose hints at the fix: {err}"
);
match err {
SerializeError::PendingBuilder { node_type, missing_args } => {
assert_eq!(node_type, "Session", "names the pending node's type: {missing_args:?}");
assert_eq!(
missing_args,
vec!["tz".to_string(), "open".to_string()],
"names every declared arg as missing (none configured yet)"
);
}
SerializeError::Json(e) => panic!("wrong error variant: {e:?}"),
}
}
/// The fenced happy path (#341, negative twin): a `try_args`-configured
/// `Session` (the SAME recipe, just no longer pending) serializes fine —
/// `session_arg_fixture`'s existing round-trip coverage
/// (`args_round_trip_preserves_pairs_and_version_2`) already proves
/// this; re-asserted here beside the refusal for the reader who lands on
/// one and wants the other in view.
#[test]
fn project_node_serializes_a_configured_arg_bearing_builder() {
let c = session_arg_fixture(chrono_tz::Europe::Berlin);
assert!(blueprint_to_json(&c).is_ok(), "a configured builder serializes");
}
}