diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index 5a3f6fb..656d86b 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -341,7 +341,7 @@ fn campaign_introspect_unwired_reports_the_spec_example_slots() { write_doc(&dir, "draft.campaign.json", draft); let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "draft.campaign.json"]); assert_eq!(code, Some(0)); - assert!(out.contains("open slot: process.ref (required, one of: content_id, identity_id)")); + assert!(out.contains("open slot: process.ref (required, content id of a process document)")); assert!(out.contains("open slot: strategies[0].axes.slow (axis declared empty — a P1 axis is a non-empty finite set)")); } diff --git a/crates/aura-research/src/lib.rs b/crates/aura-research/src/lib.rs index 4b587a8..70bbbf5 100644 --- a/crates/aura-research/src/lib.rs +++ b/crates/aura-research/src/lib.rs @@ -844,7 +844,9 @@ pub fn open_slots_campaign(text: &str) -> Result, DocError> { } } if v.get("process").and_then(|p| p.get("ref")).is_none() { - slots.push(open("process.ref", "required, one of: content_id, identity_id")); + // deliberately narrower than the strategy-ref hint: validate_campaign + // refuses an identity_id process ref, so the guide must not offer it + slots.push(open("process.ref", "required, content id of a process document")); } if v.get("seed").and_then(|s| s.as_u64()).is_none() { slots.push(open("seed", "required, non-negative integer")); @@ -1215,7 +1217,7 @@ mod tests { "presentation": { "persist_taps": [], "emit": [] } }"#; let slots = open_slots_campaign(draft).unwrap(); assert!(slots.iter().any(|s| s.path == "process.ref" - && s.hint == "required, one of: content_id, identity_id")); + && s.hint == "required, content id of a process document")); assert!(slots.iter().any(|s| s.path == "strategies[0].axes.slow" && s.hint == "axis declared empty — a P1 axis is a non-empty finite set")); diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 633ea62..f1ce499 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -1367,8 +1367,24 @@ spans the third variant, `FamilyKind::WalkForward` — the same branch rebuilds windowed slice from `manifest.window` (winner params via the shared `manifest→cells` recovery), so an `aura walkforward` family re-derives bit-identically too. All three family kinds now persist *and* reproduce through the one shared `topology_hash`+`put_blueprint` hook. - -### C19 — Bootstrap: blueprint → instance (recursive) +**Realization (cycle 0106, #189 — research-artifact document stores).** The registry's +content-addressed store family grew two siblings beside `blueprints/`: `processes/` and +`campaigns/` hold the two research-artifact document types shipped by the #188 role-model +pass — the **process document** (role 5: a named validation/eval methodology, a closed std +stage vocabulary wrapping shipped primitives) and the **campaign document** (role 6b: +persisted experiment intent — instruments × windows × strategy refs by content/identity id × +param axes × process ref (content-id-only) × data-level presentation). Documents are +canonical JSON (`format_version` envelope, omit-defaults, no trailing newline) keyed by the +**shared content-id primitive, now library-hosted** (`aura_research::content_id_of`; +`aura-cli`'s `content_id`/`topology_hash` delegate byte-identically — the id goldens pin the +move). Unlike `put_blueprint` (caller owns the hash), the document puts self-key from +canonical bytes; gets are `Ok(None)` treat-as-empty. The **referential tier** +(`validate_campaign_refs`) resolves process/strategy refs against the stores (identity refs +by store scan) and checks each campaign axis — name AND declared `ScalarKind`, the axis +carries its kind once — against the referenced blueprint's `param_space`. Campaign P1 +control constructs (axes/gates/ladders per #188) carry intent only: **no executor exists**; +executor need is re-tested against the intent-persistence diagnosis before one is scoped +(#189 triage, decided item 6). **Guarantee.** Construction is a distinct phase, recursive at every level. Each node type has a **factory** `params → sized concrete node` (e.g. `SMA(length)` sizes its ring buffer). A **blueprint** is the param-generic, input-role-generic @@ -2127,6 +2143,18 @@ artifact, and any run is deterministic once instantiated" (C1). brainstorm / milestone), and the **composable-orchestration** half — the axes (sweep / MC / walk-forward) becoming tools applied to a World-constructed harness instead of the hard-wired `aura-cli` verbs (`sweep_family` / `walkforward_family`). + **Status (cycle 0106, #189 — the artifact half shipped v1).** The #188 role-model pass + re-cut the "experiment-builder API" reading of this thread (role-6b work mis-typed in + role-2 technique): experiment intent is now **data**, not a Rust API. Cycle 0106 shipped + both document vocabularies (process document, campaign document — `aura-research`), + two-tier validation, the op-script-precedent introspection contract + (`aura process|campaign introspect --vocabulary/--block/--unwired/--content-id`), and + content-addressed stores (C18 realization note) — authorable and checkable headless, no + compile, no run. Still open here: the executor question (deliberately unscoped until the + intent-persistence diagnosis is re-tested, #189), the "once it carries" dissolution of the + hard-wired verbs into the campaign path (user decision 2026-07-03 on #188), and the + remaining #188 amendment package (role-model contract entry; C20 "plain Rust loops" + clause; C22 wording; invariant-10 clarification). - **Inferential honesty of the World — family-selection without false-discovery control (tracked: milestone "Inferential validation (defend against false discovery at sweep scale)", #144 / #145 / #146; adjacent #139).** The World's diff --git a/docs/glossary.md b/docs/glossary.md index 9504fb8..18ffc92 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -43,6 +43,10 @@ A frozen artifact that is a deployed strategy + broker: the live trading program **Avoid:** — A downstream consumer node, never part of the strategy: the signal-quality side is the **R-evaluator** (per-trade R-outcomes from the in-R `RiskExecutor` → R-expectancy), read as **gross R** (signal) and **net R** (after the `cost model`). The historical in-graph **realistic broker** — the in-loop fill/commission/slippage account simulator that backtrader (`BackBroker`) and LEAN call a "broker" — is retired (real friction is not historically knowable) — cost is approximated by the `cost model` (a C9 cost-graph, in R), and the only real broker is the **live deploy-edge I/O adapter** against an actual venue (e.g. cTrader Open API), the sole reliable ground truth (measured never modelled, C11/C13); account money lives only at that deploy edge. +### campaign document +**Avoid:** experiment doc, campaign file +The role-6b research artifact (#188/#189): persisted experiment intent as closed-vocabulary canonical JSON — instruments × windows × strategy refs (by `content id` or `identity id`) × per-strategy param axes (each axis declares its `ScalarKind` once over bare values) × a process reference (content-id-only) × data-level presentation (taps to persist, tables to emit). Authored and validated headless (`aura campaign validate|introspect|register`), content-addressed beside blueprints in the registry store; carries P1 control constructs (bounded axes, gates, ladders) as intent — execution semantics are a separate, later concern. + ### cdylib **Avoid:** — The dynamically-loadable Rust library form of a project and its nodes, hot-reloaded during authoring; the hot-reload unit is always the project-side cdylib. Frozen to a static artifact for deploy. @@ -57,7 +61,7 @@ A node that wires a sub-graph and exposes one output (a combined signal, or a st ### content id **Avoid:** — -The SHA-256 (hex) of a blueprint's byte-canonical `blueprint_to_json` form — the byte-exact identity that keys the reproduction store and anchors `aura reproduce` (stamped into a run manifest as the `topology hash`). Surfaced as `aura graph introspect --content-id`; two documents share a content id only when their canonical bytes agree, debug names included — contrast `identity id`. +The SHA-256 (hex) of an artifact's byte-canonical form — for a blueprint, `blueprint_to_json` (the byte-exact identity that keys the reproduction store and anchors `aura reproduce`, stamped into a run manifest as the `topology hash`); for a `process document` / `campaign document`, its canonical JSON (keying the sibling registry stores). One primitive, library-hosted in `aura-research` (`content_id_of`; the CLI delegates). Surfaced as `aura graph|process|campaign introspect --content-id`; two artifacts share a content id only when their canonical bytes agree, debug names included — contrast `identity id`. ### conviction-based risk allocation **Avoid:** risk budgeting, risk parity, risk contribution @@ -88,8 +92,8 @@ A directed wiring link in a harness that forwards **one field** (`from_field`) o A downstream node's output over time — the headline form is the **net-R equity curve** (gross R minus the `cost model`'s cost-in-R, via the `net_r_equity` tap); currency equity is not a research artifact (it lives only at the live deploy edge). An equity *curve* is explicitly not a strategy's output (the `bias` stream is). ### experiment -**Avoid:** — -A Rust (builder-API) definition of anything beyond a single backtest — sweep, Monte-Carlo, walk-forward, or a structural matrix; it lives in a project's `experiments/`. Authored in native Rust, never a config DSL — an active *generator* of runs, not MLflow's passive run-*container* (that grouping role is the `run registry` / family). +**Avoid:** experiment-builder API +**Superseded by `campaign document` (2026-07-03, #188/#189).** The pre-role-model reading — a Rust builder-API definition of anything beyond a single backtest, living in a project's `experiments/` — was role-6b work mis-typed in role-2 technique: experiment intent is now closed-vocabulary *data* (the campaign document), not Rust source, so a tiny campaign change never costs a compile cycle. What stays Rust is node/block *logic* (role 2). The run registry / family remains the passive run-container counterpart. ### experiment matrix **Avoid:** structural matrix @@ -175,6 +179,10 @@ The egui-native visual face (`aura play`) that plays any harness — program str **Avoid:** transactions, trade blotter A broker-independent, time-ordered table of position events (scalar columns: `event_ts, action, position_id, instrument_id, volume`) — the **derived** first difference of the executed **book** (book-tracking `deal = target − book − in_flight`; a close sizes the actual book). A **deploy / reconciliation** artifact (real volume), not a research artifact and not "fed to a broker" in research; computed, not emitted per `eval` (one decision instant may yield >1 event), and **not** the strategy's direct DAG output (that is the `bias` stream). +### process document +**Avoid:** process doc, methodology file +The role-5 research artifact (#188/#189): a named, versionable validation/eval methodology as closed-vocabulary canonical JSON — an ordered pipeline of std stage blocks (`std::sweep`, `std::gate`, `std::walk_forward`, `std::monte_carlo`, `std::generalize`) wrapping shipped analysis/selection primitives, with typed gate predicates over the declared metric vocabulary. Authored and validated headless (`aura process validate|introspect|register`), content-addressed in the registry store; campaigns reference it by `content id` only — a one-off methodology is a process document with a disposable name, never an inline body. + ### R **Avoid:** — The native unit of strategy performance: 1R = the loss if the protective stop is hit, so a trade's outcome is a multiple of R (−1R stopped out, +2R, …) and the primary question is *"how much R out per 1R risked?"*. Risk-normalized and account-/instrument-agnostic — the research yardstick replacing pips, defined by the stop (Van Tharp R-multiples), read as **gross R** (signal only) and **net R** (after the `cost model`). diff --git a/docs/plans/0106-research-artifact-vocabularies.md b/docs/plans/0106-research-artifact-vocabularies.md deleted file mode 100644 index 21ee34b..0000000 --- a/docs/plans/0106-research-artifact-vocabularies.md +++ /dev/null @@ -1,2593 +0,0 @@ -# Research-artifact vocabularies (cycle 0106) — Implementation Plan - -> **Parent spec:** `docs/specs/0106-research-artifact-vocabularies.md` -> -> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to -> run this plan. Steps use `- [ ]` checkboxes for tracking. - -**Goal:** Ship the two research-artifact document types (process, campaign) -as a new leaf crate `aura-research` — parse, intrinsic validation, canonical -form + content id (including the primitive move from aura-cli), the -introspection contract — plus content-addressed document stores and the -referential tier in aura-registry, and the `aura process` / `aura campaign` -CLI verb families. No executor. - -**Architecture:** `aura-research` is a leaf (deps: aura-core, serde, -serde_json, sha2) owning document types + intrinsic validation + -introspection + the canonical-bytes SHA-256 primitive. `aura-registry` adds -`processes/`/`campaigns/` stores on the blueprint-store pattern and -`validate_campaign_refs` (engine + research deps). `aura-cli` adds two verb -families in a new `research_docs.rs` (graph_construct.rs analogue) and -delegates its `content_id` to the moved primitive. A campaign axis declares -its `ScalarKind` ONCE (`{"kind": "I64", "values": [8, 12, 16]}`, bare -values) — user-vetoed per-cell tagging is unrepresentable (#189 comment, -2026-07-03). - -**Tech Stack:** Rust workspace; serde/serde_json (derives + two hand-rolled -strict deserializers: `StageBlock` — serde's `deny_unknown_fields` does not -compose with internally-tagged enums — and `Axis`); sha2; clap derive; -binary seam tests via `CARGO_BIN_EXE_aura`. - -**Files this plan creates or modifies:** - -- Create: `crates/aura-research/Cargo.toml` — leaf-crate manifest -- Create: `crates/aura-research/src/lib.rs` — document types, parse, - validation, canonical form + content id, introspection -- Create: `crates/aura-cli/src/research_docs.rs` — CLI dispatch + fault prose -- Create: `crates/aura-cli/tests/research_docs.rs` — binary seam tests -- Modify: `Cargo.toml:11-20` — workspace members: add `"crates/aura-research"` -- Modify: `crates/aura-cli/Cargo.toml:12-20` — add `aura-research` path dep -- Modify: `crates/aura-cli/src/main.rs:2587-2591` — `content_id` delegates -- Modify: `crates/aura-cli/src/main.rs:14-17` — `mod research_docs;` -- Modify: `crates/aura-cli/src/main.rs:3743-3765` — `Command::Process`, - `Command::Campaign` variants (one per task, dispatch arm at - `main.rs:4702-4713` threaded in the same task — compile-gate discipline) -- Modify: `crates/aura-registry/src/lib.rs` — document stores after - `get_blueprint` (~:134); `RefFault` + `validate_campaign_refs`; tests -- Modify: `crates/aura-registry/Cargo.toml` — add `aura-research` dep; - dev-deps `aura-std`, `aura-composites` if absent - -**House idioms binding every task** (read once, apply everywhere): no -`git commit` steps; fault enums Display-free, CLI phrases prose (#162); -usage exit 2 / runtime exit 1 (#175) — runtime errors print -`aura: ` to stderr then `std::process::exit(1)` (the -`dispatch_generalize` idiom, main.rs:4344-4347); the introspect -exactly-one-of guard lives INSIDE the cmd fn and exits 2 (the -`introspect_cmd` idiom, graph_construct.rs:198-208); every Run step uses at -most ONE positional test filter. - ---- - -### Task 1: aura-research crate skeleton + process document types + strict parse - -**Files:** -- Create: `crates/aura-research/Cargo.toml` -- Create: `crates/aura-research/src/lib.rs` -- Modify: `Cargo.toml:11-20` (workspace members) - -- [ ] **Step 1: Read the manifest template** - -Run: `cat crates/aura-analysis/Cargo.toml crates/aura-core/Cargo.toml` -Expected: the workspace-inherit key style and the serde declaration form -used by the existing leaf crates. Mirror those exact declaration styles in -Step 2 (the dependency SET below is fixed; only the style follows the -template). - -- [ ] **Step 2: Create the crate manifest and register the workspace member** - -`crates/aura-research/Cargo.toml`: - -```toml -[package] -name = "aura-research" -version.workspace = true -edition.workspace = true - -[dependencies] -aura-core = { path = "../aura-core" } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha2 = "0.10" -``` - -In the root `Cargo.toml` members list (lines 11-20) add, keeping the -existing ordering style: - -```toml - "crates/aura-research", -``` - -- [ ] **Step 3: Write `crates/aura-research/src/lib.rs` — process types + schema tables + strict parse** - -```rust -//! aura-research — the research-artifact document layer. -//! -//! Closed-vocabulary, typed, serialized document types for the methodology -//! and campaign roles (#188): the **process document** (a named -//! validation/eval methodology) and the **campaign document** (persisted -//! experiment intent). This crate owns parsing, intrinsic validation, -//! canonical serialization + content ids, and the introspection contract. -//! It is a leaf: no store, no engine access, no execution semantics. -//! -//! Control-flow power is the ratified P1 tier: bounded axes over declared -//! finite sets and structured gates between stages. Shapes outside P1 -//! (variables, recursion, unbounded iteration) are unrepresentable. - -use std::collections::BTreeMap; - -use aura_core::{Scalar, ScalarKind}; -use serde::{Deserialize, Serialize}; - -/// The one accepted document format version. -pub const FORMAT_VERSION: u32 = 1; - -/// Document kind tag carried in every document envelope. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum DocKind { - Process, - Campaign, -} - -/// A process document: a named, versionable validation/eval methodology. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProcessDoc { - pub format_version: u32, - pub kind: DocKind, - pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - pub pipeline: Vec, -} - -/// One pipeline stage. Serialize derives the internally-tagged wire form; -/// Deserialize is hand-rolled (schema-strict) because serde's -/// `deny_unknown_fields` does not compose with `#[serde(tag = ...)]`. -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(tag = "block")] -pub enum StageBlock { - #[serde(rename = "std::sweep")] - Sweep { - metric: String, - select: SelectRule, - #[serde(default, skip_serializing_if = "is_false")] - deflate: bool, - }, - #[serde(rename = "std::gate")] - Gate { all: Vec }, - #[serde(rename = "std::walk_forward")] - WalkForward { - folds: u32, - in_sample_bars: u64, - out_of_sample_bars: u64, - metric: String, - select: SelectRule, - }, - #[serde(rename = "std::monte_carlo")] - MonteCarlo { resamples: u32, block_len: u32 }, - #[serde(rename = "std::generalize")] - Generalize { metric: String }, -} - -fn is_false(b: &bool) -> bool { - !*b -} - -/// Winner-selection rule; wire strings mirror the CLI `--select` values. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum SelectRule { - #[serde(rename = "argmax")] - Argmax, - #[serde(rename = "plateau:mean")] - PlateauMean, - #[serde(rename = "plateau:worst")] - PlateauWorst, -} - -/// One typed gate predicate: ` `. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Predicate { - pub metric: String, - pub cmp: Cmp, - pub value: f64, -} - -/// Closed comparator set (no equality on floats). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Cmp { - Gt, - Ge, - Lt, - Le, -} - -// --------------------------------------------------------------------------- -// Block schema tables — the single source for parse strictness AND the -// introspection contract (the 0105 roster lesson: one source, no drift). -// --------------------------------------------------------------------------- - -/// Slot value kind, for schema description and CLI presentation. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SlotKind { - MetricName, - SelectRule, - Bool, - U32, - U64, - Predicates, - Strings, - Windows, - Ref, - Axes, - EmitKinds, -} - -/// One typed slot of a block. -pub struct SlotInfo { - pub name: &'static str, - pub kind: SlotKind, - pub required: bool, -} - -/// One block of a document vocabulary: id, one-line doc, typed slots. -pub struct BlockSchema { - pub id: &'static str, - pub doc: &'static str, - pub slots: &'static [SlotInfo], -} - -/// The closed v1 process-stage vocabulary (std set; ids are namespaced). -pub const PROCESS_BLOCKS: &[BlockSchema] = &[ - BlockSchema { - id: "std::sweep", - doc: "evaluate the campaign's axes-space; reduce members to R metrics; select a winner", - slots: &[ - SlotInfo { name: "metric", kind: SlotKind::MetricName, required: true }, - SlotInfo { name: "select", kind: SlotKind::SelectRule, required: true }, - SlotInfo { name: "deflate", kind: SlotKind::Bool, required: false }, - ], - }, - BlockSchema { - id: "std::gate", - doc: "filter survivors by a conjunction of typed metric predicates", - slots: &[SlotInfo { name: "all", kind: SlotKind::Predicates, required: true }], - }, - BlockSchema { - id: "std::walk_forward", - doc: "rolling in-sample optimize + out-of-sample test", - slots: &[ - SlotInfo { name: "folds", kind: SlotKind::U32, required: true }, - SlotInfo { name: "in_sample_bars", kind: SlotKind::U64, required: true }, - SlotInfo { name: "out_of_sample_bars", kind: SlotKind::U64, required: true }, - SlotInfo { name: "metric", kind: SlotKind::MetricName, required: true }, - SlotInfo { name: "select", kind: SlotKind::SelectRule, required: true }, - ], - }, - BlockSchema { - id: "std::monte_carlo", - doc: "R-bootstrap over realised R (terminal annotator in v1)", - slots: &[ - SlotInfo { name: "resamples", kind: SlotKind::U32, required: true }, - SlotInfo { name: "block_len", kind: SlotKind::U32, required: true }, - ], - }, - BlockSchema { - id: "std::generalize", - doc: "cross-instrument worst-case floor (terminal annotator in v1)", - slots: &[SlotInfo { name: "metric", kind: SlotKind::MetricName, required: true }], - }, -]; - -// --------------------------------------------------------------------------- -// Strict stage parsing (schema-driven). -// --------------------------------------------------------------------------- - -fn require_str( - m: &serde_json::Map, - key: &str, - block: &str, -) -> Result { - match m.get(key) { - Some(serde_json::Value::String(s)) => Ok(s.clone()), - Some(_) => Err(format!("block {block}: slot \"{key}\" must be a string")), - None => Err(format!("block {block}: missing required slot \"{key}\"")), - } -} - -fn require_u64( - m: &serde_json::Map, - key: &str, - block: &str, -) -> Result { - match m.get(key) { - Some(v) => v - .as_u64() - .ok_or_else(|| format!("block {block}: slot \"{key}\" must be a non-negative integer")), - None => Err(format!("block {block}: missing required slot \"{key}\"")), - } -} - -fn require_u32( - m: &serde_json::Map, - key: &str, - block: &str, -) -> Result { - let v = require_u64(m, key, block)?; - u32::try_from(v).map_err(|_| format!("block {block}: slot \"{key}\" out of range")) -} - -fn optional_bool( - m: &serde_json::Map, - key: &str, - block: &str, -) -> Result { - match m.get(key) { - None => Ok(false), - Some(serde_json::Value::Bool(b)) => Ok(*b), - Some(_) => Err(format!("block {block}: slot \"{key}\" must be a bool")), - } -} - -fn select_from( - m: &serde_json::Map, - block: &str, -) -> Result { - let s = require_str(m, "select", block)?; - match s.as_str() { - "argmax" => Ok(SelectRule::Argmax), - "plateau:mean" => Ok(SelectRule::PlateauMean), - "plateau:worst" => Ok(SelectRule::PlateauWorst), - other => Err(format!("block {block}: unknown select rule \"{other}\"")), - } -} - -fn stage_from_value(v: &serde_json::Value) -> Result { - let m = v - .as_object() - .ok_or_else(|| "a pipeline stage must be an object".to_string())?; - let id = match m.get("block") { - Some(serde_json::Value::String(s)) => s.clone(), - _ => return Err("a pipeline stage must carry a string \"block\" id".to_string()), - }; - let schema = PROCESS_BLOCKS - .iter() - .find(|b| b.id == id) - .ok_or_else(|| format!("unknown block \"{id}\""))?; - for key in m.keys() { - if key != "block" && !schema.slots.iter().any(|s| s.name == key) { - return Err(format!("block {id}: unknown slot \"{key}\"")); - } - } - match id.as_str() { - "std::sweep" => Ok(StageBlock::Sweep { - metric: require_str(m, "metric", &id)?, - select: select_from(m, &id)?, - deflate: optional_bool(m, "deflate", &id)?, - }), - "std::gate" => { - let all = m - .get("all") - .ok_or_else(|| format!("block {id}: missing required slot \"all\""))?; - let all: Vec = serde_json::from_value(all.clone()) - .map_err(|e| format!("block {id}: bad predicate list: {e}"))?; - Ok(StageBlock::Gate { all }) - } - "std::walk_forward" => Ok(StageBlock::WalkForward { - folds: require_u32(m, "folds", &id)?, - in_sample_bars: require_u64(m, "in_sample_bars", &id)?, - out_of_sample_bars: require_u64(m, "out_of_sample_bars", &id)?, - metric: require_str(m, "metric", &id)?, - select: select_from(m, &id)?, - }), - "std::monte_carlo" => Ok(StageBlock::MonteCarlo { - resamples: require_u32(m, "resamples", &id)?, - block_len: require_u32(m, "block_len", &id)?, - }), - "std::generalize" => Ok(StageBlock::Generalize { - metric: require_str(m, "metric", &id)?, - }), - _ => unreachable!("schema membership checked above"), - } -} - -impl<'de> Deserialize<'de> for StageBlock { - fn deserialize>(d: D) -> Result { - let v = serde_json::Value::deserialize(d)?; - stage_from_value(&v).map_err(serde::de::Error::custom) - } -} - -// --------------------------------------------------------------------------- -// Parse (envelope-checked). -// --------------------------------------------------------------------------- - -/// Parse-level refusals. By-identifier and Display-free: the CLI phrases them. -#[derive(Clone, Debug, PartialEq)] -pub enum DocError { - NotJson(String), - BadFormatVersion(u32), - WrongKind { expected: DocKind }, - Malformed(String), -} - -fn check_envelope(v: &serde_json::Value, expected: DocKind) -> Result<(), DocError> { - let fv = v.get("format_version").and_then(|x| x.as_u64()); - if fv != Some(u64::from(FORMAT_VERSION)) { - return Err(DocError::BadFormatVersion(fv.unwrap_or(0) as u32)); - } - let want = match expected { - DocKind::Process => "process", - DocKind::Campaign => "campaign", - }; - match v.get("kind").and_then(|k| k.as_str()) { - Some(k) if k == want => Ok(()), - _ => Err(DocError::WrongKind { expected }), - } -} - -/// Parse a process document from text (envelope + strict vocabulary). -pub fn parse_process(text: &str) -> Result { - let v: serde_json::Value = - serde_json::from_str(text).map_err(|e| DocError::NotJson(e.to_string()))?; - check_envelope(&v, DocKind::Process)?; - serde_json::from_value(v).map_err(|e| DocError::Malformed(e.to_string())) -} - -#[cfg(test)] -mod tests { - use super::*; - - pub(crate) const PROCESS_FIXTURE: &str = r#"{ - "format_version": 1, - "kind": "process", - "name": "wf-deflated-screen", - "description": "Sweep, keep only deflated-positive candidates, then walk-forward.", - "pipeline": [ - { "block": "std::sweep", "metric": "net_expectancy_r", "select": "plateau:worst", "deflate": true }, - { "block": "std::gate", "all": [ - { "metric": "net_expectancy_r", "cmp": "gt", "value": 0.0 }, - { "metric": "overfit_probability", "cmp": "lt", "value": 0.1 } ] }, - { "block": "std::walk_forward", "folds": 4, "in_sample_bars": 4000, - "out_of_sample_bars": 1000, "metric": "net_expectancy_r", "select": "argmax" } - ] - }"#; - - #[test] - fn process_fixture_parses_to_typed_stages() { - let doc = parse_process(PROCESS_FIXTURE).expect("fixture parses"); - assert_eq!(doc.format_version, 1); - assert_eq!(doc.kind, DocKind::Process); - assert_eq!(doc.name, "wf-deflated-screen"); - assert_eq!(doc.pipeline.len(), 3); - match &doc.pipeline[0] { - StageBlock::Sweep { metric, select, deflate } => { - assert_eq!(metric, "net_expectancy_r"); - assert_eq!(*select, SelectRule::PlateauWorst); - assert!(*deflate); - } - other => panic!("stage 0 is not a sweep: {other:?}"), - } - match &doc.pipeline[1] { - StageBlock::Gate { all } => { - assert_eq!(all.len(), 2); - assert_eq!(all[0].cmp, Cmp::Gt); - } - other => panic!("stage 1 is not a gate: {other:?}"), - } - } - - #[test] - fn process_parse_refuses_bad_envelope_and_unknown_vocabulary() { - // wrong format_version - let bad_fv = PROCESS_FIXTURE.replacen("\"format_version\": 1", "\"format_version\": 2", 1); - assert_eq!(parse_process(&bad_fv), Err(DocError::BadFormatVersion(2))); - // wrong kind - let bad_kind = PROCESS_FIXTURE.replacen("\"kind\": \"process\"", "\"kind\": \"campaign\"", 1); - assert_eq!( - parse_process(&bad_kind), - Err(DocError::WrongKind { expected: DocKind::Process }) - ); - // unknown block id - let bad_block = PROCESS_FIXTURE.replacen("std::sweep", "std::sweeep", 1); - let err = parse_process(&bad_block).expect_err("unknown block refused"); - assert!(matches!(err, DocError::Malformed(msg) if msg.contains("unknown block"))); - // unknown slot on a known block - let bad_slot = PROCESS_FIXTURE.replacen("\"deflate\": true", "\"deflaate\": true", 1); - let err = parse_process(&bad_slot).expect_err("unknown slot refused"); - assert!(matches!(err, DocError::Malformed(msg) if msg.contains("unknown slot"))); - // unknown select rule - let bad_select = PROCESS_FIXTURE.replacen("plateau:worst", "plateau:median", 1); - let err = parse_process(&bad_select).expect_err("unknown select refused"); - assert!(matches!(err, DocError::Malformed(msg) if msg.contains("unknown select rule"))); - // top-level unknown field (derive deny_unknown_fields) - let extra = PROCESS_FIXTURE.replacen("\"name\":", "\"nam3\": \"x\", \"name\":", 1); - assert!(matches!(parse_process(&extra), Err(DocError::Malformed(_)))); - // not JSON at all - assert!(matches!(parse_process("not json"), Err(DocError::NotJson(_)))); - } -} -``` - -- [ ] **Step 4: Run the new tests (crate is new — tests land with their code)** - -Run: `cargo test -p aura-research process_fixture_parses_to_typed_stages` -Expected: PASS (1 passed) - -Run: `cargo test -p aura-research process_parse_refuses_bad_envelope_and_unknown_vocabulary` -Expected: PASS (1 passed) - -- [ ] **Step 5: Workspace still builds** - -Run: `cargo build --workspace` -Expected: clean build, 0 errors - ---- - -### Task 2: campaign document types (axis declares its kind once) + strict parse - -**Files:** -- Modify: `crates/aura-research/src/lib.rs` (append types + parse + tests) - -- [ ] **Step 1: Append the campaign types, the Axis serde pair, and the parse fn (before the tests module)** - -```rust -/// A campaign document: persisted experiment intent. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CampaignDoc { - pub format_version: u32, - pub kind: DocKind, - pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - pub data: DataSection, - pub strategies: Vec, - pub process: ProcessRef, - pub seed: u64, - pub presentation: Presentation, -} - -/// Structural data axes: which instruments over which windows. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DataSection { - pub instruments: Vec, - pub windows: Vec, -} - -/// One data window, inclusive epoch-ms bounds (the CLI --from/--to form). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Window { - pub from_ms: i64, - pub to_ms: i64, -} - -/// A strategy under test: an opaque id handle + its tuning axes. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct StrategyEntry { - #[serde(rename = "ref")] - pub r#ref: DocRef, - pub axes: BTreeMap, -} - -/// One tuning axis: the parameter's kind, declared ONCE, plus a finite list -/// of bare JSON values parsed as that kind. A mixed-kind axis is -/// unrepresentable by construction (the kind is a property of the -/// parameter, not of each value — user decision 2026-07-03, #189). -#[derive(Clone, Debug, PartialEq)] -pub struct Axis { - pub kind: ScalarKind, - pub values: Vec, -} - -/// Bare wire value of one Scalar (strips the tagged form via Scalar's own -/// serde as the oracle — no Scalar internals assumed). -fn scalar_to_bare(s: &Scalar) -> serde_json::Value { - match serde_json::to_value(s).expect("scalar serializes") { - serde_json::Value::Object(m) => m.into_values().next().expect("tagged form has one value"), - v => v, - } -} - -fn kind_tag(kind: ScalarKind) -> &'static str { - match kind { - ScalarKind::I64 => "I64", - ScalarKind::F64 => "F64", - ScalarKind::Bool => "Bool", - ScalarKind::Timestamp => "Timestamp", - } -} - -/// Parse one bare JSON value as a Scalar of the declared kind, using -/// Scalar's own serde as the type oracle (an int literal under F64 parses -/// as F64; a float under I64 refuses). -fn scalar_from_bare(kind: ScalarKind, v: &serde_json::Value) -> Result { - let tag = kind_tag(kind); - serde_json::from_value(serde_json::json!({ tag: v })) - .map_err(|_| format!("value {v} is not a valid {tag}")) -} - -impl Serialize for Axis { - fn serialize(&self, s: S) -> Result { - use serde::ser::SerializeStruct; - let mut st = s.serialize_struct("Axis", 2)?; - st.serialize_field("kind", &self.kind)?; - let bare: Vec = self.values.iter().map(scalar_to_bare).collect(); - st.serialize_field("values", &bare)?; - st.end() - } -} - -impl<'de> Deserialize<'de> for Axis { - fn deserialize>(d: D) -> Result { - use serde::de::Error as _; - #[derive(Deserialize)] - #[serde(deny_unknown_fields)] - struct Raw { - kind: ScalarKind, - values: Vec, - } - let raw = Raw::deserialize(d)?; - let values = raw - .values - .iter() - .map(|v| scalar_from_bare(raw.kind, v)) - .collect::, _>>() - .map_err(D::Error::custom)?; - Ok(Axis { kind: raw.kind, values }) - } -} - -/// Reference to a content-addressed artifact: by byte-exact content id or -/// by topological identity id. Externally tagged one-of. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum DocRef { - ContentId(String), - IdentityId(String), -} - -/// The campaign's process slot: reference-only, never an inline body. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProcessRef { - #[serde(rename = "ref")] - pub r#ref: DocRef, -} - -/// Data-level presentation: what to record and emit (never how to render). -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Presentation { - pub persist_taps: Vec, - pub emit: Vec, -} - -/// Parse a campaign document from text (envelope + strict fields). -pub fn parse_campaign(text: &str) -> Result { - let v: serde_json::Value = - serde_json::from_str(text).map_err(|e| DocError::NotJson(e.to_string()))?; - check_envelope(&v, DocKind::Campaign)?; - serde_json::from_value(v).map_err(|e| DocError::Malformed(e.to_string())) -} -``` - -If `aura_core::ScalarKind` does not derive `serde::Deserialize`/`Serialize` -(check crates/aura-core/src/scalar.rs:15-21 — the recon says it does), stop -and report: the Raw struct above depends on it. - -- [ ] **Step 2: Append campaign tests inside the tests module** - -```rust - pub(crate) const CAMPAIGN_FIXTURE: &str = r#"{ - "format_version": 1, - "kind": "campaign", - "name": "ger40-momentum-screen", - "data": { - "instruments": ["GER40"], - "windows": [ { "from_ms": 1704067200000, "to_ms": 1719792000000 } ] - }, - "strategies": [ - { "ref": { "content_id": "9f3a" }, - "axes": { "fast": { "kind": "I64", "values": [8, 12, 16] }, - "slow": { "kind": "I64", "values": [21, 34] } } } - ], - "process": { "ref": { "content_id": "4e2d" } }, - "seed": 1, - "presentation": { - "persist_taps": ["net_r_equity"], - "emit": ["family_table", "selection_report"] - } - }"#; - - #[test] - fn campaign_fixture_parses_with_axis_level_kind() { - let doc = parse_campaign(CAMPAIGN_FIXTURE).expect("fixture parses"); - assert_eq!(doc.kind, DocKind::Campaign); - assert_eq!(doc.data.instruments, vec!["GER40".to_string()]); - let s = &doc.strategies[0]; - assert_eq!(s.r#ref, DocRef::ContentId("9f3a".into())); - let fast = &s.axes["fast"]; - assert_eq!(fast.kind, ScalarKind::I64); - assert_eq!(fast.values, vec![Scalar::I64(8), Scalar::I64(12), Scalar::I64(16)]); - assert_eq!(doc.process.r#ref, DocRef::ContentId("4e2d".into())); - // an int literal under an F64 axis parses AS F64 (kind is the oracle) - let f64_axis: Axis = - serde_json::from_str(r#"{ "kind": "F64", "values": [1, 2.5] }"#).expect("parses"); - assert_eq!(f64_axis.values, vec![Scalar::F64(1.0), Scalar::F64(2.5)]); - } - - #[test] - fn campaign_refuses_inline_process_and_kindless_axes() { - // an inline process body in the ref slot must refuse to parse - let inline = CAMPAIGN_FIXTURE.replacen( - r#""process": { "ref": { "content_id": "4e2d" } }"#, - r#""process": { "ref": { "pipeline": [] } }"#, - 1, - ); - assert!(matches!(parse_campaign(&inline), Err(DocError::Malformed(_)))); - // an inline body BESIDE the ref must refuse too (deny_unknown_fields) - let beside = CAMPAIGN_FIXTURE.replacen( - r#""process": { "ref": { "content_id": "4e2d" } }"#, - r#""process": { "ref": { "content_id": "4e2d" }, "pipeline": [] }"#, - 1, - ); - assert!(matches!(parse_campaign(&beside), Err(DocError::Malformed(_)))); - // a kindless bare array is not an axis - let bare = CAMPAIGN_FIXTURE.replacen( - r#"{ "kind": "I64", "values": [8, 12, 16] }"#, - "[8, 12, 16]", - 1, - ); - assert!(matches!(parse_campaign(&bare), Err(DocError::Malformed(_)))); - // a tagged cell inside values is not a bare value - let tagged = CAMPAIGN_FIXTURE.replacen("[8, 12, 16]", r#"[{"I64": 8}]"#, 1); - assert!(matches!(parse_campaign(&tagged), Err(DocError::Malformed(_)))); - // a float under an I64 axis refuses - let float_in_i64 = CAMPAIGN_FIXTURE.replacen("[8, 12, 16]", "[8.5]", 1); - assert!(matches!(parse_campaign(&float_in_i64), Err(DocError::Malformed(_)))); - // wrong kind envelope - let bad_kind = CAMPAIGN_FIXTURE.replacen("\"kind\": \"campaign\"", "\"kind\": \"process\"", 1); - assert_eq!( - parse_campaign(&bad_kind), - Err(DocError::WrongKind { expected: DocKind::Campaign }) - ); - } -``` - -- [ ] **Step 3: Run the two new tests** - -Run: `cargo test -p aura-research campaign_fixture_parses_with_axis_level_kind` -Expected: PASS - -Run: `cargo test -p aura-research campaign_refuses_inline_process_and_kindless_axes` -Expected: PASS - ---- - -### Task 3: canonical form + content id (the primitive) + goldens - -**Files:** -- Modify: `crates/aura-research/src/lib.rs` (append fns + tests) - -- [ ] **Step 1: Append canonical serialization and the hashing primitive** - -```rust -// --------------------------------------------------------------------------- -// Canonical form + content id — THE hashing primitive (library home; the -// CLI's content_id/topology_hash delegate here). -// --------------------------------------------------------------------------- - -/// Canonical byte form of a process document: omit-defaults JSON in struct -/// field order, no trailing newline (the blueprint canonical-form recipe). -pub fn process_to_json(doc: &ProcessDoc) -> String { - serde_json::to_string(doc).expect("process document serializes") -} - -/// Canonical byte form of a campaign document. Value spelling is -/// normalized: an F64 axis serializes 8 as 8.0, so equivalent spellings -/// share one content id. -pub fn campaign_to_json(doc: &CampaignDoc) -> String { - serde_json::to_string(doc).expect("campaign document serializes") -} - -/// SHA-256 hex over canonical bytes — the single content-id primitive. -pub fn content_id_of(canonical: &str) -> String { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(canonical.as_bytes()); - format!("{:x}", hasher.finalize()) -} -``` - -- [ ] **Step 2: Append the canonical goldens + id tests (tests module)** - -```rust - #[test] - fn process_canonical_form_is_pinned_and_id_stable() { - let doc = parse_process(PROCESS_FIXTURE).unwrap(); - let canonical = process_to_json(&doc); - let golden = concat!( - r#"{"format_version":1,"kind":"process","name":"wf-deflated-screen","#, - r#""description":"Sweep, keep only deflated-positive candidates, then walk-forward.","#, - r#""pipeline":[{"block":"std::sweep","metric":"net_expectancy_r","select":"plateau:worst","deflate":true},"#, - r#"{"block":"std::gate","all":[{"metric":"net_expectancy_r","cmp":"gt","value":0.0},"#, - r#"{"metric":"overfit_probability","cmp":"lt","value":0.1}]},"#, - r#"{"block":"std::walk_forward","folds":4,"in_sample_bars":4000,"out_of_sample_bars":1000,"#, - r#""metric":"net_expectancy_r","select":"argmax"}]}"# - ); - assert_eq!(canonical, golden); - assert!(!canonical.ends_with('\n')); - let id = content_id_of(&canonical); - assert_eq!(id.len(), 64); - assert!(id.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); - let reparsed = parse_process(&canonical).unwrap(); - assert_eq!(content_id_of(&process_to_json(&reparsed)), id); - // omit-defaults: deflate:false disappears from the canonical form - let no_deflate = PROCESS_FIXTURE.replacen("\"deflate\": true", "\"deflate\": false", 1); - let canonical2 = process_to_json(&parse_process(&no_deflate).unwrap()); - assert!(!canonical2.contains("deflate")); - assert_ne!(content_id_of(&canonical2), id); - } - - #[test] - fn campaign_canonical_form_normalizes_and_distinguishes() { - let doc = parse_campaign(CAMPAIGN_FIXTURE).unwrap(); - let canonical = campaign_to_json(&doc); - assert!(!canonical.ends_with('\n')); - let reparsed = parse_campaign(&canonical).unwrap(); - assert_eq!(reparsed, doc); - assert_eq!(campaign_to_json(&reparsed), canonical); - let id = content_id_of(&canonical); - assert_eq!(id.len(), 64); - // a different seed is a different campaign - let other = CAMPAIGN_FIXTURE.replacen("\"seed\": 1", "\"seed\": 2", 1); - let other_id = content_id_of(&campaign_to_json(&parse_campaign(&other).unwrap())); - assert_ne!(other_id, id); - // spelling normalization: an F64 axis writes ints as floats, so the - // two spellings share one canonical form (and thus one content id) - let spelled_a = CAMPAIGN_FIXTURE.replacen( - r#""fast": { "kind": "I64", "values": [8, 12, 16] }"#, - r#""fast": { "kind": "F64", "values": [8, 12.0, 16] }"#, - 1, - ); - let spelled_b = CAMPAIGN_FIXTURE.replacen( - r#""fast": { "kind": "I64", "values": [8, 12, 16] }"#, - r#""fast": { "kind": "F64", "values": [8.0, 12, 16.0] }"#, - 1, - ); - let ca = campaign_to_json(&parse_campaign(&spelled_a).unwrap()); - let cb = campaign_to_json(&parse_campaign(&spelled_b).unwrap()); - assert_eq!(ca, cb); - assert!(ca.contains(r#""values":[8.0,12.0,16.0]"#)); - } -``` - -- [ ] **Step 3: Run the two golden tests** - -Run: `cargo test -p aura-research process_canonical_form_is_pinned_and_id_stable` -Expected: PASS (if the golden mismatches, the diff must be a pure -serde-order artefact justified against the declared struct order of Tasks -1-2 — fix the golden then, never the struct order) - -Run: `cargo test -p aura-research campaign_canonical_form_normalizes_and_distinguishes` -Expected: PASS - ---- - -### Task 4: intrinsic validation — process side - -**Files:** -- Modify: `crates/aura-research/src/lib.rs` (append fault enum + validator + tests) - -- [ ] **Step 1: Append the fault enum, metric roster, and process validator** - -```rust -// --------------------------------------------------------------------------- -// Intrinsic validation (store-free). -// --------------------------------------------------------------------------- - -/// The closed declared-metric vocabulary: the scalar field names of the -/// shipped metrics/selection types (RMetrics + RunMetrics scalars, plus the -/// FamilySelection annotation scores). Single source for gate/stage checks -/// and introspection. -pub fn metric_vocabulary() -> &'static [&'static str] { - &[ - "expectancy_r", - "win_rate", - "avg_win_r", - "avg_loss_r", - "profit_factor", - "max_r_drawdown", - "sqn", - "sqn_normalized", - "net_expectancy_r", - "n_trades", - "n_open_at_end", - "total_pips", - "max_drawdown", - "bias_sign_flips", - "deflated_score", - "overfit_probability", - "neighbourhood_score", - ] -} - -/// The closed v1 emit vocabulary (data-level presentation outputs). -pub fn emit_vocabulary() -> &'static [&'static str] { - &["family_table", "selection_report"] -} - -/// Intrinsic-validation findings. By-identifier and Display-free. -#[derive(Clone, Debug, PartialEq)] -pub enum DocFault { - // process side - EmptyPipeline, - UnknownMetric { stage: usize, metric: String }, - EmptyConjunction { stage: usize }, - GateFirst, - GateAfterTerminalStage { stage: usize }, - // campaign side - EmptyInstruments, - NoWindow, - BadWindow { index: usize }, - NoStrategy, - EmptyAxes { strategy: usize }, - EmptyAxis { strategy: usize, axis: String }, - UnknownEmitKind(String), - ProcessRefMustBeContentId, -} - -fn is_known_metric(name: &str) -> bool { - metric_vocabulary().contains(&name) -} - -/// Intrinsic validation of a process document (P1 constraints included). -/// Returns ALL findings, not the first. -pub fn validate_process(doc: &ProcessDoc) -> Vec { - let mut faults = Vec::new(); - if doc.pipeline.is_empty() { - faults.push(DocFault::EmptyPipeline); - } - let mut terminal_seen = false; - for (i, stage) in doc.pipeline.iter().enumerate() { - match stage { - StageBlock::Sweep { metric, .. } - | StageBlock::Generalize { metric } - | StageBlock::WalkForward { metric, .. } => { - if !is_known_metric(metric) { - faults.push(DocFault::UnknownMetric { stage: i, metric: metric.clone() }); - } - } - StageBlock::Gate { all } => { - if i == 0 { - faults.push(DocFault::GateFirst); - } else if terminal_seen { - faults.push(DocFault::GateAfterTerminalStage { stage: i }); - } - if all.is_empty() { - faults.push(DocFault::EmptyConjunction { stage: i }); - } - for p in all { - if !is_known_metric(&p.metric) { - faults.push(DocFault::UnknownMetric { stage: i, metric: p.metric.clone() }); - } - } - } - StageBlock::MonteCarlo { .. } => {} - } - if matches!(stage, StageBlock::MonteCarlo { .. } | StageBlock::Generalize { .. }) { - terminal_seen = true; - } - } - faults -} -``` - -- [ ] **Step 2: Append the process-validation tests** - -```rust - #[test] - fn validate_process_accepts_the_fixture_and_reports_each_fault() { - let ok = parse_process(PROCESS_FIXTURE).unwrap(); - assert_eq!(validate_process(&ok), Vec::new()); - - let empty = ProcessDoc { pipeline: Vec::new(), ..ok.clone() }; - assert_eq!(validate_process(&empty), vec![DocFault::EmptyPipeline]); - - let bad_metric = parse_process(&PROCESS_FIXTURE.replacen( - "net_expectancy_r\", \"select\": \"plateau:worst", - "netto_r\", \"select\": \"plateau:worst", - 1, - )) - .unwrap(); - assert!(validate_process(&bad_metric) - .iter() - .any(|f| matches!(f, DocFault::UnknownMetric { stage: 0, metric } if metric == "netto_r"))); - - let gate_first = ProcessDoc { - pipeline: vec![StageBlock::Gate { - all: vec![Predicate { metric: "sqn".into(), cmp: Cmp::Gt, value: 1.0 }], - }], - ..ok.clone() - }; - assert!(validate_process(&gate_first).contains(&DocFault::GateFirst)); - - let empty_conj = ProcessDoc { - pipeline: vec![ok.pipeline[0].clone(), StageBlock::Gate { all: Vec::new() }], - ..ok.clone() - }; - assert!(validate_process(&empty_conj).contains(&DocFault::EmptyConjunction { stage: 1 })); - - let gate_after = ProcessDoc { - pipeline: vec![ - ok.pipeline[0].clone(), - StageBlock::MonteCarlo { resamples: 100, block_len: 5 }, - StageBlock::Gate { - all: vec![Predicate { metric: "sqn".into(), cmp: Cmp::Gt, value: 1.0 }], - }, - ], - ..ok.clone() - }; - assert!(validate_process(&gate_after) - .contains(&DocFault::GateAfterTerminalStage { stage: 2 })); - } -``` - -- [ ] **Step 3: Run the test** - -Run: `cargo test -p aura-research validate_process_accepts_the_fixture_and_reports_each_fault` -Expected: PASS - ---- - -### Task 5: intrinsic validation — campaign side - -**Files:** -- Modify: `crates/aura-research/src/lib.rs` (append validator + tests) - -- [ ] **Step 1: Append the campaign validator (after `validate_process`)** - -```rust -/// Intrinsic validation of a campaign document (P1 constraints included). -/// Returns ALL findings, not the first. -pub fn validate_campaign(doc: &CampaignDoc) -> Vec { - let mut faults = Vec::new(); - if doc.data.instruments.is_empty() { - faults.push(DocFault::EmptyInstruments); - } - if doc.data.windows.is_empty() { - faults.push(DocFault::NoWindow); - } - for (i, w) in doc.data.windows.iter().enumerate() { - if w.from_ms >= w.to_ms { - faults.push(DocFault::BadWindow { index: i }); - } - } - if doc.strategies.is_empty() { - faults.push(DocFault::NoStrategy); - } - for (i, s) in doc.strategies.iter().enumerate() { - if s.axes.is_empty() { - faults.push(DocFault::EmptyAxes { strategy: i }); - } - for (axis, ax) in &s.axes { - if ax.values.is_empty() { - faults.push(DocFault::EmptyAxis { strategy: i, axis: axis.clone() }); - } - } - } - if matches!(doc.process.r#ref, DocRef::IdentityId(_)) { - faults.push(DocFault::ProcessRefMustBeContentId); - } - for e in &doc.presentation.emit { - if !emit_vocabulary().contains(&e.as_str()) { - faults.push(DocFault::UnknownEmitKind(e.clone())); - } - } - faults -} -``` - -- [ ] **Step 2: Append the campaign-validation tests** - -```rust - #[test] - fn validate_campaign_accepts_the_fixture_and_reports_each_fault() { - let ok = parse_campaign(CAMPAIGN_FIXTURE).unwrap(); - assert_eq!(validate_campaign(&ok), Vec::new()); - - let mut bare = ok.clone(); - bare.data.instruments.clear(); - bare.data.windows.clear(); - let faults = validate_campaign(&bare); - assert!(faults.contains(&DocFault::EmptyInstruments)); - assert!(faults.contains(&DocFault::NoWindow)); - - let mut inv = ok.clone(); - inv.data.windows[0] = Window { from_ms: 10, to_ms: 10 }; - assert!(validate_campaign(&inv).contains(&DocFault::BadWindow { index: 0 })); - - let mut none = ok.clone(); - none.strategies.clear(); - assert!(validate_campaign(&none).contains(&DocFault::NoStrategy)); - - let mut no_axes = ok.clone(); - no_axes.strategies[0].axes.clear(); - assert!(validate_campaign(&no_axes).contains(&DocFault::EmptyAxes { strategy: 0 })); - - let mut empty_axis = ok.clone(); - empty_axis.strategies[0] - .axes - .insert("slow".into(), Axis { kind: ScalarKind::I64, values: Vec::new() }); - assert!(validate_campaign(&empty_axis) - .contains(&DocFault::EmptyAxis { strategy: 0, axis: "slow".into() })); - - let mut ident = ok.clone(); - ident.process.r#ref = DocRef::IdentityId("aaaa".into()); - assert!(validate_campaign(&ident).contains(&DocFault::ProcessRefMustBeContentId)); - - let mut emit = ok.clone(); - emit.presentation.emit.push("pie_chart".into()); - assert!(validate_campaign(&emit).contains(&DocFault::UnknownEmitKind("pie_chart".into()))); - } -``` - -- [ ] **Step 3: Run the test** - -Run: `cargo test -p aura-research validate_campaign_accepts_the_fixture_and_reports_each_fault` -Expected: PASS - ---- - -### Task 6: the introspection contract - -**Files:** -- Modify: `crates/aura-research/src/lib.rs` (append campaign section table, - vocabulary/describe/open-slots + tests) - -- [ ] **Step 1: Append the campaign section table and the introspection fns** - -```rust -// --------------------------------------------------------------------------- -// Introspection contract: enumerate vocabulary, describe a block's typed -// slots, list a partial document's open slots. The same tables that make -// parsing strict make a block palette generatable (the litmus test). -// --------------------------------------------------------------------------- - -/// The campaign document's section vocabulary (descriptive: campaign parsing -/// is derive-strict; this table powers introspection). -pub const CAMPAIGN_SECTIONS: &[BlockSchema] = &[ - BlockSchema { - id: "std::data", - doc: "campaign section: instruments + windows", - slots: &[ - SlotInfo { name: "instruments", kind: SlotKind::Strings, required: true }, - SlotInfo { name: "windows", kind: SlotKind::Windows, required: true }, - ], - }, - BlockSchema { - id: "std::strategy", - doc: "campaign section: strategy ref (content or identity id) + param axes", - slots: &[ - SlotInfo { name: "ref", kind: SlotKind::Ref, required: true }, - SlotInfo { name: "axes", kind: SlotKind::Axes, required: true }, - ], - }, - BlockSchema { - id: "std::process_ref", - doc: "campaign section: reference to a named process document (content id)", - slots: &[SlotInfo { name: "ref", kind: SlotKind::Ref, required: true }], - }, - BlockSchema { - id: "std::presentation", - doc: "campaign section: taps to persist + tables to emit (data-level only)", - slots: &[ - SlotInfo { name: "persist_taps", kind: SlotKind::Strings, required: true }, - SlotInfo { name: "emit", kind: SlotKind::EmitKinds, required: true }, - ], - }, -]; - -/// Human label for a slot kind (CLI presentation). -pub fn slot_kind_label(kind: SlotKind) -> &'static str { - match kind { - SlotKind::MetricName => "metric name (see metric_vocabulary)", - SlotKind::SelectRule => "select rule: argmax | plateau:mean | plateau:worst", - SlotKind::Bool => "bool", - SlotKind::U32 => "non-negative integer (u32)", - SlotKind::U64 => "non-negative integer (u64)", - SlotKind::Predicates => "list of { metric, cmp: gt|ge|lt|le, value }", - SlotKind::Strings => "list of strings", - SlotKind::Windows => "list of { from_ms, to_ms }", - SlotKind::Ref => "one of { content_id } | { identity_id }", - SlotKind::Axes => "map: param name -> { kind, values }", - SlotKind::EmitKinds => "list of: family_table | selection_report", - } -} - -/// The process-stage vocabulary (id + doc + slots per block). -pub fn process_vocabulary() -> &'static [BlockSchema] { - PROCESS_BLOCKS -} - -/// The campaign section vocabulary. -pub fn campaign_vocabulary() -> &'static [BlockSchema] { - CAMPAIGN_SECTIONS -} - -/// Describe one block by id, searching both vocabularies. -pub fn describe_block(id: &str) -> Option<&'static BlockSchema> { - PROCESS_BLOCKS - .iter() - .chain(CAMPAIGN_SECTIONS.iter()) - .find(|b| b.id == id) -} - -/// One open slot of a partial document. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct OpenSlot { - /// JSON-ish path, e.g. "process.ref" or "strategies[0].axes.slow". - pub path: String, - /// Short human hint, e.g. "required, one of: content_id, identity_id". - pub hint: String, -} - -fn open(path: impl Into, hint: impl Into) -> OpenSlot { - OpenSlot { path: path.into(), hint: hint.into() } -} - -/// List the open slots of a (possibly partial) process document. Only the -/// text being JSON is required; everything else missing is reported as open. -pub fn open_slots_process(text: &str) -> Result, DocError> { - let v: serde_json::Value = - serde_json::from_str(text).map_err(|e| DocError::NotJson(e.to_string()))?; - let mut slots = Vec::new(); - envelope_open_slots(&v, "process", &mut slots); - if v.get("name").and_then(|n| n.as_str()).is_none() { - slots.push(open("name", "required, string")); - } - match v.get("pipeline").and_then(|p| p.as_array()) { - None => slots.push(open("pipeline", "required, list of stage blocks")), - Some(stages) if stages.is_empty() => { - slots.push(open("pipeline", "declared empty — a process needs at least one stage")) - } - Some(stages) => { - for (i, stage) in stages.iter().enumerate() { - let Some(m) = stage.as_object() else { - slots.push(open(format!("pipeline[{i}]"), "must be a stage object")); - continue; - }; - let Some(id) = m.get("block").and_then(|b| b.as_str()) else { - slots.push(open(format!("pipeline[{i}].block"), "required, block id")); - continue; - }; - let Some(schema) = PROCESS_BLOCKS.iter().find(|b| b.id == id) else { - slots.push(open(format!("pipeline[{i}].block"), format!("unknown block \"{id}\""))); - continue; - }; - for s in schema.slots.iter().filter(|s| s.required) { - if !m.contains_key(s.name) { - slots.push(open( - format!("pipeline[{i}].{}", s.name), - format!("required, {}", slot_kind_label(s.kind)), - )); - } - } - } - } - } - Ok(slots) -} - -/// List the open slots of a (possibly partial) campaign document. -pub fn open_slots_campaign(text: &str) -> Result, DocError> { - let v: serde_json::Value = - serde_json::from_str(text).map_err(|e| DocError::NotJson(e.to_string()))?; - let mut slots = Vec::new(); - envelope_open_slots(&v, "campaign", &mut slots); - if v.get("name").and_then(|n| n.as_str()).is_none() { - slots.push(open("name", "required, string")); - } - match v.get("data") { - None => slots.push(open("data", "required section: instruments + windows")), - Some(d) => { - if d.get("instruments").and_then(|x| x.as_array()).map_or(true, |a| a.is_empty()) { - slots.push(open("data.instruments", "required, non-empty list of strings")); - } - if d.get("windows").and_then(|x| x.as_array()).map_or(true, |a| a.is_empty()) { - slots.push(open("data.windows", "required, non-empty list of { from_ms, to_ms }")); - } - } - } - match v.get("strategies").and_then(|s| s.as_array()) { - None => slots.push(open("strategies", "required, non-empty list of { ref, axes }")), - Some(list) if list.is_empty() => { - slots.push(open("strategies", "declared empty — a campaign needs at least one strategy")) - } - Some(list) => { - for (i, s) in list.iter().enumerate() { - if s.get("ref").is_none() { - slots.push(open( - format!("strategies[{i}].ref"), - "required, one of: content_id, identity_id", - )); - } - match s.get("axes").and_then(|a| a.as_object()) { - None => slots.push(open( - format!("strategies[{i}].axes"), - "required, map: param name -> { kind, values }", - )), - Some(axes) => { - for (axis, spec) in axes { - let empty = spec - .get("values") - .and_then(|c| c.as_array()) - .map_or(true, |c| c.is_empty()); - if empty { - slots.push(open( - format!("strategies[{i}].axes.{axis}"), - "axis declared empty — a P1 axis is a non-empty finite set", - )); - } - } - } - } - } - } - } - if v.get("process").and_then(|p| p.get("ref")).is_none() { - slots.push(open("process.ref", "required, one of: content_id, identity_id")); - } - if v.get("seed").and_then(|s| s.as_u64()).is_none() { - slots.push(open("seed", "required, non-negative integer")); - } - if v.get("presentation").is_none() { - slots.push(open("presentation", "required section: persist_taps + emit")); - } - Ok(slots) -} - -fn envelope_open_slots(v: &serde_json::Value, kind: &str, slots: &mut Vec) { - if v.get("format_version").and_then(|x| x.as_u64()) != Some(u64::from(FORMAT_VERSION)) { - slots.push(open("format_version", "required, must be 1")); - } - if v.get("kind").and_then(|k| k.as_str()) != Some(kind) { - slots.push(open("kind", format!("required, must be \"{kind}\""))); - } -} -``` - -(The `{ kind, values }` inside two hint strings is literal text inside a -normal string — no `format!` braces to escape there; the two `format!` -calls in this block interpolate only `{i}`/`{axis}`/`{id}`.) -Note: `open_slots_campaign`'s axes-`None` arm passes a literal string to -`open(...)`, not a `format!` — copy it as written. - -- [ ] **Step 2: Append the introspection tests** - -```rust - #[test] - fn vocabularies_enumerate_every_block_with_typed_slots() { - let ids: Vec<&str> = process_vocabulary().iter().map(|b| b.id).collect(); - assert_eq!( - ids, - vec!["std::sweep", "std::gate", "std::walk_forward", "std::monte_carlo", "std::generalize"] - ); - for b in process_vocabulary().iter().chain(campaign_vocabulary()) { - assert!(!b.doc.is_empty(), "block {} has no doc line", b.id); - assert!(!b.slots.is_empty(), "block {} has no slots", b.id); - for s in b.slots { - assert!(!slot_kind_label(s.kind).is_empty()); - } - } - let sweep = describe_block("std::sweep").expect("sweep describable"); - assert!(sweep.slots.iter().any(|s| s.name == "metric" && s.required)); - assert!(sweep.slots.iter().any(|s| s.name == "deflate" && !s.required)); - assert!(describe_block("std::strategy").is_some()); - assert!(describe_block("std::nope").is_none()); - } - - #[test] - fn open_slots_report_partial_documents_by_path() { - assert_eq!(open_slots_process(PROCESS_FIXTURE).unwrap(), Vec::new()); - assert_eq!(open_slots_campaign(CAMPAIGN_FIXTURE).unwrap(), Vec::new()); - - let partial = r#"{ "format_version": 1, "kind": "process", - "pipeline": [ { "block": "std::sweep", "metric": "sqn" } ] }"#; - let slots = open_slots_process(partial).unwrap(); - assert!(slots.iter().any(|s| s.path == "name")); - assert!(slots.iter().any(|s| s.path == "pipeline[0].select")); - assert!(!slots.iter().any(|s| s.path == "pipeline[0].metric")); - - let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft", - "data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] }, - "strategies": [ { "ref": { "content_id": "9f3a" }, - "axes": { "slow": { "kind": "I64", "values": [] } } } ], - "seed": 1, - "presentation": { "persist_taps": [], "emit": [] } }"#; - let slots = open_slots_campaign(draft).unwrap(); - assert!(slots.iter().any(|s| s.path == "process.ref" - && s.hint == "required, one of: content_id, identity_id")); - assert!(slots.iter().any(|s| s.path == "strategies[0].axes.slow" - && s.hint == "axis declared empty — a P1 axis is a non-empty finite set")); - - assert!(matches!(open_slots_process("nope"), Err(DocError::NotJson(_)))); - } -``` - -- [ ] **Step 3: Run the two tests** - -Run: `cargo test -p aura-research vocabularies_enumerate_every_block_with_typed_slots` -Expected: PASS - -Run: `cargo test -p aura-research open_slots_report_partial_documents_by_path` -Expected: PASS - -- [ ] **Step 4: Crate-level gate** - -Run: `cargo test -p aura-research` -Expected: PASS — 10 passed, 0 failed - ---- - -### Task 7: move the content-id primitive (CLI delegates; pins stay green untouched) - -**Files:** -- Modify: `crates/aura-cli/Cargo.toml:12-20` (add dependency) -- Modify: `crates/aura-cli/src/main.rs:2587-2591` (`content_id` body) - -- [ ] **Step 1: Add the dependency** - -In `crates/aura-cli/Cargo.toml` `[dependencies]`, beside the other `aura-*` -path deps: - -```toml -aura-research = { path = "../aura-research" } -``` - -- [ ] **Step 2: Delegate `content_id`** - -Read `crates/aura-cli/src/main.rs:2580-2600` first. Replace the BODY of -`fn content_id(canonical_json: &str) -> String` (signature and doc comment -stay byte-identical) with: - -```rust - aura_research::content_id_of(canonical_json) -``` - -Remove the fn-local `use sha2::{Digest, Sha256};` if it becomes unused — -`sha2` REMAINS a crate dependency (crates/aura-cli/src/project.rs:18,357 -`dylib_sha256` still uses it). `topology_hash` (main.rs:2595) is untouched. - -- [ ] **Step 3: The id pin set stays green UNTOUCHED (acceptance criterion 5)** - -Run: `cargo test -p aura-cli topology_hash_is_the_content_id_of_the_canonical_form` -Expected: PASS - -Run: `cargo test -p aura-cli content_id_is_stable_across_the_store_round_trip` -Expected: PASS - -Run: `cargo test -p aura-cli aura_sweep_persists_the_canonical_blueprint_keyed_by_topology_hash` -Expected: PASS (recomputes SHA-256 independently — the strongest byte-value -pin on the moved primitive) - -Run: `cargo test -p aura-cli graph_introspect_content_id_is_deterministic_and_distinguishes` -Expected: PASS - -- [ ] **Step 4: Clippy on the touched crate** - -Run: `cargo clippy -p aura-cli --all-targets -- -D warnings` -Expected: clean, 0 warnings - ---- - -### Task 8: registry document stores (blueprint-store pattern) - -**Files:** -- Modify: `crates/aura-registry/Cargo.toml` (add `aura-research` dep) -- Modify: `crates/aura-registry/src/lib.rs` (store methods + test) - -- [ ] **Step 1: Add the dependency** - -In `crates/aura-registry/Cargo.toml` `[dependencies]`: - -```toml -aura-research = { path = "../aura-research" } -``` - -- [ ] **Step 2: Append the document stores inside `impl Registry`, directly - after `get_blueprint` (~lib.rs:134)** - -The shipped store idiom (read lib.rs:98-134 first): `?` on fs calls -(`From` conversion), missing file → `Ok(None)`. - -```rust - /// The content-addressed document-store dir for one document kind — - /// a sibling of the runs store, like `blueprints/`. - fn doc_dir(&self, kind: &str) -> PathBuf { - self.path.with_file_name(kind) - } - - /// The single id→path mapping every document put/get routes through - /// (the `blueprint_path` write-one/read-one lockstep discipline). - fn doc_path(&self, kind: &str, content_id: &str) -> PathBuf { - self.doc_dir(kind).join(format!("{content_id}.json")) - } - - /// Content-addressed put: computes the content id from the canonical - /// bytes (unlike `put_blueprint`, whose caller owns the hash — document - /// callers always hold canonical bytes, so self-keying is safe here) - /// and writes `/.json`. Idempotent. - fn put_doc(&self, kind: &str, canonical_json: &str) -> Result { - let id = aura_research::content_id_of(canonical_json); - fs::create_dir_all(self.doc_dir(kind))?; - fs::write(self.doc_path(kind, &id), canonical_json)?; - Ok(id) - } - - /// Read a stored document by content id; `Ok(None)` if absent (the - /// `get_blueprint` treat-as-empty discipline). - fn get_doc(&self, kind: &str, content_id: &str) -> Result, RegistryError> { - match fs::read_to_string(self.doc_path(kind, content_id)) { - Ok(s) => Ok(Some(s)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(RegistryError::Io(e)), - } - } - - /// Store a canonical process document; returns its content id. - pub fn put_process(&self, canonical_json: &str) -> Result { - self.put_doc("processes", canonical_json) - } - - /// Load a stored process document by content id (`Ok(None)` if absent). - pub fn get_process(&self, content_id: &str) -> Result, RegistryError> { - self.get_doc("processes", content_id) - } - - /// Store a canonical campaign document; returns its content id. - pub fn put_campaign(&self, canonical_json: &str) -> Result { - self.put_doc("campaigns", canonical_json) - } - - /// Load a stored campaign document by content id (`Ok(None)` if absent). - pub fn get_campaign(&self, content_id: &str) -> Result, RegistryError> { - self.get_doc("campaigns", content_id) - } - - /// The store path a process document with this content id lives at — - /// the same single mapping the put/get pair routes through, exposed so - /// consumers never re-derive the layout. - pub fn process_path(&self, content_id: &str) -> PathBuf { - self.doc_path("processes", content_id) - } - - /// The store path a campaign document with this content id lives at. - pub fn campaign_path(&self, content_id: &str) -> PathBuf { - self.doc_path("campaigns", content_id) - } -``` - -If the shipped `put_blueprint`/`get_blueprint` map errors differently than -shown (read them), mirror THAT idiom verbatim. - -- [ ] **Step 3: Append the store round-trip test in the registry tests - module, beside `blueprint_store_round_trips_by_content_id` (~:704), - using the module's own `temp_family_dir` helper** - -```rust - #[test] - fn document_stores_round_trip_by_content_id() { - let reg = Registry::open(temp_family_dir("document_store_round_trip")); - let process = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#; - let campaign = r#"{"format_version":1,"kind":"campaign","name":"c"}"#; - let pid = reg.put_process(process).expect("put process"); - assert_eq!(pid, aura_research::content_id_of(process)); - assert_eq!(reg.get_process(&pid).expect("get process"), Some(process.to_string())); - let cid = reg.put_campaign(campaign).expect("put campaign"); - assert_eq!(reg.get_campaign(&cid).expect("get campaign"), Some(campaign.to_string())); - assert_ne!(pid, cid); - // absent id is Ok(None), not an error (treat-as-empty discipline) - assert_eq!(reg.get_process("never-written").expect("get"), None); - } -``` - -(Storage is content-addressed bytes; document validity is the caller's -gate — the minimal campaign line above is deliberately not a valid -campaign document.) - -- [ ] **Step 4: Run the test** - -Run: `cargo test -p aura-registry document_stores_round_trip_by_content_id` -Expected: PASS - ---- - -### Task 9: referential tier — `validate_campaign_refs` - -**Files:** -- Modify: `crates/aura-registry/src/lib.rs` (RefFault + methods + test) -- Modify: `crates/aura-registry/Cargo.toml` (dev-deps if absent) - -- [ ] **Step 1: Append `RefFault` and the methods (same file, after the - Task-8 block)** - -Imports: extend the crate's existing `use` block — `aura_research::{Axis, -CampaignDoc, DocRef}` plus `blueprint_from_json`, `blueprint_identity_json` -and `PrimitiveBuilder` from the same paths the crate/engine re-exports -already provide (read the current `use` block and -crates/aura-engine/src/lib.rs:61 first; `PrimitiveBuilder` comes from -aura-core, re-exported — import it the way crates/aura-cli/src/project.rs -does). - -```rust -/// Referential-validation findings for a campaign document. By-identifier -/// and Display-free (the CLI phrases them). -#[derive(Clone, Debug, PartialEq)] -pub enum RefFault { - ProcessNotFound(String), - StrategyNotFound(String), - IdentityUnmatched(String), - StrategyUnloadable { id: String, error: String }, - AxisNotInParamSpace { strategy: String, axis: String }, - AxisKindMismatch { strategy: String, axis: String }, -} - -impl Registry { - /// Referential tier: resolve the campaign's references against the - /// project's stores and check each axis (name AND declared kind) against - /// the referenced strategy's param space. IO faults are Errors; semantic - /// findings are RefFaults. - pub fn validate_campaign_refs( - &self, - doc: &CampaignDoc, - resolve: &dyn Fn(&str) -> Option, - ) -> Result, RegistryError> { - let mut faults = Vec::new(); - - if let DocRef::ContentId(id) = &doc.process.r#ref { - if self.get_process(id)?.is_none() { - faults.push(RefFault::ProcessNotFound(id.clone())); - } - } - - for entry in &doc.strategies { - let (label, blueprint_json) = match &entry.r#ref { - DocRef::ContentId(id) => match self.get_blueprint(id)? { - Some(json) => (id.clone(), Some(json)), - None => { - faults.push(RefFault::StrategyNotFound(id.clone())); - (id.clone(), None) - } - }, - DocRef::IdentityId(id) => match self.find_blueprint_by_identity(id, resolve)? { - Some(json) => (id.clone(), Some(json)), - None => { - faults.push(RefFault::IdentityUnmatched(id.clone())); - (id.clone(), None) - } - }, - }; - let Some(json) = blueprint_json else { continue }; - let composite = match blueprint_from_json(&json, resolve) { - Ok(c) => c, - Err(e) => { - faults.push(RefFault::StrategyUnloadable { - id: label.clone(), - error: format!("{e:?}"), - }); - continue; - } - }; - let space = composite.param_space(); - for (axis, ax) in &entry.axes { - match space.iter().find(|p| &p.name == axis) { - None => faults.push(RefFault::AxisNotInParamSpace { - strategy: label.clone(), - axis: axis.clone(), - }), - Some(spec) => { - if ax.kind != spec.kind { - faults.push(RefFault::AxisKindMismatch { - strategy: label.clone(), - axis: axis.clone(), - }); - } - } - } - } - } - Ok(faults) - } - - /// Scan the blueprint store for a blueprint whose identity id matches. - fn find_blueprint_by_identity( - &self, - identity_id: &str, - resolve: &dyn Fn(&str) -> Option, - ) -> Result, RegistryError> { - let entries = match fs::read_dir(self.blueprints_dir()) { - Ok(e) => e, - Err(_) => return Ok(None), // no store yet -> nothing matches - }; - for entry in entries { - let entry = entry?; - let json = fs::read_to_string(entry.path())?; - let Ok(composite) = blueprint_from_json(&json, resolve) else { continue }; - let Ok(identity_json) = blueprint_identity_json(&composite) else { continue }; - if aura_research::content_id_of(&identity_json) == identity_id { - return Ok(Some(json)); - } - } - Ok(None) - } -} -``` - -(The unused-import warning for `Axis` will fire if it ends up unreferenced -— drop it from the import list in that case; `ax.kind` needs no named -import.) - -- [ ] **Step 2: Dev-deps for the fixture, if absent** - -In `crates/aura-registry/Cargo.toml` `[dev-dependencies]` add if missing: - -```toml -aura-std = { path = "../aura-std" } -``` - -(NOT aura-composites: its shipped composites — vol_stop, risk_executor* — -all route through `LinComb`, whose builder needs a structural arity -argument and is therefore deliberately absent from the zero-arg -`std_vocabulary` roster, so none of them can round-trip through -`blueprint_from_json` with the by-type-name resolver this test uses. The -fixture below hand-builds a minimal loadable composite instead.) - -- [ ] **Step 3: Append the referential test (tests module). The fixture is - GENERIC over the composite's real param space — no hardcoded param names** - -```rust - #[test] - fn referential_tier_resolves_refs_and_checks_axes() { - use aura_engine::{blueprint_to_json, Composite}; - use aura_research::{Axis, DocRef}; - - let reg = Registry::open(temp_family_dir("referential_tier")); - let resolve = |t: &str| aura_std::std_vocabulary(t); - - // A minimal fixture composite with one OPEN param, built from a - // zero-arg `aura-std` node (`Bias`) so `std_vocabulary` can actually - // resolve it on load (see Step 2's note on why no aura-composites - // composite qualifies). - let composite = Composite::new( - "fixture", - vec![aura_std::Bias::builder().named("b").into()], - vec![], - vec![], - vec![], - ); - let space = composite.param_space(); - let real = space.first().expect("fixture composite has an open param"); - let real_name = real.name.clone(); - let real_kind = real.kind; - - let blueprint_json = blueprint_to_json(&composite).expect("serializes"); - let bp_id = aura_research::content_id_of(&blueprint_json); - reg.put_blueprint(&bp_id, &blueprint_json).expect("seed blueprint"); - let process = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#; - let proc_id = reg.put_process(process).expect("seed process"); - - // one kind-correct bare value for the real param - let bare = match real_kind { - aura_core::ScalarKind::I64 => "4", - aura_core::ScalarKind::F64 => "1.5", - aura_core::ScalarKind::Bool => "true", - aura_core::ScalarKind::Timestamp => "0", - }; - let kind_tag = format!("{real_kind:?}"); // unit-variant Debug == serde string - let campaign_text = format!( - concat!( - r#"{{"format_version":1,"kind":"campaign","name":"c","#, - r#""data":{{"instruments":["GER40"],"windows":[{{"from_ms":1,"to_ms":2}}]}},"#, - r#""strategies":[{{"ref":{{"content_id":"{bp}"}},"#, - r#""axes":{{"{axis}":{{"kind":"{kind}","values":[{val}]}}}}}}],"#, - r#""process":{{"ref":{{"content_id":"{proc}"}}}},"#, - r#""seed":1,"presentation":{{"persist_taps":[],"emit":["family_table"]}}}}"# - ), - bp = bp_id, - axis = real_name, - kind = kind_tag, - val = bare, - proc = proc_id, - ); - let doc = aura_research::parse_campaign(&campaign_text).expect("campaign parses"); - assert_eq!(reg.validate_campaign_refs(&doc, &resolve).expect("io ok"), Vec::new()); - - // unknown process + unknown strategy ids - let mut missing = doc.clone(); - missing.process.r#ref = DocRef::ContentId("00".into()); - missing.strategies[0].r#ref = DocRef::ContentId("11".into()); - let faults = reg.validate_campaign_refs(&missing, &resolve).expect("io ok"); - assert!(faults.contains(&RefFault::ProcessNotFound("00".into()))); - assert!(faults.contains(&RefFault::StrategyNotFound("11".into()))); - - // unknown axis name - let mut bad_axis = doc.clone(); - let ax = bad_axis.strategies[0].axes.remove(&real_name).unwrap(); - bad_axis.strategies[0].axes.insert("nope".into(), ax); - assert!(reg - .validate_campaign_refs(&bad_axis, &resolve) - .expect("io ok") - .iter() - .any(|f| matches!(f, RefFault::AxisNotInParamSpace { axis, .. } if axis == "nope"))); - - // declared kind != the param's kind - let wrong_kind = if matches!(real_kind, aura_core::ScalarKind::I64) { - aura_core::ScalarKind::F64 - } else { - aura_core::ScalarKind::I64 - }; - let mut mismatched = doc.clone(); - // values were parsed under the right kind; re-kind the axis only — - // the check compares DECLARED kind vs the param's kind - let kept_values = mismatched.strategies[0].axes[&real_name].values.clone(); - mismatched.strategies[0].axes.insert( - real_name.clone(), - Axis { kind: wrong_kind, values: kept_values }, - ); - assert!(reg - .validate_campaign_refs(&mismatched, &resolve) - .expect("io ok") - .iter() - .any(|f| matches!(f, RefFault::AxisKindMismatch { axis, .. } if axis == &real_name))); - - // identity ref resolves via the store scan - let identity_json = blueprint_identity_json(&composite).expect("identity form"); - let identity_id = aura_research::content_id_of(&identity_json); - let mut by_identity = doc.clone(); - by_identity.strategies[0].r#ref = DocRef::IdentityId(identity_id); - assert_eq!(reg.validate_campaign_refs(&by_identity, &resolve).expect("io ok"), Vec::new()); - } -``` - -Anchor notes: `blueprint_to_json`/`blueprint_identity_json` — import from -the engine re-export the way the crate already imports engine items; if -`aura_core::ScalarKind`/`Scalar` are not yet dev-visible, they come through -the existing `aura-engine` dependency's re-exports or a direct -`aura-core` path dep (mirror whichever the crate already uses). The test -is generic and needs only ONE open param from the fixture composite. - -- [ ] **Step 4: Run the test** - -Run: `cargo test -p aura-registry referential_tier_resolves_refs_and_checks_axes` -Expected: PASS - -- [ ] **Step 5: Registry package gate** - -Run: `cargo test -p aura-registry` -Expected: PASS, 0 failed - ---- - -### Task 10: CLI verb family `aura process` - -**Files:** -- Create: `crates/aura-cli/src/research_docs.rs` -- Modify: `crates/aura-cli/src/main.rs` (mod line :14-17; `Command` enum - :3743-3765; clap structs near :3792-3823; dispatch arm :4702-4713 — ALL - in this task) -- Create: `crates/aura-cli/tests/research_docs.rs` - -- [ ] **Step 1: Read the templates** - -Run: `sed -n '3730,3830p;4335,4341p;4700,4715p' crates/aura-cli/src/main.rs` -Expected: the `Command` enum + clap-struct idiom, `dispatch_graph`'s shape, -and the match arms. The new arms follow `Command::Graph(a) => -dispatch_graph(a, &env)` exactly (by-value cmd, `&env`). - -- [ ] **Step 2: Create `crates/aura-cli/src/research_docs.rs`** - -```rust -//! CLI surface for the research-artifact documents: the `aura process` and -//! `aura campaign` verb families. Presentation layer only — -//! parsing/validation/introspection live in aura-research; stores and the -//! referential tier in aura-registry. - -use std::path::PathBuf; - -use aura_research::{ - describe_block, open_slots_process, parse_process, process_to_json, process_vocabulary, - slot_kind_label, validate_process, DocError, DocFault, DocKind, ProcessDoc, StageBlock, -}; - -use crate::project::Env; - -#[derive(clap::Args)] -pub struct ProcessCmd { - #[command(subcommand)] - pub sub: ProcessSub, -} - -#[derive(clap::Subcommand)] -pub enum ProcessSub { - /// Validate a process document (intrinsic tier) - Validate { file: PathBuf }, - /// Introspect the process vocabulary or a document - Introspect(DocIntrospectCmd), - /// Register a valid process document into the store under the runs root - Register { file: PathBuf }, -} - -#[derive(clap::Args)] -pub struct DocIntrospectCmd { - /// List the block vocabulary - #[arg(long)] - pub vocabulary: bool, - /// Describe one block's typed slots - #[arg(long, value_name = "ID")] - pub block: Option, - /// List the open slots of a (partial) document - #[arg(long, value_name = "FILE")] - pub unwired: Option, - /// Print the content id of a valid document - #[arg(long, value_name = "FILE")] - pub content_id: Option, -} - -impl DocIntrospectCmd { - fn selected_modes(&self) -> usize { - usize::from(self.vocabulary) - + usize::from(self.block.is_some()) - + usize::from(self.unwired.is_some()) - + usize::from(self.content_id.is_some()) - } -} - -/// Exactly one introspect mode (the graph-introspect guard idiom: usage -/// error on stderr, exit 2). -fn guard_one_mode(cmd: &DocIntrospectCmd, family: &str) { - if cmd.selected_modes() != 1 { - eprintln!( - "aura: Usage: aura {family} introspect --vocabulary | --block | --unwired | --content-id " - ); - std::process::exit(2); - } -} - -fn doc_error_prose(what: &str, e: &DocError) -> String { - match e { - DocError::NotJson(msg) => format!("{what} is not JSON: {msg}"), - DocError::BadFormatVersion(v) => { - format!("{what}: unsupported format_version {v} (expected 1)") - } - DocError::WrongKind { expected } => { - let want = match expected { - DocKind::Process => "process", - DocKind::Campaign => "campaign", - }; - format!("{what}: wrong document kind (expected \"{want}\")") - } - DocError::Malformed(msg) => format!("{what}: {msg}"), - } -} - -fn doc_fault_prose(f: &DocFault) -> String { - match f { - DocFault::EmptyPipeline => "the pipeline is empty — a process needs at least one stage".into(), - DocFault::UnknownMetric { stage, metric } => { - format!("stage {stage}: unknown metric \"{metric}\"") - } - DocFault::EmptyConjunction { stage } => { - format!("stage {stage}: a gate needs at least one predicate") - } - DocFault::GateFirst => "stage 0: a gate cannot open the pipeline (nothing to filter yet)".into(), - DocFault::GateAfterTerminalStage { stage } => { - format!("stage {stage}: a gate cannot follow a terminal stage (monte_carlo/generalize)") - } - DocFault::EmptyInstruments => "data.instruments is empty".into(), - DocFault::NoWindow => "data.windows is empty".into(), - DocFault::BadWindow { index } => { - format!("data.windows[{index}]: from_ms must be earlier than to_ms") - } - DocFault::NoStrategy => "strategies is empty".into(), - DocFault::EmptyAxes { strategy } => format!("strategies[{strategy}]: axes is empty"), - DocFault::EmptyAxis { strategy, axis } => { - format!("strategies[{strategy}].axes.{axis}: an axis is a non-empty finite set") - } - DocFault::UnknownEmitKind(kind) => format!("presentation.emit: unknown kind \"{kind}\""), - DocFault::ProcessRefMustBeContentId => { - "process.ref: a process is referenced by content id in this version".into() - } - } -} - -fn read_doc(file: &PathBuf) -> Result { - std::fs::read_to_string(file).map_err(|e| format!("cannot read {}: {e}", file.display())) -} - -fn fault_block(header: &str, lines: Vec) -> String { - format!("{header}\n {}", lines.join("\n ")) -} - -/// `aura process …` — the dispatch entry main.rs calls. -pub fn process_cmd(cmd: ProcessCmd, env: &Env) { - let result = match &cmd.sub { - ProcessSub::Validate { file } => validate_process_file(file), - ProcessSub::Introspect(i) => { - guard_one_mode(i, "process"); - introspect_process(i) - } - ProcessSub::Register { file } => register_process(file, env), - }; - if let Err(m) = result { - eprintln!("aura: {m}"); - std::process::exit(1); - } -} - -fn parse_valid_process(file: &PathBuf) -> Result { - let text = read_doc(file)?; - let doc = parse_process(&text).map_err(|e| doc_error_prose("process document", &e))?; - let faults = validate_process(&doc); - if !faults.is_empty() { - return Err(fault_block( - "process document invalid:", - faults.iter().map(doc_fault_prose).collect(), - )); - } - Ok(doc) -} - -fn validate_process_file(file: &PathBuf) -> Result<(), String> { - let doc = parse_valid_process(file)?; - let gates: usize = doc - .pipeline - .iter() - .map(|s| match s { - StageBlock::Gate { all } => all.len(), - _ => 0, - }) - .sum(); - println!( - "process document valid (intrinsic): {} pipeline blocks, {} gate predicates", - doc.pipeline.len(), - gates - ); - Ok(()) -} - -fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> { - let doc = parse_valid_process(file) - .map_err(|m| format!("refusing to register: {m}"))?; - let canonical = process_to_json(&doc); - let registry = env.registry(); - let id = registry.put_process(&canonical).map_err(|e| e.to_string())?; - println!( - "registered process content:{id} ({})", - registry.process_path(&id).display() - ); - Ok(()) -} - -fn introspect_process(cmd: &DocIntrospectCmd) -> Result<(), String> { - if cmd.vocabulary { - for b in process_vocabulary() { - println!("{:<18} {}", b.id, b.doc); - } - return Ok(()); - } - if let Some(id) = &cmd.block { - return describe_one_block(id); - } - if let Some(file) = &cmd.unwired { - let text = read_doc(file)?; - let slots = - open_slots_process(&text).map_err(|e| doc_error_prose("process document", &e))?; - print_open_slots(&slots); - return Ok(()); - } - if let Some(file) = &cmd.content_id { - let doc = parse_valid_process(file) - .map_err(|m| format!("an invalid document has no canonical form — {m}"))?; - println!("content:{}", aura_research::content_id_of(&process_to_json(&doc))); - return Ok(()); - } - unreachable!("guard_one_mode enforces one mode") -} - -fn describe_one_block(id: &str) -> Result<(), String> { - let schema = describe_block(id).ok_or_else(|| format!("unknown block \"{id}\""))?; - println!("{} — {}", schema.id, schema.doc); - for s in schema.slots { - let req = if s.required { "required" } else { "optional" }; - println!(" {:<20} {req}, {}", s.name, slot_kind_label(s.kind)); - } - Ok(()) -} - -fn print_open_slots(slots: &[aura_research::OpenSlot]) { - for s in slots { - println!("open slot: {} ({})", s.path, s.hint); - } - if slots.is_empty() { - println!("no open slots"); - } -} -``` - -- [ ] **Step 3: Wire main.rs — mod, enum variant, dispatch arm (one task, - compile-gate discipline)** - -1. `mod` block (:14-17): add `mod research_docs;` -2. `Command` enum (:3743-3765), beside `Graph`: - -```rust - /// Validate, introspect, and register process documents (methodology) - Process(research_docs::ProcessCmd), -``` - -3. The main dispatch match (:4702-4713): - -```rust - Command::Process(a) => research_docs::process_cmd(a, &env), -``` - -- [ ] **Step 4: Build gate** - -Run: `cargo build -p aura-cli` -Expected: 0 errors - -- [ ] **Step 5: Create `crates/aura-cli/tests/research_docs.rs`** - -Copy ONLY the exit-code helper from -`crates/aura-cli/tests/graph_construct.rs` (the `run_code` fn over -`const BIN = env!("CARGO_BIN_EXE_aura")`, ~:31; do not copy helpers this -file will not call — dead test code fails clippy), add a cwd variant, then -the tests: - -```rust -use std::path::Path; - -fn run_code_in(dir: &Path, args: &[&str]) -> (String, Option) { - let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(args) - .current_dir(dir) - .output() - .expect("binary runs"); - let text = format!( - "{}{}", - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) - ); - (text, out.status.code()) -} - -fn run_code(args: &[&str]) -> (String, Option) { - run_code_in(Path::new("."), args) -} - -const PROCESS_DOC: &str = r#"{ - "format_version": 1, - "kind": "process", - "name": "wf-deflated-screen", - "pipeline": [ - { "block": "std::sweep", "metric": "net_expectancy_r", "select": "plateau:worst", "deflate": true }, - { "block": "std::gate", "all": [ - { "metric": "net_expectancy_r", "cmp": "gt", "value": 0.0 }, - { "metric": "overfit_probability", "cmp": "lt", "value": 0.1 } ] }, - { "block": "std::walk_forward", "folds": 4, "in_sample_bars": 4000, - "out_of_sample_bars": 1000, "metric": "net_expectancy_r", "select": "argmax" } - ] -}"#; - -fn write_doc(dir: &Path, name: &str, text: &str) -> std::path::PathBuf { - let p = dir.join(name); - std::fs::write(&p, text).expect("write doc"); - p -} - -#[test] -fn process_validate_reports_intrinsic_ok() { - let dir = tempfile::tempdir().expect("tempdir"); - write_doc(dir.path(), "p.process.json", PROCESS_DOC); - let (out, code) = run_code_in(dir.path(), &["process", "validate", "p.process.json"]); - assert_eq!(code, Some(0), "stdout/stderr: {out}"); - assert!(out.contains("process document valid (intrinsic): 3 pipeline blocks, 2 gate predicates")); -} - -#[test] -fn process_validate_refuses_unknown_metric_as_prose_exit_1() { - let dir = tempfile::tempdir().expect("tempdir"); - let bad = PROCESS_DOC.replacen( - "net_expectancy_r\", \"select\": \"plateau:worst", - "netto_r\", \"select\": \"plateau:worst", - 1, - ); - write_doc(dir.path(), "bad.process.json", &bad); - let (out, code) = run_code_in(dir.path(), &["process", "validate", "bad.process.json"]); - assert_eq!(code, Some(1)); - assert!(out.contains("unknown metric \"netto_r\"")); - assert!(!out.contains("UnknownMetric"), "Debug leak: {out}"); -} - -#[test] -fn process_introspect_vocabulary_block_and_content_id() { - let (out, code) = run_code(&["process", "introspect", "--vocabulary"]); - assert_eq!(code, Some(0)); - for id in ["std::sweep", "std::gate", "std::walk_forward", "std::monte_carlo", "std::generalize"] { - assert!(out.contains(id), "vocabulary misses {id}: {out}"); - } - let (out, code) = run_code(&["process", "introspect", "--block", "std::sweep"]); - assert_eq!(code, Some(0)); - assert!(out.contains("metric")); - assert!(out.contains("required")); - - let dir = tempfile::tempdir().expect("tempdir"); - write_doc(dir.path(), "p.process.json", PROCESS_DOC); - let (out, code) = run_code_in(dir.path(), &["process", "introspect", "--content-id", "p.process.json"]); - assert_eq!(code, Some(0), "stdout/stderr: {out}"); - let line = out.lines().find(|l| l.starts_with("content:")).expect("id line"); - assert_eq!(line.len(), "content:".len() + 64); -} - -#[test] -fn process_introspect_unwired_lists_open_slots() { - let dir = tempfile::tempdir().expect("tempdir"); - let partial = r#"{ "format_version": 1, "kind": "process", - "pipeline": [ { "block": "std::sweep", "metric": "sqn" } ] }"#; - write_doc(dir.path(), "partial.process.json", partial); - let (out, code) = run_code_in(dir.path(), &["process", "introspect", "--unwired", "partial.process.json"]); - assert_eq!(code, Some(0)); - assert!(out.contains("open slot: name")); - assert!(out.contains("open slot: pipeline[0].select")); -} - -#[test] -fn process_introspect_no_flag_is_usage_exit_2() { - let (_out, code) = run_code(&["process", "introspect"]); - assert_eq!(code, Some(2)); -} - -#[test] -fn process_register_stores_content_addressed_under_runs_root() { - let dir = tempfile::tempdir().expect("tempdir"); - write_doc(dir.path(), "p.process.json", PROCESS_DOC); - let (out, code) = run_code_in(dir.path(), &["process", "register", "p.process.json"]); - assert_eq!(code, Some(0), "stdout/stderr: {out}"); - let line = out.lines().find(|l| l.starts_with("registered process content:")).expect("line"); - let id = line - .trim_start_matches("registered process content:") - .split(' ') - .next() - .expect("id"); - assert!(dir.path().join("runs").join("processes").join(format!("{id}.json")).is_file()); -} -``` - -Add `tempfile` under aura-cli `[dev-dependencies]` only if absent (check -the manifest first). - -- [ ] **Step 6: Run the seam tests** - -Run: `cargo test -p aura-cli --test research_docs` -Expected: PASS — 6 passed, 0 failed - ---- - -### Task 11: CLI verb family `aura campaign` - -**Files:** -- Modify: `crates/aura-cli/src/research_docs.rs` (campaign half + prose unit test) -- Modify: `crates/aura-cli/src/main.rs` (`Command::Campaign` + arm, same task) -- Modify: `crates/aura-cli/tests/research_docs.rs` (append) - -- [ ] **Step 1: Append the campaign half to research_docs.rs** - -Extend the aura-research import with `campaign_to_json, campaign_vocabulary, -open_slots_campaign, parse_campaign, validate_campaign, CampaignDoc`, and -add `use aura_registry::RefFault;`. - -```rust -#[derive(clap::Args)] -pub struct CampaignCmd { - #[command(subcommand)] - pub sub: CampaignSub, -} - -#[derive(clap::Subcommand)] -pub enum CampaignSub { - /// Validate a campaign document (intrinsic + referential inside a project) - Validate { file: PathBuf }, - /// Introspect the campaign vocabulary or a document - Introspect(DocIntrospectCmd), - /// Register a valid campaign document into the store under the runs root - Register { file: PathBuf }, -} - -fn ref_fault_prose(f: &RefFault) -> String { - match f { - RefFault::ProcessNotFound(id) => format!("process {id} not found in the project store"), - RefFault::StrategyNotFound(id) => format!("strategy {id} not found in the blueprint store"), - RefFault::IdentityUnmatched(id) => format!("identity id {id} matches no stored blueprint"), - RefFault::StrategyUnloadable { id, error } => { - format!("strategy {id} cannot be loaded: {error}") - } - RefFault::AxisNotInParamSpace { strategy, axis } => { - format!("strategy {strategy}: axis \"{axis}\" is not in the param space") - } - RefFault::AxisKindMismatch { strategy, axis } => { - format!("strategy {strategy}: axis \"{axis}\" declares a kind that is not the param's kind") - } - } -} - -/// `aura campaign …` — the dispatch entry main.rs calls. -pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) { - let result = match &cmd.sub { - CampaignSub::Validate { file } => validate_campaign_file(file, env), - CampaignSub::Introspect(i) => { - guard_one_mode(i, "campaign"); - introspect_campaign(i) - } - CampaignSub::Register { file } => register_campaign(file, env), - }; - if let Err(m) = result { - eprintln!("aura: {m}"); - std::process::exit(1); - } -} - -fn parse_valid_campaign(file: &PathBuf) -> Result { - let text = read_doc(file)?; - let doc = parse_campaign(&text).map_err(|e| doc_error_prose("campaign document", &e))?; - let faults = validate_campaign(&doc); - if !faults.is_empty() { - return Err(fault_block( - "campaign document invalid:", - faults.iter().map(doc_fault_prose).collect(), - )); - } - Ok(doc) -} - -fn validate_campaign_file(file: &PathBuf, env: &Env) -> Result<(), String> { - let doc = parse_valid_campaign(file)?; - let axes: usize = doc.strategies.iter().map(|s| s.axes.len()).sum(); - let points: usize = doc - .strategies - .iter() - .map(|s| s.axes.values().map(|a| a.values.len()).product::()) - .sum(); - println!( - "campaign document valid (intrinsic): {} strategy(ies), {} axes ({} points), process ref, {} window(s)", - doc.strategies.len(), - axes, - points, - doc.data.windows.len() - ); - if env.provenance().is_some() { - let resolve = |t: &str| env.resolve(t); - let faults = env - .registry() - .validate_campaign_refs(&doc, &resolve) - .map_err(|e| e.to_string())?; - if !faults.is_empty() { - return Err(fault_block( - "campaign references do not resolve:", - faults.iter().map(ref_fault_prose).collect(), - )); - } - println!("campaign document valid (referential): all references resolve, axes are in the param space"); - } else { - let cwd = std::env::current_dir() - .map(|d| d.display().to_string()) - .unwrap_or_default(); - println!("referential checks skipped (no Aura.toml found up from {cwd})"); - } - Ok(()) -} - -fn register_campaign(file: &PathBuf, env: &Env) -> Result<(), String> { - let doc = parse_valid_campaign(file) - .map_err(|m| format!("refusing to register: {m}"))?; - let canonical = campaign_to_json(&doc); - let registry = env.registry(); - let id = registry.put_campaign(&canonical).map_err(|e| e.to_string())?; - println!( - "registered campaign content:{id} ({})", - registry.campaign_path(&id).display() - ); - Ok(()) -} - -fn introspect_campaign(cmd: &DocIntrospectCmd) -> Result<(), String> { - if cmd.vocabulary { - for b in campaign_vocabulary() { - println!("{:<18} {}", b.id, b.doc); - } - return Ok(()); - } - if let Some(id) = &cmd.block { - return describe_one_block(id); - } - if let Some(file) = &cmd.unwired { - let text = read_doc(file)?; - let slots = - open_slots_campaign(&text).map_err(|e| doc_error_prose("campaign document", &e))?; - print_open_slots(&slots); - return Ok(()); - } - if let Some(file) = &cmd.content_id { - let doc = parse_valid_campaign(file) - .map_err(|m| format!("an invalid document has no canonical form — {m}"))?; - println!("content:{}", aura_research::content_id_of(&campaign_to_json(&doc))); - return Ok(()); - } - unreachable!("guard_one_mode enforces one mode") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ref_fault_prose_is_debug_free() { - let cases = [ - ref_fault_prose(&RefFault::ProcessNotFound("4e2d".into())), - ref_fault_prose(&RefFault::StrategyNotFound("9f3a".into())), - ref_fault_prose(&RefFault::AxisKindMismatch { - strategy: "9f3a".into(), - axis: "fast".into(), - }), - ]; - assert_eq!(cases[0], "process 4e2d not found in the project store"); - assert_eq!(cases[1], "strategy 9f3a not found in the blueprint store"); - assert!(cases[2].contains("declares a kind that is not the param's kind")); - for c in &cases { - assert!(!c.contains("NotFound") && !c.contains("Mismatch"), "Debug leak: {c}"); - } - } -} -``` - -(The referential CLI path inside a real loadable project is exercised by -the milestone fieldtest, not this cycle's seam tests — `project::load` -requires a built project dylib, which is out of scope for a unit fixture. -The referential LOGIC is pinned in Task 9's registry tests; the prose in -the unit test above.) - -- [ ] **Step 2: Wire `Command::Campaign` in main.rs (enum variant + - dispatch arm, same task)** - -```rust - /// Validate, introspect, and register campaign documents (experiment intent) - Campaign(research_docs::CampaignCmd), -``` - -```rust - Command::Campaign(a) => research_docs::campaign_cmd(a, &env), -``` - -- [ ] **Step 3: Build gate** - -Run: `cargo build -p aura-cli` -Expected: 0 errors - -- [ ] **Step 4: Append the campaign seam tests to tests/research_docs.rs** - -```rust -const CAMPAIGN_DOC: &str = r#"{ - "format_version": 1, - "kind": "campaign", - "name": "screen", - "data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] }, - "strategies": [ { "ref": { "content_id": "9f3a" }, - "axes": { "fast": { "kind": "I64", "values": [8] } } } ], - "process": { "ref": { "content_id": "4e2d" } }, - "seed": 1, - "presentation": { "persist_taps": [], "emit": ["family_table"] } -}"#; - -#[test] -fn campaign_validate_outside_project_skips_referential_tier() { - let dir = tempfile::tempdir().expect("tempdir"); - write_doc(dir.path(), "c.campaign.json", CAMPAIGN_DOC); - let (out, code) = run_code_in(dir.path(), &["campaign", "validate", "c.campaign.json"]); - assert_eq!(code, Some(0), "stdout/stderr: {out}"); - assert!(out.contains("campaign document valid (intrinsic):")); - assert!(out.contains("referential checks skipped (no Aura.toml found up from ")); -} - -#[test] -fn campaign_validate_refuses_empty_axis_prose_exit_1() { - let dir = tempfile::tempdir().expect("tempdir"); - let bad = CAMPAIGN_DOC.replacen(r#""values": [8]"#, r#""values": []"#, 1); - write_doc(dir.path(), "bad.campaign.json", &bad); - let (out, code) = run_code_in(dir.path(), &["campaign", "validate", "bad.campaign.json"]); - assert_eq!(code, Some(1)); - assert!(out.contains("axes.fast: an axis is a non-empty finite set")); - assert!(!out.contains("EmptyAxis"), "Debug leak: {out}"); -} - -#[test] -fn campaign_introspect_vocabulary_lists_sections() { - let (out, code) = run_code(&["campaign", "introspect", "--vocabulary"]); - assert_eq!(code, Some(0)); - for id in ["std::data", "std::strategy", "std::process_ref", "std::presentation"] { - assert!(out.contains(id), "vocabulary misses {id}: {out}"); - } -} - -#[test] -fn campaign_introspect_unwired_reports_the_spec_example_slots() { - let dir = tempfile::tempdir().expect("tempdir"); - let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft", - "data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] }, - "strategies": [ { "ref": { "content_id": "9f3a" }, - "axes": { "slow": { "kind": "I64", "values": [] } } } ], - "seed": 1, - "presentation": { "persist_taps": [], "emit": [] } }"#; - write_doc(dir.path(), "draft.campaign.json", draft); - let (out, code) = run_code_in(dir.path(), &["campaign", "introspect", "--unwired", "draft.campaign.json"]); - assert_eq!(code, Some(0)); - assert!(out.contains("open slot: process.ref (required, one of: content_id, identity_id)")); - assert!(out.contains("open slot: strategies[0].axes.slow (axis declared empty — a P1 axis is a non-empty finite set)")); -} - -#[test] -fn campaign_register_stores_content_addressed_under_runs_root() { - let dir = tempfile::tempdir().expect("tempdir"); - write_doc(dir.path(), "c.campaign.json", CAMPAIGN_DOC); - let (out, code) = run_code_in(dir.path(), &["campaign", "register", "c.campaign.json"]); - assert_eq!(code, Some(0), "stdout/stderr: {out}"); - let line = out.lines().find(|l| l.starts_with("registered campaign content:")).expect("line"); - let id = line - .trim_start_matches("registered campaign content:") - .split(' ') - .next() - .expect("id"); - assert!(dir.path().join("runs").join("campaigns").join(format!("{id}.json")).is_file()); -} -``` - -- [ ] **Step 5: Run the seam tests** - -Run: `cargo test -p aura-cli --test research_docs` -Expected: PASS — 11 passed, 0 failed - ---- - -### Task 12: workspace gates + acceptance sweep - -**Files:** none new (verification only) - -- [ ] **Step 1: Full workspace suite** - -Run: `cargo test --workspace` -Expected: PASS, 0 failed; every pre-existing test unchanged-green (the -Task-7 pin set certifies the primitive move); total ≈ 885 pre-cycle tests -plus the ~24 this plan adds. - -- [ ] **Step 2: Lint gate** - -Run: `cargo clippy --workspace --all-targets -- -D warnings` -Expected: clean, 0 warnings - -- [ ] **Step 3: Doc gate** - -Run: `cargo doc --workspace --no-deps 2>&1` -Expected: success; no new warnings (aura-research's crate + public items -carry doc comments per this plan's code) - -- [ ] **Step 4: Acceptance sweep against the spec (manual, no code change)** - -Run: `cargo run -p aura-cli -- process introspect --vocabulary` -Expected: the five std:: process blocks with one-line docs (spec acceptance -criterion 2; criteria 1/3/4/5 are pinned by the Task 10/11 seam tests and -the Task 7 pin set — confirm those tests exist and passed in Step 1) diff --git a/docs/specs/0106-research-artifact-vocabularies.md b/docs/specs/0106-research-artifact-vocabularies.md deleted file mode 100644 index 4ee8de2..0000000 --- a/docs/specs/0106-research-artifact-vocabularies.md +++ /dev/null @@ -1,376 +0,0 @@ -# Research-artifact vocabularies: process & campaign documents — Design Spec - -**Date:** 2026-07-03 -**Status:** Draft — awaiting sign-off (boss run: grounding-check gate) -**Authors:** orchestrator + Claude -**Cycle:** 0106 — milestone "Research artifacts — process & campaign documents"; -work item #189 (design basis: #188 body + both addenda + fork-triage + -resolution comments; spec-bearing fork resolutions: the cycle-0106 -reconciliation comment on #189) - -## Goal - -Give roles 5 and 6b (#188) their artifacts: two new **closed-vocabulary, -typed, serialized document types** — the **process document** (a named, -versionable validation/eval methodology) and the **campaign document** -(persisted experiment intent: data windows × strategy references × param -axes × process reference × data-level presentation) — with **parsing, -two-tier validation, the op-script-precedent introspection contract, and -content-addressing**. **No executor in this cycle**: the deliverable is that -methodology and experiment intent stop being smeared over CLI flags and -shell history and become nameable, diffable, content-addressed documents an -LLM or human can author and validate headless, without a Rust compile and -without executing a run. - -Power ceiling is the ratified P1 tier (#188 addendum 2026-07-03 12:08): -bounded axes over declared finite sets and structured gates between stages. -Ladders (the third ratified P1 family) are **within the ceiling but not in -the v1 vocabulary** — they describe adaptive retry, which has no host -semantics until an executor exists; they land with the executor cycle. The -v1 process vocabulary is a thin named layer over **shipped** primitives -only (fork-triage decided item 5): the R-metrics reduction, the -argmax/plateau selection rules, deflation annotation, the R-bootstrap -Monte-Carlo, and cross-instrument generalization. - -## Architecture - -One new leaf crate **`crates/aura-research`** (workspace member; deps: -`aura-core`, `serde`, `serde_json`, `sha2`) owns: - -- the two document types and their block/construct types (serde derives - directly on them — these types ARE the wire format; the engine is not - involved), -- parsing (`format_version` envelope, deny-unknown-fields), -- **intrinsic validation** (store-free): vocabulary membership, typed - slots, P1 constraints, -- the **introspection contract**: enumerate vocabulary, describe one - block's typed slots, list a partial document's open slots, -- **canonical serialization + content id** — and the canonical-bytes - SHA-256 helper moves here as the library-hosted single primitive; - `aura-cli`'s existing `content_id` (crates/aura-cli/src/main.rs:2587, - shared today by `topology_hash` at main.rs:2595 and - `graph introspect --content-id`/`--identity-id`) delegates to it, - byte-identically. - -**`crates/aura-registry`** gains the content-addressed stores for both -document types beside the existing blueprint store -(`put_blueprint`/`get_blueprint`, crates/aura-registry/src/lib.rs:119/127): -`processes/.json`, `campaigns/.json`, plus the -**referential validation** tier (registry depends on engine and can load -blueprints; aura-research stays a leaf and never sees a store). - -**`crates/aura-cli`** gains two role-addressed verb families — `aura -process …` (role 5) and `aura campaign …` (role 6b) — each with `validate -` and `introspect` mirroring the shipped `graph introspect` flag -idiom (`--vocabulary` / `--block ` / `--unwired ` / -`--content-id `, exactly-one-of enforced as in -crates/aura-cli/src/graph_construct.rs:199), and `register ` to put a -valid document into the project's store. Faults are presented as -house-style prose at the binary seam (the #162 convention; error types -stay Display-free and by-identifier); usage errors exit 2, runtime -refusals exit 1 (the #175 split). - -## Concrete code shapes - -### The worked author example (role 5 + role 6b, no Rust, no run) - -A methodology author writes `wf-deflated-screen.process.json`: - -```json -{ - "format_version": 1, - "kind": "process", - "name": "wf-deflated-screen", - "description": "Sweep, keep only deflated-positive candidates, then walk-forward.", - "pipeline": [ - { "block": "std::sweep", - "metric": "net_expectancy_r", - "select": "plateau:worst", - "deflate": true }, - { "block": "std::gate", - "all": [ - { "metric": "net_expectancy_r", "cmp": "gt", "value": 0.0 }, - { "metric": "overfit_probability", "cmp": "lt", "value": 0.1 } - ] }, - { "block": "std::walk_forward", - "folds": 4, - "in_sample_bars": 4000, - "out_of_sample_bars": 1000, - "metric": "net_expectancy_r", - "select": "argmax" } - ] -} -``` - -```console -$ aura process validate wf-deflated-screen.process.json -process document valid (intrinsic): 3 pipeline blocks, 2 gate predicates -$ aura process introspect --content-id wf-deflated-screen.process.json -content:4e2d… -$ aura process register wf-deflated-screen.process.json # inside a project -registered process content:4e2d… (runs/processes/4e2d….json) -``` - -A campaign author writes `ger40-momentum-screen.campaign.json`, referencing -the process and a strategy **by id only** (reference-only slot, #188 -resolution comment): - -```json -{ - "format_version": 1, - "kind": "campaign", - "name": "ger40-momentum-screen", - "data": { - "instruments": ["GER40"], - "windows": [ { "from_ms": 1704067200000, "to_ms": 1719792000000 } ] - }, - "strategies": [ - { "ref": { "content_id": "9f3a…" }, - "axes": { "fast": { "kind": "I64", "values": [8, 12, 16] }, - "slow": { "kind": "I64", "values": [21, 34] } } } - ], - "process": { "ref": { "content_id": "4e2d…" } }, - "seed": 1, - "presentation": { - "persist_taps": ["net_r_equity"], - "emit": ["family_table", "selection_report"] - } -} -``` - -```console -$ aura campaign validate ger40-momentum-screen.campaign.json -campaign document valid (intrinsic): 1 strategy, 2 axes (6 points), process ref, 1 window -campaign document valid (referential): strategy 9f3a… found, process 4e2d… found, axes ⊆ param space -$ aura campaign introspect --vocabulary -std::data campaign section: instruments + windows -std::strategy campaign section: strategy ref (content or identity id) + param axes -… -$ aura campaign introspect --unwired draft.campaign.json -open slot: process.ref (required, one of: content_id, identity_id) -open slot: strategies[0].axes.slow (axis declared empty — a P1 axis is a non-empty finite set) -``` - -Authoring feedback loops (`--unwired`, precise refusals) replace the -compile cycle; the Blockly litmus test is checkable because -`--vocabulary`/`--block` enumerate every block and its typed slots. - -### Document types (aura-research, primary shapes) - -```rust -// crates/aura-research/src/lib.rs (new crate) -#[derive(Serialize, Deserialize, ...)] -pub struct ProcessDoc { - pub format_version: u32, // envelope, must be 1 - pub kind: DocKind, // tag: Process - pub name: String, - #[serde(default, skip_serializing_if = "...")] - pub description: Option, - pub pipeline: Vec, -} - -#[derive(Serialize, Deserialize, ...)] -#[serde(tag = "block")] -pub enum StageBlock { - #[serde(rename = "std::sweep")] - Sweep { metric: MetricId, select: SelectRule, #[serde(default)] deflate: bool }, - #[serde(rename = "std::gate")] - Gate { all: Vec }, // non-empty conjunction - #[serde(rename = "std::walk_forward")] - WalkForward { folds: u32, in_sample_bars: u64, out_of_sample_bars: u64, - metric: MetricId, select: SelectRule }, - #[serde(rename = "std::monte_carlo")] - MonteCarlo { resamples: u32, block_len: u32 }, // terminal annotator in v1 - #[serde(rename = "std::generalize")] - Generalize { metric: MetricId }, // terminal annotator in v1 -} - -#[derive(Serialize, Deserialize, ...)] -pub struct Predicate { pub metric: MetricId, pub cmp: Cmp, pub value: f64 } -pub enum Cmp { Gt, Ge, Lt, Le } // closed comparator set; no eq on floats - -// MetricId: newtype over the closed declared-metric vocabulary — the scalar -// field names of shipped RMetrics/RunMetrics (crates/aura-analysis/src/lib.rs:58/:86) -// plus the selection annotations deflated_score / overfit_probability / -// neighbourhood_score (FamilySelection, crates/aura-analysis/src/lib.rs:32). -// SelectRule: Argmax | PlateauMean | PlateauWorst — mirrors SelectionMode -// (crates/aura-analysis/src/lib.rs:17) and the CLI --select strings. -``` - -```rust -#[derive(Serialize, Deserialize, ...)] -pub struct CampaignDoc { - pub format_version: u32, - pub kind: DocKind, // tag: Campaign - pub name: String, - #[serde(default, ...)] pub description: Option, - pub data: DataSection, // instruments: Vec, windows: Vec - pub strategies: Vec, // ref + axes - pub process: ProcessRef, // { ref: DocRef } — reference-only, never inline - pub seed: u64, - pub presentation: Presentation, // persist_taps: Vec, emit: Vec -} - -#[derive(Serialize, Deserialize, ...)] -pub enum DocRef { ContentId(String), IdentityId(String) } // one-of, typed slots -pub struct StrategyEntry { pub r#ref: DocRef, pub axes: BTreeMap } -pub struct Axis { pub kind: ScalarKind, pub values: Vec } -// custom serde: `{"kind": "I64", "values": [8, 12, 16]}` — values are bare -// JSON, parsed AS the declared kind -pub enum EmitKind { FamilyTable, SelectionReport } // closed v1 set -``` - -An axis declares its `ScalarKind` **once** — the kind is a property of the -parameter, not of each value (user veto, 2026-07-03, of a per-cell tagged -draft), so a mixed-kind axis is unrepresentable rather than merely refused. -The op-script binds keep their per-cell tagged `Scalar` form -(`OpDoc::Add { bind: BTreeMap }`, -crates/aura-cli/src/graph_construct.rs:37): a bind slot holds exactly one -value, so there the tag is the only kind carrier; an axis is a homogeneous -list, so the kind belongs to the axis. - -### Validation, canonical form, introspection (secondary shapes) - -```rust -// intrinsic tier (aura-research; store-free) -pub fn validate_process(doc: &ProcessDoc) -> Vec; -pub fn validate_campaign(doc: &CampaignDoc) -> Vec; -// DocFault: by-identifier, Display-free (CLI phrases it) — e.g. -// UnknownMetric{stage,metric}, EmptyAxis{strategy,axis}, EmptyPipeline, -// GateAfterTerminalStage{stage}, BadFormatVersion(u32), EmptyConjunction{stage}, -// NoWindow, NoStrategy, UnknownEmitKind(String) … - -// canonical form + ids (aura-research) -pub fn process_to_json(doc: &ProcessDoc) -> String; // canonical: omit-defaults, -pub fn campaign_to_json(doc: &CampaignDoc) -> String; // struct field order, no trailing \n -pub fn content_id_of(canonical_bytes: &str) -> String; // SHA-256 hex — THE primitive; - // aura-cli::content_id delegates here - -// introspection contract (aura-research) -pub fn process_vocabulary() -> Vec; // id + one-line doc -pub fn campaign_vocabulary() -> Vec; -pub fn describe_block(id: &str) -> Option; // typed slots: name/kind/required/enum values -pub fn open_slots_process(text: &str) -> Result, DocError>; // partial-doc probe -pub fn open_slots_campaign(text: &str) -> Result, DocError>; -``` - -```rust -// referential tier (aura-registry; needs stores + engine) -pub fn put_process(dir: &Path, canonical_json: &str) -> Result; // returns content id -pub fn get_process(dir: &Path, content_id: &str) -> Result; -pub fn put_campaign(…) / get_campaign(…); // same pattern as put_blueprint/get_blueprint -pub fn validate_campaign_refs(doc: &CampaignDoc, roots: &StoreRoots) - -> Vec; -// RefFault: ProcessNotFound(id), StrategyNotFound(id), IdentityAmbiguityEmpty(id), -// AxisNotInParamSpace{strategy_ref, axis}, AxisKindMismatch{strategy_ref, axis} -// — axes ⊆ the referenced blueprint's param_space, and each axis's declared -// kind must equal the param's ScalarKind (one check per axis; blueprint -// loaded from the store via blueprint_from_json). -``` - -Intrinsic P1 constraints enforced (the ratified ceiling made structural): -every axis is a non-empty finite value list; every gate is a non-empty -conjunction of typed `{metric, cmp, value}` triples whose metrics are in -the declared vocabulary; gates may not follow `std::monte_carlo` / -`std::generalize` (terminal annotators in v1 — no shipped aggregation -metric exists for their outputs to gate on; widening this is executor-cycle -work); no construct carries variables, recursion, or unbounded iteration — -such shapes are unrepresentable, not merely refused. - -### CLI wiring (secondary) - -`Command::Process(ProcessCmd)` / `Command::Campaign(CampaignCmd)` in -crates/aura-cli/src/main.rs beside `Command::Graph`; a new -`crates/aura-cli/src/research_docs.rs` module (graph_construct.rs -analogue) holds dispatch + fault presentation. `validate` runs intrinsic -always, referential when a project env is discovered (Aura.toml discovery, -crates/aura-cli/src/project.rs:169) and reports each tier on its own line; -outside a project the referential line is -`referential checks skipped (no Aura.toml found up from )`. -`register` refuses invalid documents; `--content-id` refuses -intrinsically-invalid documents (an invalid document has no canonical -form). - -## Components - -- `crates/aura-research` (new): document types, intrinsic validation, - canonical form + content id (the moved primitive), introspection - contract, `DocFault`/`DocError`. -- `crates/aura-registry`: `processes/`/`campaigns/` stores (blueprint-store - pattern), referential tier (`validate_campaign_refs`), `RefFault`. -- `crates/aura-cli`: `process`/`campaign` verb families, presentation of - faults, `content_id` delegation to `aura_research::content_id_of`. -- `Cargo.toml` (workspace): new member. - -## Data flow - -Author writes JSON text → `parse` (envelope + deny-unknown-fields) → -intrinsic `validate_*` (vocabulary membership, typed slots, P1 -constraints) → canonical `*_to_json` → `content_id_of` → `register` puts -canonical bytes into the project store keyed by content id → a campaign's -`DocRef`s resolve against those stores (referential tier), and its axes -resolve against the referenced blueprint's `param_space` loaded from the -blueprint store. Nothing executes; the registry stays the World's memory -(C18), now holding intent documents beside blueprints and run records. - -## Error handling - -Three by-identifier fault layers, all Display-free with CLI-side prose -(the #162 seam convention, pinned like -`graph_build_renders_a_bind_kind_mismatch_as_prose`): - -- `DocError` — parse-level: not JSON, bad envelope (`format_version` ≠ 1), - unknown `kind`, unknown fields, malformed ref. -- `DocFault` — intrinsic-validation findings (list, not first-fault): - unknown metric/block/emit kind, empty axis/pipeline/conjunction, gate - after a terminal stage, missing sections. -- `RefFault` — referential findings: unresolved process/strategy id, - identity id resolving to no stored blueprint, axis not in the referenced - strategy's param space. - -Exit codes: clap usage errors 2; validation refusals and runtime faults 1 -(the #175 split, already pinned at the binary seam). - -## Testing strategy - -- **aura-research unit tests:** parse/serialize round-trips for both - types; canonical-form golden pins (byte-exact, the blueprint-golden - pattern); content-id stability goldens; one refusal test per `DocFault` - variant (incl. every P1 constraint); vocabulary/describe/open-slots - shape tests (every block's slots enumerable — the Blockly litmus pin); - a test that `serde` rejects an inline process body in the campaign's - `process` slot (reference-only is structural). -- **aura-registry tests:** store round-trip for both types (hash-keyed - like the blueprint-store tests); `validate_campaign_refs` positive + - one per `RefFault` (fixtures: a stored blueprint with a known - `param_space`). -- **aura-cli seam tests:** binary-level validate (valid, intrinsic - refusal, referential refusal inside a scaffolded project, outside-project - skip line); introspect `--vocabulary`/`--block`/`--unwired`/ - `--content-id` outputs; prose (no Debug leak) pinned; exit codes pinned; - `content_id` delegation equivalence (`topology_hash`/`--content-id` - bytes unchanged — guarded by the existing golden - `signal_serializes_to_canonical_golden` and id tests staying green - untouched). -- Full gates: `cargo test --workspace`, `cargo clippy --workspace - --all-targets -- -D warnings`, `cargo doc --workspace --no-deps`. - -## Acceptance criteria - -1. The worked example above runs verbatim (modulo hash hex): both - documents author as text, validate headless with per-tier report lines, - yield stable content ids, and register into a project's store — no - Rust compile, no run executed. -2. `aura process introspect` / `aura campaign introspect` expose the full - introspection contract (vocabulary, block schema, open slots of a - partial document, content id) with the exactly-one-of flag guard. -3. Every invalid shape in the fault tables refuses with house-style prose - naming the offending element; no Rust `Debug` form reaches the user; - exit codes follow the usage-2/runtime-1 split. -4. The declared-metric vocabulary, select rules, and stage blocks are - exactly the shipped-primitive wrappers named in this spec — nothing - invented; ladders and any executor semantics are absent. -5. The pre-existing suite stays green unchanged; blueprint content/identity - ids and `topology_hash` are byte-identical to before (the content-id - primitive moved, not changed). -6. Workspace gates green: test, clippy `-D warnings`, doc build.