diff --git a/crates/aura-cli/src/research_docs.rs b/crates/aura-cli/src/research_docs.rs index df8e5a7..551b7cc 100644 --- a/crates/aura-cli/src/research_docs.rs +++ b/crates/aura-cli/src/research_docs.rs @@ -161,7 +161,7 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String { format!("data.windows[{index}]: from_ms must be earlier than to_ms") } DocFault::BadRegime { index } => { - format!("risk[{index}]: stop length must be >= 1 and k must be > 0") + format!("risk[{index}]: stop length must be >= 1, k must be > 0, and a fixed-stop distance must be > 0") } DocFault::BadCost { index } => { format!("cost[{index}]: the component's price-unit knob must be finite and >= 0") diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index 9472ece..b838d5a 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -662,7 +662,7 @@ fn campaign_validate_refuses_bad_risk_regime_prose_exit_1() { let (out, code) = run_code_in(&dir, &["campaign", "validate", "risk-bad.campaign.json"]); assert_eq!(code, Some(1), "stdout/stderr: {out}"); assert!( - out.contains("risk[0]: stop length must be >= 1 and k must be > 0"), + out.contains("risk[0]: stop length must be >= 1, k must be > 0, and a fixed-stop distance must be > 0"), "stdout/stderr: {out}" ); assert!(!out.contains("BadRegime"), "Debug leak: {out}"); @@ -1192,7 +1192,7 @@ fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() { assert_eq!(code, Some(1), "stdout/stderr: {out}"); assert!(out.contains("refusing to register:"), "stdout/stderr: {out}"); assert!( - out.contains("risk[0]: stop length must be >= 1 and k must be > 0"), + out.contains("risk[0]: stop length must be >= 1, k must be > 0, and a fixed-stop distance must be > 0"), "stdout/stderr: {out}" ); assert!( @@ -3086,6 +3086,68 @@ fn campaign_run_real_e2e_non_default_regime_stamps_its_own_stop() { } } +/// Property (#338, the `campaign_run_real_e2e_non_default_regime_stamps_its_own_stop` +/// precedent above, `Fixed` edition): a campaign document's `RiskRegime::Fixed` +/// reaches the real `CliMemberRunner::run_member` path and IS the stop actually +/// stamped into each emitted member's manifest as `stop_distance` — making the +/// shipped `FixedStop` composite campaign-reachable end to end, not merely +/// intrinsically valid. +#[test] +fn campaign_run_real_e2e_fixed_regime_stamps_its_own_distance() { + let (dir, _fixture) = fresh_project(); + 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("fixed_regime.process.json")), + ScratchPath::File(dir.join("fixed_regime.campaign.json")), + ]); + let bp_id = seed_blueprint(&dir, "campaign-run-fixed-regime-seed"); + let proc_id = register_process_doc(&dir, "fixed_regime.process.json", SWEEP_ONLY_PROCESS_DOC); + let base = campaign_doc_json( + &bp_id, + &proc_id, + (1725148800000, 1727740799999), + "", + "\"family_table\"", + ); + let with_regime = base.replacen( + "\"seed\": 7,", + "\"seed\": 7,\n \"risk\": [ { \"fixed\": { \"distance\": 12.0 } } ],", + 1, + ); + assert_ne!(with_regime, base, "replacen must actually match the fixture's seed field"); + write_doc(&dir, "fixed_regime.campaign.json", &with_regime); + let (out, code) = run_code_in(&dir, &["exec", "fixed_regime.campaign.json"]); + + if code == Some(3) + && (out.contains("no recorded geometry") || out.contains("no data for instrument")) + { + eprintln!("skip: no local GER40 data for the fixed-regime campaign e2e"); + return; + } + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + let member_lines: Vec<&str> = + out.lines().filter(|l| l.starts_with("{\"family_id\":")).collect(); + assert!(!member_lines.is_empty(), "family_table member lines emitted: {out}"); + for line in &member_lines { + let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON"); + let params = v["report"]["manifest"]["params"] + .as_array() + .expect("manifest.params is an array"); + let get = |name: &str| params.iter().find(|p| p[0].as_str() == Some(name)); + assert_eq!( + get("stop_distance").and_then(|p| p[1]["F64"].as_f64()), + Some(12.0), + "the campaign's own fixed distance is stamped, not the R_SMA vol-stop default: {line}" + ); + assert!( + get("stop_length").is_none() && get("stop_k").is_none(), + "a Fixed stop stamps no vol knobs: {line}" + ); + } +} + /// Property (#210 T3/T4 — per-cell regime resolution, not a shared last-write): /// a campaign with TWO distinct non-default risk regimes stamps EACH regime's /// OWN stop into ITS OWN cell's members, never the other regime's values and diff --git a/crates/aura-research/src/lib.rs b/crates/aura-research/src/lib.rs index 37e232e..408795b 100644 --- a/crates/aura-research/src/lib.rs +++ b/crates/aura-research/src/lib.rs @@ -513,16 +513,17 @@ pub struct Axis { } /// A protective-stop regime: a serializable, content-addressable mirror of the -/// runtime `StopRule` structural axis (C10/C20). The sole implemented variant is -/// the vol-stop; the shipped fixed-stop rule is admitted as a future additive -/// variant (it runs as a composite today but is not yet campaign-reachable). -/// Externally tagged so adding `Fixed` is additive — no content-id churn on -/// stored `Vol` regimes. +/// runtime `StopRule` structural axis (C10/C20). `Fixed{distance}` (#338) makes +/// the shipped `FixedStop` composite campaign-reachable, beside the vol-stop +/// family — `distance` is the same price-unit knob `FixedStop`'s own `distance` +/// param carries. Externally tagged so each variant was additive on arrival — +/// no content-id churn on stored `Vol`/`VolTf` regimes. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "snake_case")] pub enum RiskRegime { Vol { length: i64, k: f64 }, VolTf { period_minutes: i64, length: i64, k: f64 }, + Fixed { distance: f64 }, } /// One component of the campaign's cost model (#234): a closed, additive @@ -973,6 +974,14 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec { faults.push(DocFault::BadRegime { index: i }); } } + RiskRegime::Fixed { distance } => { + // Mirrors `FixedStop::new`'s own `distance > 0.0` assert + // (`aura-strategy`) — refused gracefully here instead of + // reaching that assert and panicking. + if *distance <= 0.0 || distance.is_nan() { + faults.push(DocFault::BadRegime { index: i }); + } + } } } for (i, c) in doc.cost.iter().enumerate() { @@ -1666,6 +1675,18 @@ mod tests { assert_eq!(back, r); } + /// #338: the third variant's wire form follows the same externally-tagged + /// convention as `vol`/`vol_tf` — `fixed` snake_case tag, one named field + /// (the `FixedStop` composite's own `distance` param). + #[test] + fn risk_regime_round_trips_as_externally_tagged_fixed() { + let r = RiskRegime::Fixed { distance: 10.0 }; + let j = serde_json::to_string(&r).unwrap(); + assert_eq!(j, r#"{"fixed":{"distance":10.0}}"#); + let back: RiskRegime = serde_json::from_str(&j).unwrap(); + assert_eq!(back, r); + } + #[test] fn campaign_absent_and_empty_risk_omit_from_canonical_bytes() { let doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); @@ -1724,6 +1745,23 @@ mod tests { assert!(!faults.contains(&DocFault::BadRegime { index: 0 }), "valid regime not flagged: {faults:?}"); } + /// #338: a non-positive fixed-stop distance must be refused as a graceful + /// `DocFault::BadRegime` at the doc tier — otherwise it reaches + /// `FixedStop::new`'s `distance > 0.0` assert and panics instead of a + /// validation error (the `validate_campaign_flags_non_positive_period_minutes_regime` + /// precedent above, `Fixed` edition). + #[test] + fn validate_campaign_flags_non_positive_fixed_distance_regime() { + let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); + doc.risk = vec![ + RiskRegime::Fixed { distance: 10.0 }, // 0: valid + RiskRegime::Fixed { distance: 0.0 }, // 1: distance <= 0 + ]; + let faults = validate_campaign(&doc); + assert!(faults.contains(&DocFault::BadRegime { index: 1 }), "{faults:?}"); + assert!(!faults.contains(&DocFault::BadRegime { index: 0 }), "valid regime not flagged: {faults:?}"); + } + #[test] fn validate_campaign_accepts_a_valid_risk_section() { let mut doc: CampaignDoc = serde_json::from_str(CAMPAIGN_FIXTURE).unwrap(); diff --git a/crates/aura-runner/src/member.rs b/crates/aura-runner/src/member.rs index 8b4ab83..b09e4d0 100644 --- a/crates/aura-runner/src/member.rs +++ b/crates/aura-runner/src/member.rs @@ -649,9 +649,10 @@ pub fn run_blueprint_member( h.run_bound(key_supply(binding, sources)) .expect("sources opened against `binding` key-match that binding's own roles by construction"); let mut named = zip_params(space, point); // by-name params for the manifest record - // `match` (not an irrefutable `let`): `StopRule` also has a `Fixed` variant, - // which stamps no vol knobs. The campaign/single-run paths only pass `Vol` - // or `VolTf`, so the `Fixed` arm is inert here. + // One arm's knobs per member (#233 Vol/VolTf, #338 Fixed) — the manifest + // stamp `stop_rule_from_params` reads back (`translate.rs`), so a + // campaign's `RiskRegime::Fixed` cell reproduces its own distance, not + // the baked default vol-stop. match stop { StopRule::Vol { length, k } => { named.push(("stop_length".to_string(), Scalar::i64(length))); @@ -662,7 +663,9 @@ pub fn run_blueprint_member( named.push(("stop_length".to_string(), Scalar::i64(length))); named.push(("stop_k".to_string(), Scalar::f64(k))); } - StopRule::Fixed(_) => {} + StopRule::Fixed(distance) => { + named.push(("stop_distance".to_string(), Scalar::f64(distance))); + } } // Stamp the cost model the member ran under, beside the stop knobs (#234, // the #233 pattern): one `cost[k].` param per component, in @@ -1694,6 +1697,49 @@ mod tests { ); } + /// #338 write-side: `run_blueprint_member` given `StopRule::Fixed` actually + /// bootstraps and runs the member (the `risk_executor`/`FixedStop` arm, + /// end-to-end over a real synthetic realization) and stamps `stop_distance` + /// into the manifest under the key `stop_rule_from_params`'s round-trip + /// reads back — the `run_blueprint_member_stamps_the_vol_tf_stop_knobs` + /// precedent above, `Fixed` edition. + #[test] + fn run_blueprint_member_stamps_the_fixed_stop_distance() { + let env = Env::std(); + let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes"); + let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads"); + let space = blueprint_axis_probe(&doc, &env).param_space(); + let binding = crate::binding::resolve_binding("fixedstamp", reload().input_roles(), &BTreeMap::new()) + .expect("the price role resolves"); + let stop = StopRule::Fixed(10.0); + let (sources, window, pip) = resolve_run_data(&RunData::Synthetic, &env, &binding); + let run = run_blueprint_member( + reload(), + &[], + &space, + sources, + window, + 0, + pip, + "topo", + &env, + stop, + &binding, + &[], + "GER40", + ); + assert!( + run.manifest.params.contains(&("stop_distance".to_string(), Scalar::f64(10.0))), + "manifest must stamp stop_distance: {:?}", + run.manifest.params + ); + assert!( + !run.manifest.params.iter().any(|(k, _)| k == "stop_length" || k == "stop_k"), + "a Fixed stop stamps no vol knobs: {:?}", + run.manifest.params + ); + } + /// A fresh project root so a run's `runs/` lands in a temp dir. fn temp_project_env(name: &str) -> (Env, PathBuf) { let root = std::env::temp_dir() diff --git a/crates/aura-runner/src/translate.rs b/crates/aura-runner/src/translate.rs index 578c12c..dd937c7 100644 --- a/crates/aura-runner/src/translate.rs +++ b/crates/aura-runner/src/translate.rs @@ -4,10 +4,11 @@ //! the cost model) must still round-trip through a manifest, so a stored //! member (family, reproduce) re-derives bit-identically. These translators //! are the single source for each such write<->read pair — the manifest -//! `stop_length`/`stop_k`/`stop_period_minutes` <-> [`StopRule`] binding, and -//! the `cost[k].` <-> [`aura_research::CostSpec`] binding — so the two -//! halves of each pair cannot drift out of sync between the run/campaign -//! paths and reproduce/persist. +//! `stop_length`/`stop_k`/`stop_period_minutes`/`stop_distance` <-> +//! [`StopRule`] binding, and the `cost[k].` <-> +//! [`aura_research::CostSpec`] binding — so the two halves of each pair +//! cannot drift out of sync between the run/campaign paths and +//! reproduce/persist. use aura_composites::StopRule; use aura_core::{PrimitiveBuilder, Scalar}; @@ -23,11 +24,14 @@ pub const R_SMA_STOP_LENGTH: i64 = 3; pub const R_SMA_STOP_K: f64 = 2.0; /// Re-derive the `StopRule` a member was minted under from its manifest params -/// (`stop_length`/`stop_k`/`stop_period_minutes`, stamped by `run_blueprint_member`): -/// if `stop_period_minutes` is present alongside `stop_length`/`stop_k`, this -/// re-derives `VolTf`; otherwise falls back to `Vol`, and to the default -/// vol-stop regime when the manifest carries no stop knobs at all (pre-#233 -/// members), mirroring [`stop_rule_for_regime`]'s `None` arm for the same +/// (`stop_length`/`stop_k`/`stop_period_minutes`/`stop_distance`, stamped by +/// `run_blueprint_member`): if `stop_period_minutes` is present alongside +/// `stop_length`/`stop_k`, this re-derives `VolTf`; `stop_length`/`stop_k` +/// alone re-derives `Vol`; `stop_distance` alone (#338) re-derives `Fixed` — +/// the three stamps are mutually exclusive (`run_blueprint_member` stamps +/// exactly one arm's knobs per member). Falls back to the default vol-stop +/// regime when the manifest carries no stop knobs at all (pre-#233 members), +/// mirroring [`stop_rule_for_regime`]'s `None` arm for the same /// one-directional widening `point_from_params` already applies to missing /// manifest params. pub fn stop_rule_from_params(params: &[(String, Scalar)]) -> StopRule { @@ -35,11 +39,13 @@ pub fn stop_rule_from_params(params: &[(String, Scalar)]) -> StopRule { params.iter().find(|(n, _)| n == "stop_period_minutes").map(|(_, s)| s.as_i64()); let length = params.iter().find(|(n, _)| n == "stop_length").map(|(_, s)| s.as_i64()); let k = params.iter().find(|(n, _)| n == "stop_k").map(|(_, s)| s.as_f64()); - match (period_minutes, length, k) { - (Some(period_minutes), Some(length), Some(k)) => { + let distance = params.iter().find(|(n, _)| n == "stop_distance").map(|(_, s)| s.as_f64()); + match (period_minutes, length, k, distance) { + (Some(period_minutes), Some(length), Some(k), _) => { StopRule::VolTf { period_minutes, length, k } } - (None, Some(length), Some(k)) => StopRule::Vol { length, k }, + (None, Some(length), Some(k), _) => StopRule::Vol { length, k }, + (None, None, None, Some(distance)) => StopRule::Fixed(distance), _ => StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, } } @@ -94,6 +100,7 @@ pub fn cost_specs_from_params( /// default vol-stop, `Some(RiskRegime::Vol { .. })` binds that regime's own /// params. Single-sourced so the persist-side re-run structurally cannot /// diverge from the run-side binding again (the #219 divergence class). +/// `Fixed { distance }` (#338) binds the shipped `FixedStop` composite. pub fn stop_rule_for_regime(regime: Option) -> aura_composites::StopRule { match regime { None => aura_composites::StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, @@ -103,6 +110,7 @@ pub fn stop_rule_for_regime(regime: Option) -> aura_c Some(aura_research::RiskRegime::VolTf { period_minutes, length, k }) => { aura_composites::StopRule::VolTf { period_minutes, length, k } } + Some(aura_research::RiskRegime::Fixed { distance }) => aura_composites::StopRule::Fixed(distance), } } @@ -178,6 +186,15 @@ mod tests { assert_eq!(stop_rule_from_params(&vol), StopRule::Vol { length: 3, k: 2.0 }); } + #[test] + /// #338 round-trip: a manifest carrying ONLY `stop_distance` (no vol + /// knobs) re-derives `StopRule::Fixed` — the `stop_rule_from_params_round_trips_the_vol_tf_stamp` + /// precedent above, `Fixed` edition. + fn stop_rule_from_params_round_trips_the_fixed_distance_stamp() { + let fixed = vec![("stop_distance".to_string(), Scalar::f64(10.0))]; + assert_eq!(stop_rule_from_params(&fixed), StopRule::Fixed(10.0)); + } + #[test] /// #262: `stop_rule_for_regime` binds `RiskRegime::VolTf` to /// `StopRule::VolTf` field-for-field — the resolve-side half of the @@ -193,6 +210,17 @@ mod tests { ); } + #[test] + /// #338: `stop_rule_for_regime` binds `RiskRegime::Fixed` to + /// `StopRule::Fixed` — the resolve-side half of the write/resolve pair the + /// manifest round-trip test above covers from the stamp side (the + /// `stop_rule_for_regime_binds_vol_tf_field_for_field` precedent, `Fixed` + /// edition). + fn stop_rule_for_regime_binds_fixed_distance() { + let regime = Some(aura_research::RiskRegime::Fixed { distance: 10.0 }); + assert_eq!(stop_rule_for_regime(regime), aura_composites::StopRule::Fixed(10.0)); + } + #[test] /// #234: the one CostSpec -> builder binding maps each component to its /// shipped cost node with the knob BOUND — a bound component adds no open diff --git a/docs/design/contracts/c10-bias-r-cost.md b/docs/design/contracts/c10-bias-r-cost.md index 1eabfdd..766a2f3 100644 --- a/docs/design/contracts/c10-bias-r-cost.md +++ b/docs/design/contracts/c10-bias-r-cost.md @@ -284,10 +284,11 @@ with like; costed families reproduce bit-identically (incl. `Carry`). curves; a cost-less doc requesting it keeps a remedy-naming skip notice. There are no `--cost-*` run-path flags (removed #221/#234); cost travels in the document. -**Risk regime as a structural campaign axis.** The `StopRule{Fixed, Vol}` axis is -realized at the campaign-document level as `CampaignDoc.risk: [RiskRegime]` -(`aura-research`, variants `Vol{length, k}` and `VolTf{period_minutes, length, k}` -(#262), the fixed-stop rule additive when needed) — a kept-separate matrix axis, +**Risk regime as a structural campaign axis.** The `StopRule{Fixed, Vol, VolTf}` +axis is realized at the campaign-document level as `CampaignDoc.risk: +[RiskRegime]` (`aura-research`, variants `Vol{length, k}`, +`VolTf{period_minutes, length, k}` (#262), and `Fixed{distance}` (#338, binding +the shipped `FixedStop` composite)) — a kept-separate matrix axis, peer of instruments and windows. The executor keys the nominee map by `(strategy, window, regime)`, so `generalize` aggregates *within* a regime, never across. Regimes are **compared** at presentation, never argmax-**selected** across (a diff --git a/docs/glossary.md b/docs/glossary.md index 1c148ce..6738d3a 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -245,13 +245,15 @@ A node that converts a finer stream to a coarser bar stream, emitting a complete **Avoid:** risk section One entry of a campaign document's structural risk axis (`risk`): a serializable protective-stop regime (variants `vol{length,k}` — per-cycle — -and `vol_tf{period_minutes,length,k}` — per completed time bucket) the matrix -runs every cell under, so cells differ by execution discipline, never by -signal. Absent or empty = one implicit default regime; the regime's stop -defines the risk unit R — in `vol{length,k}` (stop = k·√EMA(Δ², length) over -m1 cycles) `length` only smooths the vol estimator while `k` scales the stop -distance, so the stop's timescale stays one cycle (`vol_tf` sets the stop's -timescale via `period_minutes`). +`vol_tf{period_minutes,length,k}` — per completed time bucket — and +`fixed{distance}` — a constant price-unit distance, binding the shipped +`FixedStop` composite) the matrix runs every cell under, so cells differ by +execution discipline, never by signal. Absent or empty = one implicit default +regime; the regime's stop defines the risk unit R — in `vol{length,k}` (stop = +k·√EMA(Δ², length) over m1 cycles) `length` only smooths the vol estimator +while `k` scales the stop distance, so the stop's timescale stays one cycle +(`vol_tf` sets the stop's timescale via `period_minutes`; `fixed` has no +timescale — the distance never adapts). ### run **Avoid:** —