From 3149720f216dae80fcc1d18957ea7ee1d71caf18 Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 3 Jul 2026 13:19:32 +0200 Subject: [PATCH] plan: 0106 research-artifact vocabularies Twelve tasks: aura-research leaf crate (process/campaign types, strict parse incl. hand-rolled StageBlock + Axis deserializers, canonical form + content-id primitive, intrinsic validation, introspection contract), content-id delegation from aura-cli (pin set certifies the move), aura-registry document stores + referential tier (generic param-space fixture), process/campaign CLI verb families with binary seam tests, workspace gates. Axis shape per the #189 user veto: kind declared once, bare values. refs #189 --- .../0106-research-artifact-vocabularies.md | 2562 +++++++++++++++++ 1 file changed, 2562 insertions(+) create mode 100644 docs/plans/0106-research-artifact-vocabularies.md diff --git a/docs/plans/0106-research-artifact-vocabularies.md b/docs/plans/0106-research-artifact-vocabularies.md new file mode 100644 index 0000000..3a297c3 --- /dev/null +++ b/docs/plans/0106-research-artifact-vocabularies.md @@ -0,0 +1,2562 @@ +# 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) + } +``` + +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 whichever of +these is missing: + +```toml +aura-std = { path = "../aura-std" } +aura-composites = { path = "../aura-composites" } +``` + +- [ ] **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_research::{Axis, DocRef}; + + let reg = Registry::open(temp_family_dir("referential_tier")); + let resolve = |t: &str| aura_std::std_vocabulary(t); + + // a real public composite with an open param space + let composite = aura_composites::vol_stop(4, 1.5); + 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). If +`vol_stop(4, 1.5).param_space()` turns out EMPTY, use +`aura_composites::risk_executor_vol_open(1.0)` instead — the test is +generic and needs only ONE open param. + +- [ ] **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 id = env.registry().put_process(&canonical).map_err(|e| e.to_string())?; + let path = env.runs_root().join("processes").join(format!("{id}.json")); + println!("registered process content:{id} ({})", path.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 id = env.registry().put_campaign(&canonical).map_err(|e| e.to_string())?; + let path = env.runs_root().join("campaigns").join(format!("{id}.json")); + println!("registered campaign content:{id} ({})", path.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)