Files
Aura/docs/specs/risk-regime-axis.md
T
Brummel abab1f1684 docs(specs,plans): risk-regime structural campaign axis — spec + plan (#210)
Spec auto-signed on a grounding-check PASS: the risk regime becomes a
structural campaign axis (a peer of instruments/windows, kept-separate in
the executor, each member's resolved stop stamped into its manifest;
regimes are compared, never argmax-selected across — a cross-regime E[R]
argmax would mix R units, C10). The 5-task plan threads it through
aura-research (RiskRegime + CampaignDoc.risk), aura-campaign + aura-registry
(matrix expansion + regime_ordinal), and aura-cli (StopArm + wrap_r + the
manifest stamp). Two bounded, documented descopes: the descriptive
CAMPAIGN_SECTIONS introspection entry and multi-regime trace-dir isolation.

refs #210
2026-07-06 13:33:25 +02:00

19 KiB
Raw Blame History

Risk regime as a structural campaign axis — Design Spec

Date: 2026-07-06 Status: Draft — awaiting user spec review Authors: orchestrator + Claude

Goal

The campaign document cannot vary the protective stop. The R-path stop is baked to fixed constants (vol-stop length 3, k 2.0) inside the non-serialized wrap_r scaffolding, invisible to every stored artifact; stored strategy blueprints are the signal leg only (price → bias). Only the legacy verbs vary stops, and they do it by gridding stop params jointly with the signal params and argmaxing across the whole grid — comparing R-multiples measured in different R units (C10: the stop defines 1R). This blocks the behaviour-preserving dissolution of generalize, walkforward, and mc's R-path equally, and leaves the executed stop regime unreconstructable from any recorded artifact (the C18 audit gap named in the fork triage).

This cycle makes the risk regime a first-class structural axis of the campaign matrix — a peer of instruments and windows — so that:

  1. a campaign document enumerates a finite list of risk regimes and runs every (strategy, instrument, window, regime) cell;
  2. each member's resolved stop is stamped into its manifest, closing the C18 gap (the stop becomes addressable, auditable data);
  3. regimes are compared at presentation, never argmax-selected across — the cross-R-unit comparison the structural framing deliberately forecloses (the parity break ratified in the direction decision, Gitea #210).

The user decision (regime = structural axis, not an ordinary param axis) and the derived sub-forks are recorded on Gitea #210 (comments 2846 / 2847). This is the design pass that unblocks the remaining verb dissolutions; the "how do I find the best regime" methodology is a consequence of the compared- not-selected semantics and is deliberately not written down (it follows from the architecture).

Architecture

Three tiers, matching the existing campaign stack:

  • Document vocabulary (aura-research) — a new serializable RiskRegime closed vocabulary and an optional top-level CampaignDoc.risk: Vec<RiskRegime> field. This crate stays dependency-pure (aura-core + serde + sha2); it knows nothing of the runtime StopRule.
  • Matrix expansion (aura-campaign) — the realization matrix gains a fourth structural coordinate. The regime is a kept-separate axis (a peer of window), not an aggregated-over axis (a peer of instrument): generalize aggregates across instruments (a candidate must survive all instruments — worst-case R floor), but regimes are alternatives compared to find the most robust one, never a set the candidate must all survive. So each (strategy, window, regime) triple is an independent generalize-across- instruments unit, and the regime enters the nominee key.
  • Runtime binding (aura-cli) — the CLI member runner maps RiskRegime → StopRule at the runtime boundary (exactly as it maps campaign axes → runtime param-spaces), threads the resolved stop into the wrap_r bound-stop arm in place of the baked constants, and stamps the resolved stop into the member manifest.

The default regime (absent or empty risk) is a single implicit regime equal to today's baked constants. The default's value lives in aura-cli (R_SMA_STOP_LENGTH / R_SMA_STOP_K), so aura-campaign carries the regime as Option<RiskRegime> where None means "member runner supplies its default" — the doc is never mutated to inject a default (its content id must hash with risk absent).

Concrete code shapes

The campaign document a user writes (the acceptance evidence)

A campaign that sweeps one signal over one instrument/window across three risk regimes — the new capability:

{
  "format_version": 1,
  "kind": "campaign",
  "name": "sma-cross across three vol stops",
  "data": {
    "instruments": ["GER40"],
    "windows": [{ "from_ms": 1725148800000, "to_ms": 1727740799999 }]
  },
  "risk": [
    { "vol": { "length": 3, "k": 1.5 } },
    { "vol": { "length": 3, "k": 2.0 } },
    { "vol": { "length": 3, "k": 3.0 } }
  ],
  "strategies": [
    { "ref": { "content_id": "<sma-blueprint-hash>" },
      "axes": { "fast.length": { "kind": "i64", "values": [2, 3] } } }
  ],
  "process": { "ref": { "content_id": "<sweep-process-hash>" } },
  "seed": 0,
  "presentation": { "persist_taps": [], "emit": ["family_table"] }
}

Running it produces one family per (strategy, instrument, window, regime) — three families here, each a sweep over fast.length ∈ {2,3} at that regime's stop. Every member line carries its resolved stop in the manifest; the three regimes' results sit side by side in the family table, compared, never collapsed by an argmax. "Best regime" is read off the compared families (the most robust out-of-sample R floor across a plateau of stops) — a comparison, not a search.

The absent-risk parity case — the document the sweep sugar generates and every stored campaign document written to date:

{ "...": "no risk section", "data": { "...": "..." }, "strategies": ["..."] }

runs exactly one regime equal to the baked default (length 3, k 2.0), behaviour-identical to today, and its content id is byte-unchanged (the field absent-serializes).

RiskRegime — the new closed vocabulary (aura-research)

/// 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 fixed-stop rule is admitted as a future additive variant
/// (it ships as a composite today but is not yet campaign-reachable). Tagged so
/// adding `Fixed` is additive — no content-id churn on stored `Vol` regimes.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum RiskRegime {
    Vol { length: i64, k: f64 },
}

Wire form (serde external tagging): { "vol": { "length": 3, "k": 2.0 } }.

CampaignDoc.risk — the optional top-level field

// before → after (crates/aura-research/src/lib.rs:425)
 pub struct CampaignDoc {
     pub format_version: u32,
     pub kind: DocKind,
     pub name: String,
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub description: Option<String>,
     pub data: DataSection,
+    /// Structural risk-execution axis: the finite list of stop regimes the
+    /// matrix runs each cell under. Absent or empty = one implicit default
+    /// regime (the runtime baked constants). Absent-serializes → content-id
+    /// parity for every risk-less document.
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub risk: Vec<RiskRegime>,
     pub strategies: Vec<StrategyEntry>,
     pub process: ProcessRef,
     pub seed: u64,
     pub presentation: Presentation,
 }

DataSection is unchanged — the regime is a risk-execution concern, kept out of the "which instruments over which windows" data section (invariant 7).

CellSpec.regime — the fourth structural coordinate (aura-campaign)

// before → after (crates/aura-campaign/src/lib.rs:26)
 pub struct CellSpec {
     pub strategy_ordinal: usize,
     pub strategy_id: String,
     pub blueprint_json: String,
     pub axes: BTreeMap<String, Axis>,
     pub instrument: String,
     pub window_ms: (i64, i64),
+    /// The cell's risk regime. `None` = the member runner's baked default (the
+    /// absent/empty-`risk` case); `Some` = an explicit document regime.
+    pub regime: Option<RiskRegime>,
+    /// The regime's ordinal in the resolved regime list (0 for the default),
+    /// used to key the generalize unit and discriminate family names.
+    pub regime_ordinal: usize,
 }

Matrix loop + nominee key (aura-campaign/src/exec.rs)

// before → after (exec.rs:124 + the strategy×instrument×window loop at :125)
- let mut nominees: BTreeMap<(usize, usize), Vec<(String, Nominee)>> = BTreeMap::new();
+ let mut nominees: BTreeMap<(usize, usize, usize), Vec<(String, Nominee)>> = BTreeMap::new();
+ // Absent/empty risk resolves to a single default cell (regime = None, ord 0);
+ // a non-empty list maps each regime to Some. The default's value is the member
+ // runner's; the doc is never mutated (content id hashes with risk absent).
+ let regimes: Vec<(usize, Option<RiskRegime>)> = if campaign.risk.is_empty() {
+     vec![(0, None)]
+ } else {
+     campaign.risk.iter().copied().enumerate().map(|(i, r)| (i, Some(r))).collect()
+ };
  for (strategy_ordinal, ...) in campaign.strategies.iter().zip(strategies).enumerate() {
      for instrument in &campaign.data.instruments {
          for (window_ordinal, window) in campaign.data.windows.iter().enumerate() {
+             for (regime_ordinal, regime) in &regimes {
                  let cell = CellSpec { /* ...as before... */,
+                     regime: *regime, regime_ordinal: *regime_ordinal };
                  let (outcome, realization) = run_cell(&cell, ...)?;
                  nominees
-                     .entry((strategy_ordinal, window_ordinal))
+                     .entry((strategy_ordinal, window_ordinal, *regime_ordinal))
                      .or_default()
                      .push((instrument.clone(), outcome.nominee.clone()));
                  /* ... */
+             }
          }
      }
  }

The campaign-scope generalize loop (exec.rs:164-195) iterates the same now-3-tuple key, so each (strategy, window, regime) gets its own generalize-across-instruments grade. CampaignGeneralization gains a regime_ordinal: usize field beside strategy_ordinal / window_ordinal.

Family naming — the regime discriminator (collision fix)

The family name (exec.rs:250 / :352) carries no regime component today, so two regimes for the same (strategy, instrument, window, stage) would collide and overwrite in the registry:

// before → after (exec.rs:250, :352)
- "{campaign_prefix}-{}-{}-w{window_ordinal}-s{stage_ordinal}",
+ "{campaign_prefix}-{}-{}-w{window_ordinal}-r{regime_ordinal}-s{stage_ordinal}",

campaign_cell_key (campaign_run.rs:558) gains the regime ordinal by the same reasoning (report-name isolation across regimes):

// before → after
- fn campaign_cell_key(strategy: &str, instrument: &str, window_ordinal: usize) -> String
+ fn campaign_cell_key(strategy: &str, instrument: &str, window_ordinal: usize, regime_ordinal: usize) -> String

wrap_r bound-stop arm + the member-runner thread (aura-cli)

wrap_r's sixth argument stops being a bare stop_open: bool and becomes a StopArm that carries the bound stop's value:

// new CLI-internal enum
enum StopArm {
    /// Open vol-stop: the two knobs land in param_space as sweep axes (the r-sma
    /// verb's gridded-stop path).
    Open,
    /// Bound vol-stop with these params (single run, blueprint/campaign member).
    Bound(StopRule),
}

// before → after (main.rs:2691 stop arm inside wrap_r)
- let exec = g.add(if stop_open {
-     risk_executor_vol_open(1.0)
- } else {
-     risk_executor(StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, 1.0)
- });
+ let exec = g.add(match stop {
+     StopArm::Open => risk_executor_vol_open(1.0),
+     StopArm::Bound(rule) => risk_executor(rule, 1.0),
+ });

The signature change propagates to wrap_r's five call sites (compiler- enumerated). r_sma_graph (main.rs:2622) keeps its stop_open: bool and maps at its single wrap_r call, localizing the default constants to the Rust-built path:

// r_sma_graph's wrap_r call
+ if stop_open { StopArm::Open }
+ else { StopArm::Bound(StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }) }

run_blueprint_member (main.rs:2877) gains a stop: StopRule parameter, passes StopArm::Bound(stop) to wrap_r, and stamps the stop into the manifest (next shape). CliMemberRunner::run_member (campaign_run.rs:215) maps the cell's regime to the runtime rule and threads it in:

// campaign_run.rs run_member — the RiskRegime → StopRule map at the runtime boundary
+ let stop = match cell.regime {
+     None => StopRule::Vol { length: crate::R_SMA_STOP_LENGTH, k: crate::R_SMA_STOP_K },
+     Some(RiskRegime::Vol { length, k }) => StopRule::Vol { length, k },
+ };
  let mut report = crate::run_blueprint_member(signal, &point, &space, sources,
-     (from, to), 0, geo.pip_size, &cell.strategy_id, self.env);
+     (from, to), 0, geo.pip_size, &cell.strategy_id, self.env, stop);

Manifest stamp — closing the C18 gap (additive, like the instrument stamp)

run_blueprint_member builds its manifest from zip_params(space, point) — the signal axes only (main.rs:2896), so the campaign member manifest records no stop today. The resolved stop is appended, matching the single-run manifest shape (stop_length i64 / stop_k f64, main.rs:3608-3609):

// before → after (main.rs:2896, inside run_blueprint_member)
  let named = zip_params(space, point);
+ let named = { let mut p = named;
+     if let StopRule::Vol { length, k } = stop {
+         p.push(("stop_length".into(), Scalar::i64(length)));
+         p.push(("stop_k".into(), Scalar::f64(k)));
+     }
+     p };
  let mut manifest = sim_optimal_manifest(named, window, seed, pip);

Ratified contract extension. family_member_line serializes the full RunManifest.params (main.rs:1601-1606), so this additive stamp changes the dissolved-sweep member-line output. That a campaign member manifest carries no stop param today is a currently-green characterization pin, sweep_real_blueprint_member_manifest_carries_no_stop_param_today (cli_run.rs) — added as this cycle's grounding ratification, since the older sweep_real_blueprint_member_lines_pin_the_dissolved_contract locates each binding by find and so never pinned the absence. The stamp flips that characterization pin to require the stop key — the deliberate C18-gap closure the milestone exists to perform, an additive stamp exactly like the instrument stamp cycle 0110 added ("full parity modulo an additive stamp", #210 decision 5), not accidental drift. The older find-based pin also gains the default stop in its member lines (its find assertions stay green; it is extended to assert the new key). The change is ratified here, not silent.

translate_sweep — sugar parity (no risk emitted)

// verb_sugar.rs:76 generated CampaignDoc — the risk field defaults to absent
  CampaignDoc { /* ...unchanged... */, data: DataSection { ... },
+     risk: vec![],   // absent-serializes → sweep-sugar doc byte-unchanged
      strategies: vec![one], ... }

Components

  • aura-research: RiskRegime enum (new); CampaignDoc.risk field; validate_campaign regime check (new DocFault::BadRegime { index, detail }); CAMPAIGN_SECTIONS gains an optional std::risk section descriptor; open_slots_campaign treats risk as optional (never an open slot — absence is legal). metric_vocabulary untouched.
  • aura-campaign: CellSpec.regime + regime_ordinal; exec.rs regime expansion, 3-tuple nominee key, generalize per (strategy, window, regime); CampaignGeneralization.regime_ordinal; family-name regime discriminator.
  • aura-cli: StopArm enum; wrap_r signature; r_sma_graph bool→arm map; run_blueprint_member stop param + manifest stamp; CliMemberRunner::run_member regime→rule map; campaign_cell_key regime arg.
  • verb_sugar: translate_sweep sets risk: vec![].
  • Design ledger: a C10/C20 amendment note — the risk regime realized as a structural campaign axis (kept-separate, compared not selected), and the ratified parity break (cross-regime argmax is structurally unavailable; the verbs' --stop-length/--stop-k joint grid is retired as a methodology).

Data flow

document (risk list) → parse (serde, risk known/optional) → validate_campaign (regime params sane) → content id (risk absent-serializes) → execute: for each (strategy, instrument, window, regime) cell → run_member maps regime → StopRulerun_blueprint_member binds the signal point, runs with the bound stop, stamps stop_length/stop_k into the manifest → nominee keyed (strategy, window, regime) → per-(strategy, window, regime) generalize across instruments → family table rows carry the regime in family_id → presentation compares regimes side by side.

Error handling

  • A Vol regime with length < 1 or k <= 0 (or non-finite k) → the new DocFault::BadRegime { index, detail } at validate_campaign, phrased by the CLI like the sibling EmptyAxis / BadWindow faults (by-identifier, display- free in the library).
  • risk: [] (explicit empty) is valid — it means the default regime, identical to an absent risk (both round-trip to absent under skip_serializing_if). No refusal.
  • No duplicate-regime detection, matching the existing non-dedup of instruments and windows (consistency, not a new invariant).
  • An unknown top-level key stays refused by CampaignDoc's deny_unknown_fields; risk is now a known optional key.

Testing strategy

  • Unit (aura-research): RiskRegime serde round-trip + wire-form pin ({"vol":{"length":3,"k":2.0}}); validate_campaign accepts a risk section and refuses a bad regime; content-id parity — a doc with risk absent and the same doc with risk: [] both hash to the pre-feature content id (the absent-serialize guarantee).
  • E2E (aura-cli): a real-data campaign with two distinct regimes runs both families; each member manifest stamps its regime's stop_length/stop_k; the two families' family_ids differ by the -r{ordinal} discriminator (no collision/overwrite); the per-(strategy, window, regime) generalize grades are independent.
  • Parity (aura-cli): the dissolved aura sweep <blueprint> --real over an absent-risk generated document still runs one default regime; its member lines now additionally carry stop_length:3/stop_k:2.0 — the characterization pin sweep_real_blueprint_member_manifest_carries_no_stop_param_today flips from asserting the stop's absence to requiring it, and the find-based 0110 pin is extended to assert the new key; the generated doc's content id is unchanged.

Acceptance criteria

  • The intended audience — a researcher authoring a campaign — writes a risk list and gets one family per regime, compared side by side (the worked example above is the empirical evidence).
  • Every member manifest records its resolved stop, default included → each run is reproducible from its own manifest (C18); the executed stop is auditable.
  • Regimes are kept separate (per-regime generalize) and compared at presentation; no cross-regime argmax exists (the structural framing forecloses the cross-R-unit comparison — C10).
  • Absent/empty risk = byte-identical document content id and behaviour parity (one default regime); aura sweep sugar emits no risk.
  • No new failure class against the core constraints: determinism (the regime is static structural data, bound at bootstrap), causality (unaffected), and the no-look-ahead / one-merge invariants are untouched.