c37abb2a27
The std::sweep selection triple (metric/select/deflate) becomes ONE
optional group, Option<SweepSelection>, serde-flattened so the wire
form keeps the flat slots: every existing document parses to Some and
its canonical bytes — and content id — are unchanged (golden pin
byte-untouched). A half-populated group is refused by the schema-strict
parser (all-or-nothing); a bare {"block":"std::sweep"} is the
selection-free sweep.
Campaign side: preflight permits a selection-free sweep only as the
pipeline's terminal stage (new ExecFault::SelectionFreeSweepNotTerminal,
prose in aura-cli's exhaustive exec_fault_prose); the executor's
selection-free arm appends the family exactly as before but records no
StageSelection and produces no nominee (recording one would fabricate
intent — #210 fork decision 1).
Introspection follows: the sweep block's schema table marks the group
optional; CLI introspection pins updated. New tests: group-or-nothing
refusals, selection-free round-trip + distinct content id, terminal-only
preflight accept/refuse, selection-free execute realization.
refs #210
1703 lines
70 KiB
Rust
1703 lines
70 KiB
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<String>,
|
|
pub pipeline: Vec<StageBlock>,
|
|
}
|
|
|
|
/// 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 {
|
|
/// The selection triple is one optional group: `None` is a
|
|
/// selection-free sweep (permitted only as the terminal stage — the
|
|
/// executor's preflight rule), `Some` carries metric+select(+deflate).
|
|
/// Flattened so the wire form stays the flat `metric`/`select`/
|
|
/// `deflate` slots and every existing document's canonical bytes —
|
|
/// and content id — are unchanged.
|
|
#[serde(flatten, skip_serializing_if = "Option::is_none")]
|
|
selection: Option<SweepSelection>,
|
|
},
|
|
#[serde(rename = "std::gate")]
|
|
Gate { all: Vec<Predicate> },
|
|
#[serde(rename = "std::walk_forward")]
|
|
WalkForward {
|
|
in_sample_ms: u64,
|
|
out_of_sample_ms: u64,
|
|
step_ms: u64,
|
|
mode: WfMode,
|
|
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
|
|
}
|
|
|
|
/// The sweep stage's selection group — all-or-nothing (a half-populated
|
|
/// group is unrepresentable; the schema-strict parser refuses it).
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct SweepSelection {
|
|
pub metric: String,
|
|
pub select: SelectRule,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub deflate: bool,
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
|
|
/// Walk-forward roll mode — wire form "rolling" | "anchored" (the two shipped
|
|
/// engine `RollMode`s: fixed-length rolling in-sample vs anchored-origin
|
|
/// growing in-sample).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub enum WfMode {
|
|
#[serde(rename = "rolling")]
|
|
Rolling,
|
|
#[serde(rename = "anchored")]
|
|
Anchored,
|
|
}
|
|
|
|
/// One typed gate predicate: `<metric> <cmp> <value>`.
|
|
#[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,
|
|
/// Walk-forward roll mode (`WfMode` wire strings).
|
|
WfMode,
|
|
Bool,
|
|
U32,
|
|
U64,
|
|
Predicates,
|
|
Strings,
|
|
Windows,
|
|
Ref,
|
|
/// A content-id-only reference (the process ref: `validate_campaign`
|
|
/// refuses an identity id there, so the describe path must not offer one).
|
|
ContentRef,
|
|
Axes,
|
|
EmitKinds,
|
|
/// The closed tap vocabulary (`tap_vocabulary()` entries).
|
|
TapKinds,
|
|
}
|
|
|
|
/// 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; \
|
|
optionally select a winner (the selection group metric+select is \
|
|
all-or-nothing; omitted = selection-free, terminal-stage-only)",
|
|
slots: &[
|
|
SlotInfo { name: "metric", kind: SlotKind::MetricName, required: false },
|
|
SlotInfo { name: "select", kind: SlotKind::SelectRule, required: false },
|
|
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: "in_sample_ms", kind: SlotKind::U64, required: true },
|
|
SlotInfo { name: "out_of_sample_ms", kind: SlotKind::U64, required: true },
|
|
SlotInfo { name: "step_ms", kind: SlotKind::U64, required: true },
|
|
SlotInfo { name: "mode", kind: SlotKind::WfMode, 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): after a \
|
|
walk_forward, ONE bootstrap over its pooled OOS trade series; \
|
|
otherwise one per surviving member; both slots must be > 0 to \
|
|
execute",
|
|
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): runs at \
|
|
campaign scope, per (strategy, window) over the cells' nominees; \
|
|
needs >= 2 instruments and an R-expectancy metric to execute",
|
|
slots: &[SlotInfo { name: "metric", kind: SlotKind::MetricName, required: true }],
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Strict stage parsing (schema-driven).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn require_str(
|
|
m: &serde_json::Map<String, serde_json::Value>,
|
|
key: &str,
|
|
block: &str,
|
|
) -> Result<String, String> {
|
|
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<String, serde_json::Value>,
|
|
key: &str,
|
|
block: &str,
|
|
) -> Result<u64, String> {
|
|
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<String, serde_json::Value>,
|
|
key: &str,
|
|
block: &str,
|
|
) -> Result<u32, String> {
|
|
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<String, serde_json::Value>,
|
|
key: &str,
|
|
block: &str,
|
|
) -> Result<bool, String> {
|
|
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<String, serde_json::Value>,
|
|
block: &str,
|
|
) -> Result<SelectRule, String> {
|
|
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 mode_from(
|
|
m: &serde_json::Map<String, serde_json::Value>,
|
|
block: &str,
|
|
) -> Result<WfMode, String> {
|
|
let s = require_str(m, "mode", block)?;
|
|
// WfMode's own derived Deserialize (which carries the #[serde(rename)]
|
|
// wire strings) is the type oracle, mirroring select_from: no second
|
|
// hardcoded copy of the two roll-mode wire strings.
|
|
serde_json::from_value(serde_json::Value::String(s.clone()))
|
|
.map_err(|_| format!("block {block}: unknown mode \"{s}\""))
|
|
}
|
|
|
|
fn stage_from_value(v: &serde_json::Value) -> Result<StageBlock, String> {
|
|
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" => {
|
|
let has_metric = m.contains_key("metric");
|
|
let has_select = m.contains_key("select");
|
|
let selection = match (has_metric, has_select) {
|
|
(true, true) => Some(SweepSelection {
|
|
metric: require_str(m, "metric", &id)?,
|
|
select: select_from(m, &id)?,
|
|
deflate: optional_bool(m, "deflate", &id)?,
|
|
}),
|
|
(false, false) => {
|
|
if m.contains_key("deflate") {
|
|
return Err(format!(
|
|
"block {id}: slot \"deflate\" needs the selection \
|
|
group (\"metric\" + \"select\")"
|
|
));
|
|
}
|
|
None
|
|
}
|
|
(true, false) => {
|
|
return Err(format!(
|
|
"block {id}: slot \"metric\" without \"select\" — the \
|
|
selection group is all-or-nothing"
|
|
));
|
|
}
|
|
(false, true) => {
|
|
return Err(format!(
|
|
"block {id}: slot \"select\" without \"metric\" — the \
|
|
selection group is all-or-nothing"
|
|
));
|
|
}
|
|
};
|
|
Ok(StageBlock::Sweep { selection })
|
|
}
|
|
"std::gate" => {
|
|
let all = m
|
|
.get("all")
|
|
.ok_or_else(|| format!("block {id}: missing required slot \"all\""))?;
|
|
let all: Vec<Predicate> = 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 {
|
|
in_sample_ms: require_u64(m, "in_sample_ms", &id)?,
|
|
out_of_sample_ms: require_u64(m, "out_of_sample_ms", &id)?,
|
|
step_ms: require_u64(m, "step_ms", &id)?,
|
|
mode: mode_from(m, &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: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
|
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<ProcessDoc, DocError> {
|
|
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<String>,
|
|
pub data: DataSection,
|
|
pub strategies: Vec<StrategyEntry>,
|
|
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<String>,
|
|
pub windows: Vec<Window>,
|
|
}
|
|
|
|
/// 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<String, Axis>,
|
|
}
|
|
|
|
/// 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<Scalar>,
|
|
}
|
|
|
|
/// 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<Scalar, String> {
|
|
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<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
|
use serde::ser::SerializeStruct;
|
|
let mut st = s.serialize_struct("Axis", 2)?;
|
|
st.serialize_field("kind", &self.kind)?;
|
|
let bare: Vec<serde_json::Value> = self.values.iter().map(scalar_to_bare).collect();
|
|
st.serialize_field("values", &bare)?;
|
|
st.end()
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for Axis {
|
|
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
|
use serde::de::Error as _;
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct Raw {
|
|
kind: ScalarKind,
|
|
values: Vec<serde_json::Value>,
|
|
}
|
|
let raw = Raw::deserialize(d)?;
|
|
let values = raw
|
|
.values
|
|
.iter()
|
|
.map(|v| scalar_from_bare(raw.kind, v))
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.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<String>,
|
|
pub emit: Vec<String>,
|
|
}
|
|
|
|
/// Parse a campaign document from text (envelope + strict fields).
|
|
pub fn parse_campaign(text: &str) -> Result<CampaignDoc, DocError> {
|
|
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"]
|
|
}
|
|
|
|
/// The wrap convention's persisted sink names — the closed tap vocabulary
|
|
/// (#201 decision 1). A genuinely new observable becomes a new entry or an
|
|
/// authored sink in the strategy blueprint (C22: the choice of sinks is part
|
|
/// of the experiment) — never an open node-path namespace in the document.
|
|
pub fn tap_vocabulary() -> &'static [&'static str] {
|
|
&["equity", "exposure", "r_equity", "net_r_equity"]
|
|
}
|
|
|
|
/// 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 },
|
|
ZeroWalkForwardLength { stage: usize, field: &'static str },
|
|
// campaign side
|
|
EmptyInstruments,
|
|
NoWindow,
|
|
BadWindow { index: usize },
|
|
NoStrategy,
|
|
EmptyAxes { strategy: usize },
|
|
EmptyAxis { strategy: usize, axis: String },
|
|
UnknownEmitKind(String),
|
|
UnknownTap { index: usize, tap: 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<DocFault> {
|
|
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 { selection } => {
|
|
if let Some(sel) = selection
|
|
&& !is_known_metric(&sel.metric)
|
|
{
|
|
faults.push(DocFault::UnknownMetric { stage: i, metric: sel.metric.clone() });
|
|
}
|
|
}
|
|
StageBlock::Generalize { metric } => {
|
|
if !is_known_metric(metric) {
|
|
faults.push(DocFault::UnknownMetric { stage: i, metric: metric.clone() });
|
|
}
|
|
}
|
|
StageBlock::WalkForward { in_sample_ms, out_of_sample_ms, step_ms, metric, .. } => {
|
|
if !is_known_metric(metric) {
|
|
faults.push(DocFault::UnknownMetric { stage: i, metric: metric.clone() });
|
|
}
|
|
// WindowRoller::new refuses NonPositiveLength at run time; the
|
|
// doc tier refuses the same zeroes earlier, path-addressed.
|
|
for (field, value) in [
|
|
("in_sample_ms", *in_sample_ms),
|
|
("out_of_sample_ms", *out_of_sample_ms),
|
|
("step_ms", *step_ms),
|
|
] {
|
|
if value == 0 {
|
|
faults.push(DocFault::ZeroWalkForwardLength { stage: i, field });
|
|
}
|
|
}
|
|
}
|
|
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<DocFault> {
|
|
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()));
|
|
}
|
|
}
|
|
for (i, t) in doc.presentation.persist_taps.iter().enumerate() {
|
|
if !tap_vocabulary().contains(&t.as_str()) {
|
|
faults.push(DocFault::UnknownTap { index: i, tap: t.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::ContentRef, 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::TapKinds, 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::WfMode => "one of: rolling | anchored",
|
|
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::ContentRef => "content id of a process document",
|
|
SlotKind::Axes => "map: param name -> { kind, values }",
|
|
SlotKind::EmitKinds => "list of: family_table | selection_report",
|
|
SlotKind::TapKinds => "list of: equity | exposure | r_equity | net_r_equity",
|
|
}
|
|
}
|
|
|
|
/// 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<String>, hint: impl Into<String>) -> 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<Vec<OpenSlot>, 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.
|
|
/// Whether a `ref` slot in a DRAFT reads as open: absent, or one of the two
|
|
/// natural placeholder spellings `{}` / `null` (#195). A draft affordance of
|
|
/// the open-slot probe only — the strict parse tier keeps refusing
|
|
/// placeholders (they are not a valid document form).
|
|
fn ref_slot_is_open(r: Option<&serde_json::Value>) -> bool {
|
|
match r {
|
|
None | Some(serde_json::Value::Null) => true,
|
|
Some(serde_json::Value::Object(m)) => m.is_empty(),
|
|
Some(_) => false,
|
|
}
|
|
}
|
|
|
|
pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, 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 ref_slot_is_open(s.get("ref")) {
|
|
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 ref_slot_is_open(v.get("process").and_then(|p| p.get("ref"))) {
|
|
// deliberately narrower than the strategy-ref hint: validate_campaign
|
|
// refuses an identity_id process ref, so the guide must not offer it
|
|
slots.push(open("process.ref", "required, content id of a process document"));
|
|
}
|
|
if v.get("seed").and_then(|s| s.as_u64()).is_none() {
|
|
slots.push(open("seed", "required, non-negative integer"));
|
|
}
|
|
if v.get("presentation").is_none() {
|
|
slots.push(open(
|
|
"presentation",
|
|
format!(
|
|
"required section: persist_taps ({}) + emit",
|
|
tap_vocabulary().join(" | ")
|
|
),
|
|
));
|
|
}
|
|
Ok(slots)
|
|
}
|
|
|
|
fn envelope_open_slots(v: &serde_json::Value, kind: &str, slots: &mut Vec<OpenSlot>) {
|
|
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", "in_sample_ms": 4000, "out_of_sample_ms": 1000,
|
|
"step_ms": 1000, "mode": "rolling", "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 { selection } => {
|
|
let sel = selection.as_ref().expect("fixture sweep carries a selection");
|
|
assert_eq!(sel.metric, "net_expectancy_r");
|
|
assert_eq!(sel.select, SelectRule::PlateauWorst);
|
|
assert!(sel.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:?}"),
|
|
}
|
|
match &doc.pipeline[2] {
|
|
StageBlock::WalkForward {
|
|
in_sample_ms,
|
|
out_of_sample_ms,
|
|
step_ms,
|
|
mode,
|
|
metric,
|
|
select,
|
|
} => {
|
|
assert_eq!(*in_sample_ms, 4000);
|
|
assert_eq!(*out_of_sample_ms, 1000);
|
|
assert_eq!(*step_ms, 1000);
|
|
assert_eq!(*mode, WfMode::Rolling);
|
|
assert_eq!(metric, "net_expectancy_r");
|
|
assert_eq!(*select, SelectRule::Argmax);
|
|
}
|
|
other => panic!("stage 2 is not a walk_forward: {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","in_sample_ms":4000,"out_of_sample_ms":1000,"step_ms":1000,"#,
|
|
r#""mode":"rolling","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]"#));
|
|
}
|
|
|
|
/// The intrinsic tier does NOT constrain where a population-transforming
|
|
/// stage sits relative to the annotators (terminal_seen gates only Gate
|
|
/// stages): `[sweep, monte_carlo, walk_forward]` is intrinsically valid.
|
|
/// Execution-shape refusals are the executor preflight's job, one tier up
|
|
/// — this pin is the boundary between the two tiers.
|
|
#[test]
|
|
fn validate_process_permits_walk_forward_after_an_annotator() {
|
|
let doc = parse_process(
|
|
r#"{ "format_version": 1, "kind": "process", "name": "wf-after-mc",
|
|
"pipeline": [
|
|
{ "block": "std::sweep", "metric": "sqn", "select": "argmax" },
|
|
{ "block": "std::monte_carlo", "resamples": 100, "block_len": 5 },
|
|
{ "block": "std::walk_forward", "in_sample_ms": 4000, "out_of_sample_ms": 1000,
|
|
"step_ms": 1000, "mode": "rolling", "metric": "sqn", "select": "argmax" }
|
|
] }"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(validate_process(&doc), Vec::new());
|
|
}
|
|
|
|
#[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 }));
|
|
}
|
|
|
|
/// The intrinsic tier's metric-validation branch for `std::sweep` must
|
|
/// skip a `None` selection cleanly (no metric to resolve, so no spurious
|
|
/// `UnknownMetric`) — the terminal-placement rule itself is the
|
|
/// executor preflight's job, not this validator's.
|
|
#[test]
|
|
fn validate_process_accepts_a_selection_free_sweep() {
|
|
let doc = ProcessDoc {
|
|
format_version: FORMAT_VERSION,
|
|
kind: DocKind::Process,
|
|
name: "sweep-only".to_string(),
|
|
description: None,
|
|
pipeline: vec![StageBlock::Sweep { selection: None }],
|
|
};
|
|
assert_eq!(validate_process(&doc), Vec::new());
|
|
}
|
|
|
|
#[test]
|
|
fn walk_forward_parses_both_modes_and_refuses_bad_slots() {
|
|
// "anchored" is the other accepted wire mode
|
|
let anchored = PROCESS_FIXTURE.replacen("\"mode\": \"rolling\"", "\"mode\": \"anchored\"", 1);
|
|
let doc = parse_process(&anchored).expect("anchored parses");
|
|
assert!(matches!(
|
|
&doc.pipeline[2],
|
|
StageBlock::WalkForward { mode: WfMode::Anchored, .. }
|
|
));
|
|
// an unknown mode string refuses through WfMode's serde oracle
|
|
let bad_mode = PROCESS_FIXTURE.replacen("\"mode\": \"rolling\"", "\"mode\": \"expanding\"", 1);
|
|
let err = parse_process(&bad_mode).expect_err("unknown mode refused");
|
|
assert!(matches!(err, DocError::Malformed(msg) if msg.contains("unknown mode \"expanding\"")));
|
|
// the retired 0106 vocabulary ("folds") is an unknown slot now
|
|
let folds = PROCESS_FIXTURE.replacen("\"in_sample_ms\": 4000", "\"folds\": 4", 1);
|
|
let err = parse_process(&folds).expect_err("folds refused");
|
|
assert!(matches!(err, DocError::Malformed(msg) if msg.contains("unknown slot \"folds\"")));
|
|
// a missing required length slot is named
|
|
let missing = PROCESS_FIXTURE.replacen("\"step_ms\": 1000, ", "", 1);
|
|
let err = parse_process(&missing).expect_err("missing step_ms refused");
|
|
assert!(
|
|
matches!(err, DocError::Malformed(msg) if msg.contains("missing required slot \"step_ms\""))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_process_reports_each_zero_walk_forward_length() {
|
|
let ok = parse_process(PROCESS_FIXTURE).unwrap();
|
|
let zeroed = ProcessDoc {
|
|
pipeline: vec![
|
|
ok.pipeline[0].clone(),
|
|
StageBlock::WalkForward {
|
|
in_sample_ms: 0,
|
|
out_of_sample_ms: 0,
|
|
step_ms: 0,
|
|
mode: WfMode::Rolling,
|
|
metric: "net_expectancy_r".into(),
|
|
select: SelectRule::Argmax,
|
|
},
|
|
],
|
|
..ok.clone()
|
|
};
|
|
assert_eq!(
|
|
validate_process(&zeroed),
|
|
vec![
|
|
DocFault::ZeroWalkForwardLength { stage: 1, field: "in_sample_ms" },
|
|
DocFault::ZeroWalkForwardLength { stage: 1, field: "out_of_sample_ms" },
|
|
DocFault::ZeroWalkForwardLength { stage: 1, field: "step_ms" },
|
|
]
|
|
);
|
|
let one = ProcessDoc {
|
|
pipeline: vec![
|
|
ok.pipeline[0].clone(),
|
|
StageBlock::WalkForward {
|
|
in_sample_ms: 4000,
|
|
out_of_sample_ms: 1000,
|
|
step_ms: 0,
|
|
mode: WfMode::Anchored,
|
|
metric: "net_expectancy_r".into(),
|
|
select: SelectRule::Argmax,
|
|
},
|
|
],
|
|
..ok.clone()
|
|
};
|
|
assert_eq!(
|
|
validate_process(&one),
|
|
vec![DocFault::ZeroWalkForwardLength { stage: 1, field: "step_ms" }]
|
|
);
|
|
}
|
|
|
|
#[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())));
|
|
}
|
|
|
|
/// #201 decision 1 (cycle 0109): the tap namespace is a CLOSED vocabulary —
|
|
/// the wrap convention's four persisted sink names. A genuinely new
|
|
/// observable is a new vocabulary entry (or an authored blueprint sink),
|
|
/// never an open node-path namespace in the document.
|
|
#[test]
|
|
fn tap_vocabulary_is_the_wrap_conventions_four_sink_names() {
|
|
assert_eq!(tap_vocabulary(), ["equity", "exposure", "r_equity", "net_r_equity"]);
|
|
}
|
|
|
|
/// Each `presentation.persist_taps` entry outside `tap_vocabulary()` is an
|
|
/// intrinsic fault carrying its index (for path-addressed prose downstream)
|
|
/// and the offending name — mirroring the UnknownEmitKind loop. The
|
|
/// fixture's own "net_r_equity" tap is in-vocabulary, so the clean-fixture
|
|
/// assert in `validate_campaign_accepts_the_fixture_and_reports_each_fault`
|
|
/// stays green unchanged.
|
|
#[test]
|
|
fn validate_campaign_refuses_unknown_taps_by_index() {
|
|
let ok = parse_campaign(CAMPAIGN_FIXTURE).unwrap();
|
|
let mut tap = ok.clone();
|
|
tap.presentation.persist_taps.push("bias".into());
|
|
assert_eq!(
|
|
validate_campaign(&tap),
|
|
vec![DocFault::UnknownTap { index: 1, tap: "bias".into() }]
|
|
);
|
|
// every in-vocabulary name passes
|
|
let mut all = ok.clone();
|
|
all.presentation.persist_taps = tap_vocabulary().iter().map(|t| t.to_string()).collect();
|
|
assert_eq!(validate_campaign(&all), Vec::new());
|
|
}
|
|
|
|
/// The `std::presentation` persist_taps slot advertises the closed tap
|
|
/// vocabulary through the describe path (`describe_block` +
|
|
/// `slot_kind_label`); the label is cross-pinned against `tap_vocabulary()`
|
|
/// so the static label and the validator cannot drift apart.
|
|
#[test]
|
|
fn persist_taps_slot_advertises_the_tap_vocabulary() {
|
|
let block = describe_block("std::presentation").expect("presentation describable");
|
|
let slot = block
|
|
.slots
|
|
.iter()
|
|
.find(|s| s.name == "persist_taps")
|
|
.expect("presentation has a persist_taps slot");
|
|
assert_eq!(slot.kind, SlotKind::TapKinds);
|
|
assert_eq!(
|
|
slot_kind_label(slot.kind),
|
|
format!("list of: {}", tap_vocabulary().join(" | "))
|
|
);
|
|
assert_eq!(
|
|
slot_kind_label(slot.kind),
|
|
"list of: equity | exposure | r_equity | net_r_equity"
|
|
);
|
|
}
|
|
|
|
/// A draft missing its `presentation` section is pointed at the section
|
|
/// AND the closed tap vocabulary (the guide never advertises an open
|
|
/// namespace the validator would refuse). The hint is cross-pinned
|
|
/// against `tap_vocabulary()` — like `persist_taps_slot_advertises_the_
|
|
/// tap_vocabulary` above — so a fifth tap cannot leave this draft hint
|
|
/// silently stale.
|
|
#[test]
|
|
fn presentation_open_slot_hint_names_the_tap_vocabulary() {
|
|
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": [10] } } } ],
|
|
"process": { "ref": { "content_id": "4e2d" } },
|
|
"seed": 1 }"#;
|
|
let slots = open_slots_campaign(draft).unwrap();
|
|
assert_eq!(
|
|
slots,
|
|
vec![open(
|
|
"presentation",
|
|
format!(
|
|
"required section: persist_taps ({}) + emit",
|
|
tap_vocabulary().join(" | ")
|
|
),
|
|
)]
|
|
);
|
|
assert_eq!(
|
|
slots,
|
|
vec![open(
|
|
"presentation",
|
|
"required section: persist_taps (equity | exposure | r_equity | net_r_equity) + emit",
|
|
)]
|
|
);
|
|
}
|
|
|
|
#[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));
|
|
let wf = describe_block("std::walk_forward").expect("walk_forward describable");
|
|
let names: Vec<&str> = wf.slots.iter().map(|s| s.name).collect();
|
|
assert_eq!(
|
|
names,
|
|
vec!["in_sample_ms", "out_of_sample_ms", "step_ms", "mode", "metric", "select"]
|
|
);
|
|
let mode = wf.slots.iter().find(|s| s.name == "mode").expect("mode slot");
|
|
assert_eq!(slot_kind_label(mode.kind), "one of: rolling | anchored");
|
|
assert!(describe_block("std::strategy").is_some());
|
|
assert!(describe_block("std::nope").is_none());
|
|
}
|
|
|
|
/// A campaign process reference is content-id-only: `validate_campaign`
|
|
/// refuses an `identity_id` process ref (`ProcessRefMustBeContentId`), and
|
|
/// the block's own doc line says "(content id)". So the `std::process_ref`
|
|
/// describe output — the `describe_block` + `slot_kind_label` composition
|
|
/// the `--block` CLI verb renders — must not advertise an `identity_id`
|
|
/// form the validator would reject. The `--unwired` hint is already
|
|
/// narrowed (see `open_slots_campaign`); this pins the parallel describe
|
|
/// path to the same content-id-only contract. The narrowing is
|
|
/// process-ref-specific: a `std::strategy` ref legitimately accepts either
|
|
/// id form and must keep advertising both.
|
|
#[test]
|
|
fn process_ref_describe_advertises_content_id_only() {
|
|
let block = describe_block("std::process_ref").expect("process_ref describable");
|
|
let ref_slot =
|
|
block.slots.iter().find(|s| s.name == "ref").expect("process_ref has a ref slot");
|
|
let label = slot_kind_label(ref_slot.kind);
|
|
assert!(
|
|
!label.contains("identity_id"),
|
|
"process_ref advertises an identity_id form the validator refuses: {label}"
|
|
);
|
|
assert!(
|
|
label.contains("content"),
|
|
"process_ref describe must still name the content-id form: {label}"
|
|
);
|
|
|
|
let strategy = describe_block("std::strategy").expect("strategy describable");
|
|
let strat_ref =
|
|
strategy.slots.iter().find(|s| s.name == "ref").expect("strategy has a ref slot");
|
|
let strat_label = slot_kind_label(strat_ref.kind);
|
|
assert!(
|
|
strat_label.contains("identity_id") && strat_label.contains("content_id"),
|
|
"strategy ref must keep advertising both id forms: {strat_label}"
|
|
);
|
|
}
|
|
|
|
#[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, content id of a process document"));
|
|
assert!(slots.iter().any(|s| s.path == "strategies[0].axes.slow"
|
|
&& s.hint == "axis declared empty — a P1 axis is a non-empty finite set"));
|
|
|
|
assert!(matches!(open_slots_process("nope"), Err(DocError::NotJson(_))));
|
|
}
|
|
|
|
/// Property: in the open-slot probe, a ref placeholder — `"ref": {}` or
|
|
/// `"ref": null` — is a drafting affordance that reports the SAME open slot
|
|
/// (same path, same existing hint) the omitted-key form reports, on both a
|
|
/// strategy entry and `process.ref`, and never aborts the probe. It lets one
|
|
/// draft file both validate-so-far and enumerate its open slots (fieldtest
|
|
/// 0106 F4 / #195). This pins only the probe; the strict `parse_campaign`
|
|
/// tier keeps refusing placeholders (canonical-form byte stability outranks
|
|
/// draft leniency) and is deliberately not exercised here.
|
|
#[test]
|
|
fn open_slots_report_placeholder_refs_as_open_slots() {
|
|
let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft",
|
|
"data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
|
|
"strategies": [ { "ref": null,
|
|
"axes": { "slow": { "kind": "I64", "values": [10, 20] } } } ],
|
|
"process": { "ref": {} },
|
|
"seed": 1,
|
|
"presentation": { "persist_taps": [], "emit": [] } }"#;
|
|
// Errors on neither placeholder form (the probe stays a probe).
|
|
let slots = open_slots_campaign(draft).expect("placeholder refs must not abort the probe");
|
|
// The `null` strategy ref reports the strategy-ref open slot with its
|
|
// existing both-id-forms hint...
|
|
assert!(
|
|
slots.iter().any(|s| s.path == "strategies[0].ref"
|
|
&& s.hint == "required, one of: content_id, identity_id"),
|
|
"strategy `ref: null` placeholder not reported as an open slot: {slots:?}"
|
|
);
|
|
// ...and the `{}` process ref reports the narrower content-id-only hint.
|
|
assert!(
|
|
slots.iter().any(|s| s.path == "process.ref"
|
|
&& s.hint == "required, content id of a process document"),
|
|
"process `ref: {{}}` placeholder not reported as an open slot: {slots:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sweep_selection_group_is_all_or_nothing() {
|
|
let free = serde_json::json!({ "block": "std::sweep" });
|
|
assert_eq!(
|
|
stage_from_value(&free).unwrap(),
|
|
StageBlock::Sweep { selection: None }
|
|
);
|
|
let metric_only = serde_json::json!({ "block": "std::sweep", "metric": "sqn" });
|
|
let err = stage_from_value(&metric_only).unwrap_err();
|
|
assert!(err.contains("all-or-nothing"), "{err}");
|
|
let select_only = serde_json::json!({ "block": "std::sweep", "select": "argmax" });
|
|
let err = stage_from_value(&select_only).unwrap_err();
|
|
assert!(err.contains("all-or-nothing"), "{err}");
|
|
let deflate_only = serde_json::json!({ "block": "std::sweep", "deflate": true });
|
|
let err = stage_from_value(&deflate_only).unwrap_err();
|
|
assert!(err.contains("needs the selection group"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn selection_free_sweep_round_trips_and_hashes_distinctly() {
|
|
let doc = ProcessDoc {
|
|
format_version: FORMAT_VERSION,
|
|
kind: DocKind::Process,
|
|
name: "sweep".to_string(),
|
|
description: None,
|
|
pipeline: vec![StageBlock::Sweep { selection: None }],
|
|
};
|
|
let text = process_to_json(&doc);
|
|
// the absent group leaves ONLY the block id on the wire
|
|
assert!(text.contains("\"block\":\"std::sweep\""), "{text}");
|
|
assert!(!text.contains("metric"), "{text}");
|
|
assert!(!text.contains("select"), "{text}");
|
|
let back = parse_process(&text).expect("selection-free sweep parses");
|
|
assert_eq!(back, doc);
|
|
// a selected sibling hashes differently
|
|
let selected = ProcessDoc {
|
|
pipeline: vec![StageBlock::Sweep {
|
|
selection: Some(SweepSelection {
|
|
metric: "sqn_normalized".to_string(),
|
|
select: SelectRule::Argmax,
|
|
deflate: false,
|
|
}),
|
|
}],
|
|
..doc.clone()
|
|
};
|
|
assert_ne!(content_id_of(&text), content_id_of(&process_to_json(&selected)));
|
|
}
|
|
}
|