From a74c3d116ed53dd4118642f8f232de18d534744c Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 9 Jul 2026 17:51:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(research,cli):=20make=20the=20risk=20axis?= =?UTF-8?q?=20discoverable=20=E2=80=94=20probe,=20roster,=20validate=20cou?= =?UTF-8?q?nts=20(#216=20slice=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structural risk-regime axis worked end to end but was invisible on the public surface (refs #216): the --unwired probe swept only required slots (risk is #[serde(default)]), no vocabulary block described RiskRegime, and the validate summary counted axes/windows but no regimes or cells. - open_slots_campaign gains its one optional-slot probe: a risk-less (or empty/non-array) document lists 'open slot: risk (optional, list of stop regimes { vol: { length, k } }; absent = one default regime)'; a bound list closes the slot. Never a required flip — content-id parity of risk-less documents is load-bearing. - CAMPAIGN_SECTIONS gains std::risk (SlotKind::Regimes, required: false) so --vocabulary lists it and --block std::risk describes the regime shape. - The intrinsic validate summary now reports the realized matrix, mirroring the exec cross-product exactly (strategies x instruments x windows x regimes; empty risk = one default cell, marked '(default)'). Points stay a grid property, deliberately not a cell factor. Sanctioned migrations (recorded on #216): the two unit pins encoding the old invisibility — open_slots_report_partial_documents_by_path now pins exactly one open slot on the complete risk-less fixture (doubling as the probe's unit pin; fixture bytes unchanged), and the presentation-hint test migrates from full-vector equality to locating its entry (its purpose is the hint text, not the vector length). Verified: cargo test -p aura-research (29/0), -p aura-cli --test research_docs (60/0), clippy -D warnings clean. --- crates/aura-cli/src/research_docs.rs | 12 +++- crates/aura-cli/tests/research_docs.rs | 85 +++++++++++++++++++++++++- crates/aura-research/src/lib.rs | 68 ++++++++++++++++----- 3 files changed, 148 insertions(+), 17 deletions(-) diff --git a/crates/aura-cli/src/research_docs.rs b/crates/aura-cli/src/research_docs.rs index e724a36..14637cc 100644 --- a/crates/aura-cli/src/research_docs.rs +++ b/crates/aura-cli/src/research_docs.rs @@ -430,12 +430,20 @@ fn validate_campaign_file(file: &PathBuf, env: &Env) -> Result<(), String> { .iter() .map(|s| s.axes.values().map(|a| a.values.len()).product::()) .sum(); + let regimes = doc.risk.len().max(1); + let regime_marker = if doc.risk.is_empty() { " (default)" } else { "" }; + let cells = + doc.strategies.len() * doc.data.instruments.len() * doc.data.windows.len() * regimes; println!( - "campaign document valid (intrinsic): {} strategy(ies), {} axes ({} points), {} window(s)", + "campaign document valid (intrinsic): {} strategy(ies), {} axes ({} points), {} instrument(s), {} window(s), {} regime(s){} — {} cell(s)", doc.strategies.len(), axes, points, - doc.data.windows.len() + doc.data.instruments.len(), + doc.data.windows.len(), + regimes, + regime_marker, + cells ); if env.provenance().is_some() { let resolve = |t: &str| env.resolve(t); diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index fae0e18..49e8a34 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -547,15 +547,98 @@ fn campaign_validate_refuses_bad_risk_regime_prose_exit_1() { assert!(!out.contains("BadRegime"), "Debug leak: {out}"); } +/// #216: the intrinsic validate summary reports the realized matrix — the +/// exec cross-product strategies × instruments × windows × regimes — so an +/// 8-cell document no longer reads as regime-less. Points (the axis grid) are +/// deliberately NOT a cell factor: 2 points here, yet 8 cells. +#[test] +fn campaign_validate_counts_regimes_and_cells() { + let dir = temp_cwd("campaign-validate-cells"); + let doc = r#"{ + "format_version": 1, + "kind": "campaign", + "name": "screen", + "data": { "instruments": ["GER40", "FRA40"], + "windows": [ { "from_ms": 1, "to_ms": 2 }, { "from_ms": 2, "to_ms": 3 } ] }, + "risk": [ { "vol": { "length": 3, "k": 1.5 } }, { "vol": { "length": 3, "k": 3.0 } } ], + "strategies": [ { "ref": { "content_id": "9f3a" }, + "axes": { "fast": { "kind": "I64", "values": [8, 16] } } } ], + "process": { "ref": { "content_id": "4e2d" } }, + "seed": 1, + "presentation": { "persist_taps": [], "emit": ["family_table"] } +}"#; + write_doc(&dir, "cells.campaign.json", doc); + let (out, code) = run_code_in(&dir, &["campaign", "validate", "cells.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + assert!( + out.contains("campaign document valid (intrinsic): 1 strategy(ies), 1 axes (2 points), 2 instrument(s), 2 window(s), 2 regime(s) — 8 cell(s)"), + "stdout/stderr: {out}" + ); +} + +/// #216: a risk-less document counts its implicit default regime honestly — +/// one regime, marked "(default)" — instead of reading as regime-less. +#[test] +fn campaign_validate_counts_the_default_regime() { + let dir = temp_cwd("campaign-validate-default-regime"); + write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC); + let (out, code) = run_code_in(&dir, &["campaign", "validate", "c.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + assert!( + out.contains("campaign document valid (intrinsic): 1 strategy(ies), 1 axes (1 points), 1 instrument(s), 1 window(s), 1 regime(s) (default) — 1 cell(s)"), + "stdout/stderr: {out}" + ); +} + #[test] fn campaign_introspect_vocabulary_lists_sections() { let (out, code) = run_code(&["campaign", "introspect", "--vocabulary"]); assert_eq!(code, Some(0)); - for id in ["std::data", "std::strategy", "std::process_ref", "std::presentation"] { + // #216: std::risk joins the roster the CLI's own --vocabulary listing + // enumerates — the regime axis is discoverable from the public surface, + // not only from the design ledger or a generated document. + for id in ["std::data", "std::strategy", "std::process_ref", "std::presentation", "std::risk"] { assert!(out.contains(id), "vocabulary misses {id}: {out}"); } } +/// #216: `--block std::risk` (the CLI's own introspection, not the +/// `aura-research` library directly) names the regime shape and its +/// absent-means-default semantics — the second half of "the risk-regime axis +/// is undiscoverable from the ... introspection surface" (mirrors the +/// existing `--block std::presentation` / tap-vocabulary pin). +#[test] +fn campaign_introspect_block_risk_describes_the_regime_shape() { + let (out, code) = run_code(&["campaign", "introspect", "--block", "std::risk"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + assert!( + out.contains("list of stop regimes { vol: { length, k } }; absent = one default regime"), + "the block description must name the regime shape: {out}" + ); + assert!(out.contains("risk"), "stdout/stderr: {out}"); +} + +/// #216: a complete-but-risk-less campaign document (the exact shape a +/// verb-generated document has before a `--stop-*` flag is added) has its one +/// remaining open slot — the optional risk axis — enumerated by the public +/// `campaign introspect --unwired` CLI surface. Before this iteration the +/// slot existed only in the `aura-research` library's own unit tests; a +/// consumer with just the built binary had no way to discover it (#216's +/// exact complaint: "never lists the risk slot"). +#[test] +fn campaign_introspect_unwired_lists_the_risk_slot_when_absent() { + let dir = temp_cwd("campaign-introspect-unwired-risk"); + write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC); + let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "c.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + assert!( + out.contains( + "open slot: risk (optional, list of stop regimes { vol: { length, k } }; absent = one default regime)" + ), + "stdout/stderr: {out}" + ); +} + #[test] fn campaign_introspect_unwired_reports_the_spec_example_slots() { let dir = temp_cwd("campaign-introspect-unwired"); diff --git a/crates/aura-research/src/lib.rs b/crates/aura-research/src/lib.rs index ff0f6de..0570eaa 100644 --- a/crates/aura-research/src/lib.rs +++ b/crates/aura-research/src/lib.rs @@ -158,6 +158,7 @@ pub enum SlotKind { EmitKinds, /// The closed tap vocabulary (`tap_vocabulary()` entries). TapKinds, + Regimes, } /// One typed slot of a block. @@ -819,6 +820,11 @@ pub const CAMPAIGN_SECTIONS: &[BlockSchema] = &[ SlotInfo { name: "windows", kind: SlotKind::Windows, required: true }, ], }, + BlockSchema { + id: "std::risk", + doc: "campaign section: the structural risk axis — stop regimes the matrix runs each cell under (absent = one default regime)", + slots: &[SlotInfo { name: "risk", kind: SlotKind::Regimes, required: false }], + }, BlockSchema { id: "std::strategy", doc: "campaign section: strategy ref (content or identity id) + param axes", @@ -859,6 +865,9 @@ pub fn slot_kind_label(kind: SlotKind) -> &'static str { SlotKind::Axes => "map: param name -> { kind, values }", SlotKind::EmitKinds => "list of: family_table | selection_report", SlotKind::TapKinds => "list of: equity | exposure | r_equity | net_r_equity", + SlotKind::Regimes => { + "list of stop regimes { vol: { length, k } }; absent = one default regime" + } } } @@ -968,6 +977,16 @@ pub fn open_slots_campaign(text: &str) -> Result, DocError> { } } } + // The one optional slot the probe lists: the structural risk axis is + // invisible to a required-slot sweep (#[serde(default)]), which is exactly + // how it went undiscovered (#216). Absent, empty, or non-array = open. + let risk_bound = v.get("risk").and_then(|r| r.as_array()).is_some_and(|a| !a.is_empty()); + if !risk_bound { + slots.push(open( + "risk", + "optional, list of stop regimes { vol: { length, k } }; absent = one default regime", + )); + } match v.get("strategies").and_then(|s| s.as_array()) { None => slots.push(open("strategies", "required, non-empty list of { ref, axes }")), Some(list) if list.is_empty() => { @@ -1595,22 +1614,20 @@ mod tests { "process": { "ref": { "content_id": "4e2d" } }, "seed": 1 }"#; let slots = open_slots_campaign(draft).unwrap(); + let pres = slots + .iter() + .find(|s| s.path == "presentation") + .expect("presentation slot must be open"); assert_eq!( - slots, - vec![open( - "presentation", - format!( - "required section: persist_taps ({}) + emit", - tap_vocabulary().join(" | ") - ), - )] + pres.hint, + format!( + "required section: persist_taps ({}) + emit", + tap_vocabulary().join(" | ") + ) ); assert_eq!( - slots, - vec![open( - "presentation", - "required section: persist_taps (equity | exposure | r_equity | net_r_equity) + emit", - )] + pres.hint, + "required section: persist_taps (equity | exposure | r_equity | net_r_equity) + emit" ); } @@ -1681,7 +1698,15 @@ mod tests { #[test] fn open_slots_report_partial_documents_by_path() { assert_eq!(open_slots_process(PROCESS_FIXTURE).unwrap(), Vec::new()); - assert_eq!(open_slots_campaign(CAMPAIGN_FIXTURE).unwrap(), Vec::new()); + // #216: a complete risk-less campaign has exactly ONE open slot — the + // optional risk axis (the probe's one optional line). + assert_eq!( + open_slots_campaign(CAMPAIGN_FIXTURE).unwrap(), + vec![open( + "risk", + "optional, list of stop regimes { vol: { length, k } }; absent = one default regime", + )] + ); let partial = r#"{ "format_version": 1, "kind": "process", "pipeline": [ { "block": "std::sweep", "metric": "sqn" } ] }"#; @@ -1739,6 +1764,21 @@ mod tests { ); } + /// #216: the optional risk slot is listed only while unbound — a document + /// with a non-empty risk list closes it (a complete bound document has no + /// open slots at all; the risk-less complement is pinned in + /// `open_slots_report_partial_documents_by_path`). + #[test] + fn open_slots_omit_a_bound_risk_slot() { + let with_risk = CAMPAIGN_FIXTURE.replacen( + "\"seed\": 1,", + "\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],", + 1, + ); + assert_ne!(with_risk, CAMPAIGN_FIXTURE, "replacen must match the fixture's seed field"); + assert_eq!(open_slots_campaign(&with_risk).unwrap(), Vec::new()); + } + #[test] fn sweep_selection_group_is_all_or_nothing() { let free = serde_json::json!({ "block": "std::sweep" });