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

414 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```json
{
"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:
```json
{ "...": "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`)
```rust
/// 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
```rust
// 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`)
```rust
// 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`)
```rust
// 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:
```rust
// 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):
```rust
// 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:
```rust
// 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:
```rust
// 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:
```rust
// 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):
```rust
// 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)
```rust
// 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 → StopRule``run_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_id`s 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.