From 1102d776df77d479202bf3458c20dfdc0337e35f Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 23 Jul 2026 23:35:23 +0200 Subject: [PATCH] feat(runner, cli): runner-side marker literals + the zero-trade walk-forward notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iteration stderr-markers-2, tasks 3-4 of the stderr-class-markers plan. aura-runner's four continuing-run diagnostics join the class grammar as hand-written literals (the #295 boundary keeps the macro owner in the presentation crate): the stale-dylib warning gains the `aura: ` namespace, and the three tap/no-nominee notices become `aura: note: ` — the latter two extracted into pure note-builder fns with unit pins. The two e2e pins on the cost-model tap string moved with the retag (research_docs.rs). A new project_nodes e2e exercises the real stale- dylib load path: fresh build emits nothing, a source newer than the dylib emits exactly one class-marked warning and still succeeds. The #313 notice ships on both walk-forward paths: `aura: note: all N walk-forward windows recorded zero trades` before the summary JSON, exit 0 untouched (a null result is a valid research result, #198). Wording and condition live in one shared diag helper taking the per-window trade counts (orchestrator tidy of the reviewer's duplicated-predicate residue); the call sites keep only the path-specific field extraction. Three e2es pin it: the equal-length degenerate on the synthetic path (exactly one note, no warnings, exit 0, summary unchanged), a traded negative twin, and the real/sugar-path degenerate gated on the GER40 archive. refs #278, refs #313 --- crates/aura-cli/src/diag.rs | 12 ++++ crates/aura-cli/src/main.rs | 6 ++ crates/aura-cli/src/verb_sugar.rs | 3 + crates/aura-cli/tests/cli_run.rs | 95 ++++++++++++++++++++++++++ crates/aura-cli/tests/project_nodes.rs | 52 ++++++++++++++ crates/aura-cli/tests/research_docs.rs | 4 +- crates/aura-runner/src/project.rs | 3 +- crates/aura-runner/src/runner.rs | 53 ++++++++++++-- 8 files changed, 221 insertions(+), 7 deletions(-) diff --git a/crates/aura-cli/src/diag.rs b/crates/aura-cli/src/diag.rs index 3f9a36e..d3cedb6 100644 --- a/crates/aura-cli/src/diag.rs +++ b/crates/aura-cli/src/diag.rs @@ -18,3 +18,15 @@ macro_rules! warning { } pub(crate) use {note, warning}; + +/// The #313 zero-trade note, shared by both walk-forward paths (the +/// synthetic-blueprint path in `main.rs` and the `--real` sugar path in +/// `verb_sugar.rs`) so the wording AND the condition live in exactly one +/// place. Takes the per-window trade counts; a no-op unless there is at +/// least one window and every one of them traded zero times. +pub(crate) fn note_zero_trade_windows(mut window_trades: impl ExactSizeIterator) { + let n_windows = window_trades.len(); + if n_windows > 0 && window_trades.all(|n| n == 0) { + note!("all {n_windows} walk-forward windows recorded zero trades"); + } +} diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 57010f4..eab7587 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -917,6 +917,12 @@ fn run_blueprint_walkforward( for w in &result.windows { println!("{}", family_member_line(&id, &w.run.oos_report)); } + crate::diag::note_zero_trade_windows( + result + .windows + .iter() + .map(|w| w.run.oos_report.metrics.r.as_ref().map_or(0, |m| m.n_trades)), + ); println!("{}", walkforward_summary_json(&result)); } diff --git a/crates/aura-cli/src/verb_sugar.rs b/crates/aura-cli/src/verb_sugar.rs index a270f58..c00e759 100644 --- a/crates/aura-cli/src/verb_sugar.rs +++ b/crates/aura-cli/src/verb_sugar.rs @@ -549,6 +549,9 @@ pub(crate) fn run_walkforward_sugar( summary_axes.push("stop_length".to_string()); summary_axes.push("stop_k".to_string()); } + crate::diag::note_zero_trade_windows( + wf.reports.iter().map(|r| r.metrics.r.as_ref().map_or(0, |m| m.n_trades)), + ); println!("{}", crate::walkforward_summary_json_from_reports(&wf.reports, &summary_axes)); // Trace persistence runs LAST (mirroring `present_campaign`'s ordering, diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 97c93fd..f90d925 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -4782,6 +4782,101 @@ fn aura_walkforward_over_a_blueprint_persists_a_family() { let _ = std::fs::remove_dir_all(&cwd); } +/// E2E (#313, Path 2 — synthetic): a walk-forward whose every window records +/// zero trades emits exactly one `aura: note: all N walk-forward windows +/// recorded zero trades` on stderr; exit stays 0 and the summary JSON is +/// unchanged (a null result is a valid research result, #198). Equal +/// fast/slow lengths make the SMA difference constantly zero, so the bias +/// never leaves 0 and no window trades. +#[test] +fn aura_walkforward_all_zero_trade_windows_emit_one_note() { + let cwd = temp_cwd("blueprint-walkforward-zero-trade-note"); + let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["walkforward", &bp, "--axis", "sma_signal.fast.length=8", + "--axis", "sma_signal.slow.length=8", "--name", "wfzero"]) + .current_dir(&cwd) + .output() + .expect("spawn aura walkforward (degenerate)"); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + assert_eq!(out.status.code(), Some(0), "exit stays 0: {stderr}"); + assert!(stdout.contains("\"walkforward\""), "summary line unchanged: {stdout}"); + assert_eq!( + stderr.matches("walk-forward windows recorded zero trades").count(), + 1, + "exactly one zero-trade note: {stderr}" + ); + assert!( + stderr.contains("aura: note: all "), + "the note carries the class marker: {stderr}" + ); + assert!( + !stderr.contains("aura: warning: "), + "a benign degenerate run emits no warning lines: {stderr}" + ); + let _ = std::fs::remove_dir_all(&cwd); +} + +/// E2E (#313 negative twin): a walk-forward with at least one traded window +/// emits no zero-trade note. +#[test] +fn aura_walkforward_with_trades_emits_no_zero_trade_note() { + let cwd = temp_cwd("blueprint-walkforward-traded-no-note"); + let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["walkforward", &bp, "--axis", "sma_signal.fast.length=2,3", + "--axis", "sma_signal.slow.length=4,6", "--name", "wftrades"]) + .current_dir(&cwd) + .output() + .expect("spawn aura walkforward (trading)"); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + assert_eq!(out.status.code(), Some(0), "exit 0: {stderr}"); + assert!( + !stderr.contains("recorded zero trades"), + "a traded run emits no zero-trade note: {stderr}" + ); + let _ = std::fs::remove_dir_all(&cwd); +} + +/// E2E (#313, Path 1 — the real/sugar walk-forward): the degenerate +/// equal-length config over the shared GER40 archive emits the same +/// zero-trade note. Gated on the archive; skips cleanly on a data refusal. +#[test] +fn walkforward_real_all_zero_trade_windows_emit_the_note() { + const FROM_MS: &str = "1735689600000"; + const TO_MS: &str = "1767225599000"; + let (cwd, _g) = fresh_project(); + let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "walkforward", &fixture, "--real", "GER40", + "--axis", "sma_signal.fast.length=5", "--axis", "sma_signal.slow.length=5", + "--stop-length", "14", "--stop-k", "2.0", + "--from", FROM_MS, "--to", TO_MS, + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura walkforward (real degenerate)"); + if out.status.code() == Some(1) { + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("no local data") || stderr.contains("no recorded geometry"), + "exit 1 must be a data refusal, got: {stderr}" + ); + eprintln!("skip: no local GER40 data"); + return; + } + assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + assert_eq!( + stderr.matches("walk-forward windows recorded zero trades").count(), + 1, + "exactly one zero-trade note on the real path: {stderr}" + ); + assert!(stderr.contains("aura: note: all "), "class marker: {stderr}"); +} + /// E2E (#173): a CLOSED blueprint with no --axis (nothing to re-fit) is a usage /// error (exit 2), naming that >= 1 --axis is required. #[test] diff --git a/crates/aura-cli/tests/project_nodes.rs b/crates/aura-cli/tests/project_nodes.rs index f53326f..b994223 100644 --- a/crates/aura-cli/tests/project_nodes.rs +++ b/crates/aura-cli/tests/project_nodes.rs @@ -212,6 +212,58 @@ fn attached_crate_namespace_resolves_after_build() { ); } +/// E2E (#278): loading an attached node crate whose `src/` is newer than its +/// built dylib emits exactly one `aura: warning: ` class-marked line naming +/// the stale dylib, and still succeeds (the run proceeds with the existing +/// dylib rather than refusing) — staleness is a continuing-run warning, never +/// a fault. A freshly built dylib (no touched source since) emits none. Real +/// `cargo build` + a real `libloading::Library::new` load, exercising +/// `aura_runner::project::load_crate`'s actual call site rather than the pure +/// `stale_warning` helper alone. +#[test] +fn attached_crate_load_warns_when_source_outruns_the_built_dylib() { + let cwd = temp_cwd("stale-warn"); + assert!(aura(&["new", "lab"], &cwd).status.success()); + let proj = cwd.join("lab"); + let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../.."; + assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success()); + let crate_root = cwd.join("lab-nodes"); + let build = Command::new("cargo").arg("build").current_dir(&crate_root).output().expect("cargo build"); + assert!(build.status.success(), "{}", String::from_utf8_lossy(&build.stderr)); + + // Fresh build, untouched source: no staleness warning. + let fresh = aura(&["graph", "introspect", "--vocabulary"], &proj); + assert!(fresh.status.success(), "stderr: {}", String::from_utf8_lossy(&fresh.stderr)); + assert!( + !String::from_utf8_lossy(&fresh.stderr).contains("may be stale"), + "a freshly built dylib is not stale: {}", + String::from_utf8_lossy(&fresh.stderr) + ); + + // Bump `src/lib.rs`'s mtime a full minute past "now" (comfortably past the + // dylib's just-completed build time) without rebuilding — the exact + // "source outran the dylib, no rebuild yet" shape `stale_warning` guards. + let lib_rs = crate_root.join("src/lib.rs"); + let f = std::fs::File::open(&lib_rs).expect("open lib.rs"); + f.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(60)) + .expect("bump lib.rs mtime past the dylib's build time"); + + let stale = aura(&["graph", "introspect", "--vocabulary"], &proj); + assert!( + stale.status.success(), + "staleness is a warning, not a refusal: {}", + String::from_utf8_lossy(&stale.stderr) + ); + let stderr = String::from_utf8_lossy(&stale.stderr); + assert_eq!( + stderr.matches("may be stale").count(), + 1, + "exactly one staleness warning: {stderr}" + ); + assert!(stderr.contains("aura: warning: "), "carries the warning class marker (#278): {stderr}"); + assert!(stderr.contains("run `cargo build` to refresh"), "names the remedy: {stderr}"); +} + /// Property (#316 self-description): the scaffold's own seed node ships a /// `doc:` line that itself passes the C29 shape gate (`aura_core::doc_gate`) /// — non-empty and not a bare restatement of the type name. This exact text diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index 04c0b94..1188a82 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -3747,7 +3747,7 @@ fn campaign_run_real_e2e_skips_unproducible_tap_gracefully() { assert!( out.contains( - "aura: tap \"net_r_equity\" needs a cost model; add a cost block to the campaign document; skipped" + "aura: note: tap \"net_r_equity\" needs a cost model; add a cost block to the campaign document; skipped" ), "the unproducible tap gets a named, one-time note with the remedy: {out}" ); @@ -3850,7 +3850,7 @@ fn campaign_run_real_e2e_cost_block_nets_members_and_persists_net_r_equity() { assert_eq!(code_gross, Some(0), "cost-less baseline run: {out_gross}"); assert!( out_gross.contains( - "aura: tap \"net_r_equity\" needs a cost model; add a cost block to the campaign document; skipped" + "aura: note: tap \"net_r_equity\" needs a cost model; add a cost block to the campaign document; skipped" ), "the cost-less run skips the net tap with the remedy: {out_gross}" ); diff --git a/crates/aura-runner/src/project.rs b/crates/aura-runner/src/project.rs index 3eee0e5..071e66e 100644 --- a/crates/aura-runner/src/project.rs +++ b/crates/aura-runner/src/project.rs @@ -462,7 +462,7 @@ fn stale_warning( ) -> Option { if source_mtime > dylib_mtime { Some(format!( - "warning: {} may be stale — dylib built {} but a project source \ + "aura: warning: {} may be stale — dylib built {} but a project source \ file was modified {} (run `cargo build` to refresh); proceeding \ with the existing dylib", dylib_path.display(), @@ -916,6 +916,7 @@ mod tests { let source_mtime = UNIX_EPOCH + Duration::from_secs(2_003_702_400); // 2033-06-30 let path = PathBuf::from("/tmp/x.so"); let msg = stale_warning(&path, dylib_mtime, source_mtime).expect("source is newer"); + assert!(msg.starts_with("aura: warning: "), "carries the warning class marker (#278): {msg}"); assert!(msg.contains("2001"), "names the dylib's mtime: {msg}"); assert!(msg.contains("2033"), "names the source's newer mtime: {msg}"); } diff --git a/crates/aura-runner/src/runner.rs b/crates/aura-runner/src/runner.rs index 2908e08..5e3044c 100644 --- a/crates/aura-runner/src/runner.rs +++ b/crates/aura-runner/src/runner.rs @@ -259,6 +259,28 @@ pub fn tap_channel(tap: &str) -> Option { } } +/// The note text for a requested tap outside the closed vocabulary (#278): +/// pure over just the tap name, so the exact marker-bearing text +/// `persist_campaign_traces` writes to stderr is unit-testable without +/// booting a real archive run (the `tap_channel` `None` arm that reaches +/// this is defensive — `validate_campaign` already refuses an unknown tap +/// name before this function ever runs). +fn tap_not_produced_note(tap: &str) -> String { + format!("aura: note: tap \"{tap}\" is not produced by this run; skipped") +} + +/// The note text for a cell with neither a nominee nor a non-empty terminal +/// family (#278): pure over the record's own cell fields, so the exact +/// marker-bearing text `persist_campaign_traces` writes to stderr is +/// unit-testable without booting a real archive run — the `cell_member_fanout` +/// twin above already covers the emptiness logic itself. +fn no_nominee_note(strategy: &str, instrument: &str, window_ms: (i64, i64)) -> String { + format!( + "aura: note: cell {strategy}/{instrument}/[{}, {}]: no nominee; no traces persisted", + window_ms.0, window_ms.1 + ) +} + /// The content-derived on-disk key of one campaign cell under /// `traces//` — the `member_key` discipline (content, never a /// runtime ordinal): strategy content-id prefix + instrument + @@ -368,10 +390,10 @@ pub fn persist_campaign_traces( for tap in taps { match tap_channel(tap) { Some(TapChannel::Net) if campaign.cost.is_empty() => eprintln!( - "aura: tap \"{tap}\" needs a cost model; add a cost block to the campaign document; skipped" + "aura: note: tap \"{tap}\" needs a cost model; add a cost block to the campaign document; skipped" ), Some(ch) => routed.push((tap.as_str(), ch)), - None => eprintln!("aura: tap \"{tap}\" is not produced by this run; skipped"), + None => eprintln!("{}", tap_not_produced_note(tap)), } } @@ -388,8 +410,8 @@ pub fn persist_campaign_traces( let members = cell_member_fanout(cell_out); if members.is_empty() { eprintln!( - "aura: cell {}/{}/[{}, {}]: no nominee; no traces persisted", - cell_rec.strategy, cell_rec.instrument, cell_rec.window_ms.0, cell_rec.window_ms.1 + "{}", + no_nominee_note(&cell_rec.strategy, &cell_rec.instrument, cell_rec.window_ms) ); continue; } @@ -753,4 +775,27 @@ mod tests { assert_eq!(tap_channel("net_r_equity"), Some(TapChannel::Net)); assert_eq!(tap_channel("bogus"), None); } + + #[test] + /// #278: the unrecognized-tap note carries the `note` class marker AND + /// names the offending tap — the exact text `persist_campaign_traces` + /// writes to stderr on `tap_channel`'s defensive `None` arm. + fn tap_not_produced_note_carries_the_note_marker_and_names_the_tap() { + let msg = tap_not_produced_note("bogus"); + assert!(msg.starts_with("aura: note: "), "carries the note class marker: {msg}"); + assert!(msg.contains("\"bogus\""), "names the offending tap: {msg}"); + assert!(msg.contains("is not produced by this run"), "states the reason: {msg}"); + } + + #[test] + /// #278: the no-candidate-cell note carries the `note` class marker AND + /// names the cell (strategy/instrument/window) — the exact text + /// `persist_campaign_traces` writes to stderr when `cell_member_fanout` + /// (unit-tested above) reports no nominee and no non-empty family. + fn no_nominee_note_carries_the_note_marker_and_names_the_cell() { + let msg = no_nominee_note("bb34aa55", "GER40", (1_000, 2_000)); + assert!(msg.starts_with("aura: note: "), "carries the note class marker: {msg}"); + assert!(msg.contains("bb34aa55/GER40/[1000, 2000]"), "names the cell: {msg}"); + assert!(msg.contains("no nominee; no traces persisted"), "states the reason: {msg}"); + } }