From d2b0cdf64c31161f7d1ebdde40087d6a5aa39c7e Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 26 Jul 2026 12:26:27 +0200 Subject: [PATCH] feat(aura-cli, aura-engine): zero-trade cell + synthetic-smoke notes; pending-builder serialize fence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/aura-cli/src/campaign_run.rs | 15 ++++ crates/aura-cli/src/diag.rs | 62 ++++++++++++- crates/aura-cli/src/main.rs | 12 +++ crates/aura-cli/tests/exec.rs | 105 ++++++++++++++++++++++ crates/aura-engine/src/blueprint_serde.rs | 103 ++++++++++++++++++--- 5 files changed, 283 insertions(+), 14 deletions(-) diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index 4fb9dfa..1d79160 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -380,6 +380,21 @@ fn present_campaign(run: CampaignRun, env: &Env) -> Result { 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 = 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 { diff --git a/crates/aura-cli/src/diag.rs b/crates/aura-cli/src/diag.rs index 67e1538..e6c63d4 100644 --- a/crates/aura-cli/src/diag.rs +++ b/crates/aura-cli/src/diag.rs @@ -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) { + 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 `'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 `) 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" + ); + } } diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 9969969..844b083 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -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() { diff --git a/crates/aura-cli/tests/exec.rs b/crates/aura-cli/tests/exec.rs index d6347d9..1a30820 100644 --- a/crates/aura-cli/tests/exec.rs +++ b/crates/aura-cli/tests/exec.rs @@ -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 diff --git a/crates/aura-engine/src/blueprint_serde.rs b/crates/aura-engine/src/blueprint_serde.rs index 3e5d3f8..f81ce2f 100644 --- a/crates/aura-engine/src/blueprint_serde.rs +++ b/crates/aura-engine/src/blueprint_serde.rs @@ -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 }, } -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 { // 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::, _>>()?, 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 { 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 { + 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 { @@ -194,7 +227,7 @@ fn serialize_doc(doc: &BlueprintDoc) -> Result { /// 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 { - 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 { /// 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 { - 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"); + } }