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
This commit is contained in:
2026-07-06 13:33:25 +02:00
parent 455916db73
commit abab1f1684
2 changed files with 1180 additions and 0 deletions
+413
View File
@@ -0,0 +1,413 @@
# 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.