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
19 KiB
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:
- a campaign document enumerates a finite list of risk regimes and runs every
(strategy, instrument, window, regime)cell; - each member's resolved stop is stamped into its manifest, closing the C18 gap (the stop becomes addressable, auditable data);
- 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 serializableRiskRegimeclosed vocabulary and an optional top-levelCampaignDoc.risk: Vec<RiskRegime>field. This crate stays dependency-pure (aura-core + serde + sha2); it knows nothing of the runtimeStopRule. - Matrix expansion (
aura-campaign) — the realization matrix gains a fourth structural coordinate. The regime is a kept-separate axis (a peer ofwindow), not an aggregated-over axis (a peer ofinstrument): 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 mapsRiskRegime → StopRuleat the runtime boundary (exactly as it maps campaign axes → runtime param-spaces), threads the resolved stop into thewrap_rbound-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 ®imes {
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:RiskRegimeenum (new);CampaignDoc.riskfield;validate_campaignregime check (newDocFault::BadRegime { index, detail });CAMPAIGN_SECTIONSgains an optionalstd::risksection descriptor;open_slots_campaigntreatsriskas optional (never an open slot — absence is legal).metric_vocabularyuntouched.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:StopArmenum;wrap_rsignature;r_sma_graphbool→arm map;run_blueprint_memberstopparam + manifest stamp;CliMemberRunner::run_memberregime→rule map;campaign_cell_keyregime arg.verb_sugar:translate_sweepsetsrisk: 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-kjoint 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
Volregime withlength < 1ork <= 0(or non-finitek) → the newDocFault::BadRegime { index, detail }atvalidate_campaign, phrased by the CLI like the siblingEmptyAxis/BadWindowfaults (by-identifier, display- free in the library). risk: [](explicit empty) is valid — it means the default regime, identical to an absentrisk(both round-trip to absent underskip_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'sdeny_unknown_fields;riskis now a known optional key.
Testing strategy
- Unit (
aura-research):RiskRegimeserde round-trip + wire-form pin ({"vol":{"length":3,"k":2.0}});validate_campaignaccepts a risk section and refuses a bad regime; content-id parity — a doc withriskabsent and the same doc withrisk: []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'sstop_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 dissolvedaura sweep <blueprint> --realover an absent-riskgenerated document still runs one default regime; its member lines now additionally carrystop_length:3/stop_k:2.0— the characterization pinsweep_real_blueprint_member_manifest_carries_no_stop_param_todayflips from asserting the stop's absence to requiring it, and thefind-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
risklist 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 sweepsugar emits norisk. - 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.