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:
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user