From ef3bec5844418a0df26f65cd8923c7f300cae7ba Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 3 Jul 2026 15:09:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(research):=200106=20tasks=201-9=20?= =?UTF-8?q?=E2=80=94=20document=20layer,=20content-id=20move,=20stores=20+?= =?UTF-8?q?=20referential=20tier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aura-research leaf crate: ProcessDoc/CampaignDoc with strict parsing (hand-rolled StageBlock deserializer — deny_unknown_fields does not compose with internally-tagged enums; Axis declares its ScalarKind once with bare values, per the #189 user veto), canonical form + content_id_of (the SHA-256 primitive moved from aura-cli, which now delegates — the id pin set incl. the independent sweep-store recompute stayed green untouched), intrinsic validation for both document types (P1 constraints structural), and the introspection contract (schema tables single-source parse strictness AND vocabulary/describe/open-slots — the Blockly litmus made checkable). aura-registry: content-addressed processes/ + campaigns/ stores on the blueprint-store pattern (Ok(None) treat-as-empty) and the referential tier (validate_campaign_refs: process/strategy refs incl. identity-scan, axis-name + declared-kind checks against param_space). Plan deviations, verified by hand and folded back into the plan file: - Task 9's fixture could not use aura-composites as planned: every shipped composite with an open param routes through LinComb, which the zero-arg std_vocabulary roster deliberately excludes, so none round-trips through blueprint_from_json with the by-type-name resolver. The test hand-builds a minimal Bias composite instead (generic over param_space, as planned); the unused pro-forma aura-composites dev-dep from the repair pass is dropped. This is why the loop reported task 9 BLOCKED (review-loop-exhausted on the literal code block); the tree itself is complete and green. - A plan-verbatim test assertion false-matched ("deflate" as substring of "deflated-positive"); tightened to the JSON key. - OpenSlot doc comment backticked (rustdoc read strategies[0] as a link; doc gate back to 0 warnings). Verification: cargo test --workspace 898/0; clippy -D warnings clean; cargo doc --no-deps 0 warnings. Tasks 10-12 (CLI verb families, final gates) follow. refs #189 --- Cargo.lock | 13 + Cargo.toml | 1 + crates/aura-cli/Cargo.toml | 1 + crates/aura-cli/src/main.rs | 4 +- crates/aura-registry/Cargo.toml | 10 +- crates/aura-registry/src/lib.rs | 292 +++- crates/aura-research/Cargo.toml | 14 + crates/aura-research/src/lib.rs | 1224 +++++++++++++++++ .../0106-research-artifact-vocabularies.md | 31 +- 9 files changed, 1574 insertions(+), 16 deletions(-) create mode 100644 crates/aura-research/Cargo.toml create mode 100644 crates/aura-research/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 99746f3..045e01c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,6 +114,7 @@ dependencies = [ "aura-engine", "aura-ingest", "aura-registry", + "aura-research", "aura-std", "clap", "data-server", @@ -174,10 +175,22 @@ version = "0.1.0" dependencies = [ "aura-core", "aura-engine", + "aura-research", + "aura-std", "serde", "serde_json", ] +[[package]] +name = "aura-research" +version = "0.1.0" +dependencies = [ + "aura-core", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "aura-std" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a32e770..b9d9b92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "crates/aura-cli", "crates/aura-ingest", "crates/aura-registry", + "crates/aura-research", ] [workspace.package] diff --git a/crates/aura-cli/Cargo.toml b/crates/aura-cli/Cargo.toml index cc09026..2f5b8bf 100644 --- a/crates/aura-cli/Cargo.toml +++ b/crates/aura-cli/Cargo.toml @@ -16,6 +16,7 @@ aura-engine = { path = "../aura-engine" } # r-sma harness wires (kept out of aura-engine so the engine stays domain-free). aura-composites = { path = "../aura-composites" } aura-registry = { path = "../aura-registry" } +aura-research = { path = "../aura-research" } aura-std = { path = "../aura-std" } aura-ingest = { path = "../aura-ingest" } # data-server: the local M1 archive `aura run --real` streams from. Mirrors the diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index e9105e1..358172d 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -2585,9 +2585,7 @@ fn sma_signal(fast_len: Option, slow_len: Option) -> Composite { /// two surfaces agree by construction over the same canonical bytes. Research-side /// (aura-cli), off the frozen engine (invariant 8). fn content_id(canonical_json: &str) -> String { - use sha2::{Digest, Sha256}; - let digest = Sha256::digest(canonical_json.as_bytes()); - digest.iter().map(|b| format!("{b:02x}")).collect() + aura_research::content_id_of(canonical_json) } /// SHA256 (hex) of the canonical (#164, no-trailing-newline) serialization of a diff --git a/crates/aura-registry/Cargo.toml b/crates/aura-registry/Cargo.toml index 1e7fdfa..ce5d1ec 100644 --- a/crates/aura-registry/Cargo.toml +++ b/crates/aura-registry/Cargo.toml @@ -10,11 +10,17 @@ publish.workspace = true # back (admitted under the amended C16 per-case policy, INDEX.md). RunReport # derives serde in aura-engine. aura-engine = { path = "../aura-engine" } +# the document stores' content-id primitive (put_process/put_campaign key on +# aura_research::content_id_of, the same hash the doc types canonicalize to); +# also PrimitiveBuilder, the referential tier's resolver signature. +aura-core = { path = "../aura-core" } +aura-research = { path = "../aura-research" } # the lineage record types (FamilyKind / FamilyRunRecord) derive serde; admitted # under the same per-case policy as serde_json (INDEX.md). serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } [dev-dependencies] -# fixtures construct RunReport/RunManifest literals, whose window needs Timestamp -aura-core = { path = "../aura-core" } +# the referential-tier test's generic param-space fixture (a real vocabulary +# resolver + a zero-arg node, not hand-rolled). +aura-std = { path = "../aura-std" } diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index d3e0c16..fcf5a9a 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -17,10 +17,13 @@ use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +use aura_core::PrimitiveBuilder; use aura_engine::{ - expected_max_of_normals, r_metrics_from_rs, resample_block, FamilySelection, MetricStats, - RunReport, SelectionMode, SplitMix64, SweepFamily, SweepPoint, + blueprint_from_json, blueprint_identity_json, expected_max_of_normals, r_metrics_from_rs, + resample_block, FamilySelection, MetricStats, RunReport, SelectionMode, SplitMix64, + SweepFamily, SweepPoint, }; +use aura_research::{CampaignDoc, DocRef}; mod compat; @@ -131,6 +134,161 @@ impl Registry { Err(e) => Err(RegistryError::Io(e)), } } + + /// 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) + } +} + +/// 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 + && 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) + } } /// Which metric a best-first comparison keys on. Resolves a metric *name* (a @@ -711,6 +869,21 @@ mod tests { assert_eq!(reg.get_blueprint("never-written").expect("get"), None); } + #[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); + } + #[test] fn corrupt_line_is_a_parse_error_with_line_number() { let path = temp_path("corrupt"); @@ -1291,6 +1464,121 @@ mod tests { assert_eq!(g1, g2, "a pure fold must be deterministic (C1)"); } + /// Property: `validate_campaign_refs` resolves campaign document + /// references (content-id and identity-id) against the registry's + /// stores and cross-checks each declared tuning axis (name AND kind) + /// against the resolved strategy's real, generic param space — not a + /// hardcoded param name. Faults are reported per finding kind + /// (ProcessNotFound / StrategyNotFound / AxisNotInParamSpace / + /// AxisKindMismatch), and a fully-resolved, kind-correct document + /// yields no faults at all. + #[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. `aura-composites`' 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 (see aura-std/src/vocabulary.rs) + // — they cannot round-trip through `blueprint_from_json` with this + // resolver, so this fixture stands in as the "real, open-param, + // vocabulary-loadable composite" the test needs. + 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()); + } + #[test] fn check_r_metric_accepts_r_and_refuses_pip() { assert!(check_r_metric("expectancy_r").is_ok()); diff --git a/crates/aura-research/Cargo.toml b/crates/aura-research/Cargo.toml new file mode 100644 index 0000000..eb8334f --- /dev/null +++ b/crates/aura-research/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "aura-research" +edition.workspace = true +version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +aura-core = { path = "../aura-core" } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +# sha2: content-addressed ids for research documents (mirrors aura-cli's +# topology_hash use, per-case C16 review, SHA256 user-settled). +sha2 = "0.10" diff --git a/crates/aura-research/src/lib.rs b/crates/aura-research/src/lib.rs new file mode 100644 index 0000000..4b587a8 --- /dev/null +++ b/crates/aura-research/src/lib.rs @@ -0,0 +1,1224 @@ +//! 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 slot strictness (unknown-slot +// rejection in `stage_from_value`) AND the introspection contract (the 0105 +// roster lesson). Block *ids* still need declaring at each of the enum +// `#[serde(rename)]`, `PROCESS_BLOCKS.id`, and the `stage_from_value` match +// arms — Rust's static enum representation doesn't let a single runtime +// table drive both the type and the parse dispatch. +// --------------------------------------------------------------------------- + +/// 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)?; + // SelectRule's own derived Deserialize (which carries the #[serde(rename)] + // wire strings) is the type oracle, mirroring kind_tag/scalar_from_bare: + // no second hardcoded copy of the three select-rule wire strings. + serde_json::from_value(serde_json::Value::String(s.clone())) + .map_err(|_| format!("block {block}: unknown select rule \"{s}\"")) +} + +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())) +} + +/// 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, + } +} + +/// The wire tag of a `ScalarKind`, using `ScalarKind`'s own serde as the +/// oracle (mirrors `scalar_to_bare`: no wire shape assumed, only asked of +/// serde) rather than re-hardcoding the four tag strings as a second source +/// of truth for `Scalar`'s variant names. +fn kind_tag(kind: ScalarKind) -> String { + match serde_json::to_value(kind).expect("scalar kind serializes") { + serde_json::Value::String(s) => s, + other => unreachable!("ScalarKind serializes to a string, got {other}"), + } +} + +/// 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.clone(): 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())) +} + +// --------------------------------------------------------------------------- +// 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()) +} + +// --------------------------------------------------------------------------- +// 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. +/// +/// Accepted residual: this is a hand-copy of `aura-analysis`'s +/// `RMetrics`/`RunMetrics`/`FamilySelection` field names, not oracle-guarded +/// in-crate — this leaf crate deliberately has no `aura-analysis` dependency +/// (engine/domain separation), so there is no compile-time field pin to +/// reference here, unlike the block-schema tables above (:105). Same #160/0105 +/// drift class; fails safe in the direction that matters: a metric renamed or +/// removed upstream surfaces as `DocFault::UnknownMetric` at parse/validation +/// time, never a silent accept. Keep this list in sync by hand when the +/// upstream field sets change. +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 +} + +/// 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 +} + +// --------------------------------------------------------------------------- +// 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()).is_none_or(|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()).is_none_or(|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()) + .is_none_or(|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}\""))); + } +} + +#[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(_)))); + } + + 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)]); + } + + /// Axis's hand-rolled Serialize strips the tagged Scalar wire form down + /// to bare values (scalar_to_bare), and Deserialize re-parses those bare + /// values back through the declared kind (scalar_from_bare) — this must + /// round-trip exactly, and the wire shape must actually be bare (no + /// per-value `{"I64": ...}` tag leaking through). + #[test] + fn axis_serializes_to_bare_values_and_round_trips() { + let axis = Axis { + kind: ScalarKind::I64, + values: vec![Scalar::I64(8), Scalar::I64(12), Scalar::I64(16)], + }; + let json = serde_json::to_value(&axis).expect("axis serializes"); + assert_eq!(json, serde_json::json!({ "kind": "I64", "values": [8, 12, 16] })); + let round_tripped: Axis = serde_json::from_value(json).expect("bare json round-trips"); + assert_eq!(round_tripped, axis); + } + + #[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 }) + ); + } + + #[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]"#)); + } + + #[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 })); + } + + #[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()))); + } + + #[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(_)))); + } +} diff --git a/docs/plans/0106-research-artifact-vocabularies.md b/docs/plans/0106-research-artifact-vocabularies.md index 3a297c3..bc5870c 100644 --- a/docs/plans/0106-research-artifact-vocabularies.md +++ b/docs/plans/0106-research-artifact-vocabularies.md @@ -1724,27 +1724,42 @@ import.) - [ ] **Step 2: Dev-deps for the fixture, if absent** -In `crates/aura-registry/Cargo.toml` `[dev-dependencies]` add whichever of -these is missing: +In `crates/aura-registry/Cargo.toml` `[dev-dependencies]` add if missing: ```toml aura-std = { path = "../aura-std" } -aura-composites = { path = "../aura-composites" } ``` +(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 real public composite with an open param space - let composite = aura_composites::vol_stop(4, 1.5); + // 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(); @@ -1833,10 +1848,8 @@ 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. +`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**