feat(research,registry,campaign): tap vocabulary + trace_name record contract (0109 tasks 1-2)

Task 1: the tap namespace is a closed vocabulary — tap_vocabulary()
(the wrap convention's four sink names; escalation = new entry or an
authored blueprint sink, never an open node-path namespace) with
DocFault::UnknownTap in validate_campaign (index-addressed, prose
enumerating the vocabulary via the single source), SlotKind::TapKinds
on the persist_taps slot, open-slot hint naming the taps. The 0107
deferral seam test split into its two 0109 pins (unknown tap refuses
at validate; a valid tap passes validate and hits the member-data
seam) — the deferral eprintln itself survives until task 3.

Task 2: CampaignRunRecord.trace_name lands serde-default sparse; the
claim contract holds across the seam — execute stamps the sentinel iff
persist_taps is non-empty, append_campaign_run composes
Some("{campaign8}-{run}") onto the stored line (safe prefix take at
the registry seam), execute mirrors the same derivation onto the
returned copy; None stays None; the hand-seeded byte-identity dump pin
survives (absent -> skipped).

Gates: workspace 1037/0, clippy -D warnings clean.

refs #201
This commit is contained in:
2026-07-04 03:41:01 +02:00
parent ef7579f8fb
commit b8ef77b1c7
8 changed files with 390 additions and 27 deletions
+16 -3
View File
@@ -17,9 +17,10 @@ use aura_engine::{
WindowRun,
};
use aura_registry::{
generalization, optimize, optimize_deflated, optimize_plateau, sweep_member_reports,
walkforward_member_reports, CampaignGeneralization, CampaignRunRecord, CellRealization,
FamilyKind, PlateauMode, Registry, StageBootstrap, StageRealization, StageSelection,
derive_trace_name, generalization, optimize, optimize_deflated, optimize_plateau,
sweep_member_reports, walkforward_member_reports, CampaignGeneralization, CampaignRunRecord,
CellRealization, FamilyKind, PlateauMode, Registry, StageBootstrap, StageRealization,
StageSelection,
};
use aura_research::{Axis, CampaignDoc, DocRef, ProcessDoc, SelectRule, StageBlock, WfMode};
@@ -200,9 +201,21 @@ pub fn execute(
seed: campaign.seed,
cells: cells_rec,
generalizations,
// The claim sentinel (#201 d5): a `Some` (any content) tells
// `append_campaign_run` to compose the derived trace name onto the
// stored line; `None` means the document requests no taps.
trace_name: (!campaign.presentation.persist_taps.is_empty()).then(String::new),
};
let run = registry.append_campaign_run(&record).map_err(ExecFault::Registry)?;
// Mirror the store-side derivation onto the returned copy (the 0109
// contract: stored line and returned record carry the same
// `Some("{campaign8}-{run}")` iff persist_taps is non-empty, else None) —
// keyed off the claim already stamped above, via the registry's own
// `derive_trace_name` (the one source of truth for the composition), not
// a re-evaluation of `persist_taps`.
let trace_name = record.trace_name.is_some().then(|| derive_trace_name(campaign_id, run));
record.run = run;
record.trace_name = trace_name;
Ok(CampaignOutcome { record, run, cells: cells_out })
}
+42
View File
@@ -256,6 +256,10 @@ fn execute_sweep_only_records_family_and_selection() {
assert_eq!(runs[0].process, PROCESS_ID);
assert_eq!(runs[0].run, 0);
assert_eq!(runs[0].seed, 7);
assert_eq!(
runs[0].trace_name, None,
"empty persist_taps claims no trace name (0109 contract)"
);
assert_eq!(runs[0].cells.len(), 1);
let cell = &runs[0].cells[0];
assert_eq!(cell.strategy, STRATEGY_ID);
@@ -685,3 +689,41 @@ fn execute_mc_and_generalize_compose_in_one_pipeline() {
let runs = reg.load_campaign_runs().expect("load campaign runs");
assert_eq!(runs[0], out.record);
}
/// The 0109 name contract (spec seam note / #201 d5): `execute` claims a
/// TraceStore family name iff the document requests persist_taps — the
/// RETURNED record and the STORED line both carry the same derived
/// `Some("{campaign8}-{run}")` (`append_campaign_run` composes it onto the
/// stored line from the claim sentinel; `execute` mirrors the same
/// derivation onto the returned copy after the counter is assigned). The
/// empty-taps `None` side is pinned on
/// `execute_sweep_only_records_family_and_selection`.
#[test]
fn execute_persist_taps_stamps_trace_name() {
let reg = temp_registry("persist_taps_trace_name");
let mut doc = campaign(&["EURUSD"]);
doc.presentation.persist_taps = vec!["equity".to_string()];
let proc_doc = process(vec![sweep_stage(false)]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.expect("persist_taps campaign executes");
// returned record: the derived name over the first run counter
let expected = format!("{}-0", &CAMPAIGN_ID[..8]);
assert_eq!(
out.record.trace_name.as_deref(),
Some(expected.as_str()),
"the returned record carries the derived trace name",
);
// the stored line agrees — in the field and as a whole record
let runs = reg.load_campaign_runs().expect("load campaign runs");
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].trace_name.as_deref(), Some(expected.as_str()));
assert_eq!(runs[0], out.record, "stored line and returned record are the same record");
// a second run of the same campaign derives the next counter's name
let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.expect("second persist_taps run");
let expected_1 = format!("{}-1", &CAMPAIGN_ID[..8]);
assert_eq!(second.record.trace_name.as_deref(), Some(expected_1.as_str()));
}
+22 -2
View File
@@ -8,8 +8,8 @@ use std::path::PathBuf;
use aura_research::{
campaign_to_json, campaign_vocabulary, describe_block, open_slots_campaign,
open_slots_process, parse_campaign, parse_process, process_to_json, process_vocabulary,
slot_kind_label, validate_campaign, validate_process, CampaignDoc, DocError, DocFault,
DocKind, ProcessDoc, StageBlock,
slot_kind_label, tap_vocabulary, validate_campaign, validate_process, CampaignDoc, DocError,
DocFault, DocKind, ProcessDoc, StageBlock,
};
use aura_registry::RefFault;
@@ -147,6 +147,10 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
format!("strategies[{strategy}].axes.{axis}: an axis is a non-empty finite set")
}
DocFault::UnknownEmitKind(kind) => format!("presentation.emit: unknown kind \"{kind}\""),
DocFault::UnknownTap { index, tap } => format!(
"presentation.persist_taps[{index}]: unknown tap \"{tap}\" (taps: {})",
tap_vocabulary().join(" | ")
),
DocFault::ProcessRefMustBeContentId => {
"process.ref: a process is referenced by content id in this version".into()
}
@@ -593,4 +597,20 @@ mod tests {
assert_eq!(prose, "pipeline[2]: walk_forward step_ms must be > 0");
assert!(!prose.contains("ZeroWalkForwardLength"), "Debug leak: {prose}");
}
#[test]
/// #201 decision 1 (spec 0109): the unknown-tap refusal is path-addressed
/// (`presentation.persist_taps[i]`) and enumerates the closed vocabulary
/// inline — four names are small enough to print, so no introspection
/// pointer is needed (contrast the 17-name metric roster, which points at
/// `aura process introspect --metrics` instead).
fn unknown_tap_prose_is_path_addressed_and_enumerates_the_vocabulary() {
let prose = doc_fault_prose(&DocFault::UnknownTap { index: 0, tap: "bias".into() });
assert_eq!(
prose,
"presentation.persist_taps[0]: unknown tap \"bias\" \
(taps: equity | exposure | r_equity | net_r_equity)"
);
assert!(!prose.contains("UnknownTap"), "Debug leak: {prose}");
}
}
+117 -10
View File
@@ -629,6 +629,25 @@ fn campaign_introspect_metrics_also_lists_the_vocabulary() {
);
}
/// 0109 task 1 (#201 decision 1): the closed tap vocabulary is discoverable
/// through the actual CLI binary, not just the `aura-research` library —
/// `campaign introspect --block std::presentation` composes
/// `describe_block` + `slot_kind_label` over the new `SlotKind::TapKinds`,
/// and this pins that composition reaches stdout with the exact four names,
/// so a future edit to `tap_vocabulary()` that forgets the CLI-visible label
/// (there is no other CLI test exercising `--block std::presentation`) fails
/// here instead of only in a library-level unit test.
#[test]
fn campaign_introspect_block_presentation_names_the_tap_vocabulary() {
let (out, code) = run_code(&["campaign", "introspect", "--block", "std::presentation"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
assert!(
out.contains("list of: equity | exposure | r_equity | net_r_equity"),
"the block description must name the closed tap vocabulary: {out}"
);
assert!(out.contains("persist_taps"), "stdout/stderr: {out}");
}
/// Register must be a gate, not a passthrough: an invalid document is
/// refused with prose (exit 1) and — the property that matters for a
/// content-addressed store — no file is ever written under `runs/campaigns/`
@@ -900,6 +919,42 @@ fn campaign_runs_lists_and_dumps_the_bare_stored_record() {
);
}
/// 0109 task 2: `campaign runs <id>` dumps by deserializing the stored line
/// into `CampaignRunRecord` and re-serializing it (`research_docs.rs`'s
/// `campaign_runs` — "serde round-trip == the stored bytes"), so the
/// byte-identical contract `campaign_runs_lists_and_dumps_the_bare_stored_record`
/// pins above is only proven for a record where `trace_name` is absent
/// (pre-0109 shape). This is the same round-trip pinned with the new field
/// POPULATED: a stored line carrying `"trace_name":"deadbeef-3"` must dump
/// back byte-identical — the new field's declaration position (after
/// `generalizations`) and its `skip_serializing_if` must not perturb the
/// existing sparse-skip / declaration-order contract the read-back verb
/// depends on.
#[test]
fn campaign_runs_dump_round_trips_a_populated_trace_name() {
let _fixture = project_lock();
let dir = built_project();
let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok();
std::fs::create_dir_all(&runs_dir).expect("create runs dir");
let _cleanup = ScratchGuard(vec![ScratchPath::Dir(runs_dir.clone())]);
let campaign_id = "deadbeef".repeat(8);
let record_line = format!(
r#"{{"campaign":"{campaign_id}","process":"beef","run":3,"seed":42,"cells":[{{"strategy":"3f9c","instrument":"GER40","window_ms":[1725148800000,1727740799999],"stages":[{{"block":"std::sweep","family_id":"sweep-0"}}]}}],"trace_name":"deadbeef-3"}}"#
);
std::fs::write(runs_dir.join("campaign_runs.jsonl"), format!("{record_line}\n"))
.expect("seed the campaign-run store");
let (dump_out, dump_code) = run_code_in(dir, &["campaign", "runs", &campaign_id]);
assert_eq!(dump_code, Some(0), "dump exits 0: {dump_out}");
assert_eq!(
dump_out.trim(),
record_line,
"a populated trace_name round-trips byte-identical through the read-back verb: {dump_out}"
);
}
/// The v2 boundary, order side: `[sweep, monte_carlo, walk_forward]` is
/// intrinsically valid — `process register` ACCEPTS it (asserted inside
/// `register_process_doc`; the intrinsic-tier ground is the shipped
@@ -1203,14 +1258,62 @@ fn campaign_run_refuses_zero_resamples_monte_carlo_before_any_member_runs() {
assert!(!out.contains("ZeroBootstrapParam"), "Debug leak: {out}");
}
/// Non-empty persist_taps defers LOUDLY, and the note's ORDER is pinned: it
/// prints before member execution, so it is asserted here on a run
/// that refuses at the member-data seam. The [1, 2] epoch-ms window (1970)
/// makes that refusal deterministic on every machine: a data-less host
/// refuses on missing geometry, a data-ful host on a window no archive file
/// overlaps — exit 1 either way, tap note already on stderr.
/// 0109 split (a) — the tap namespace is CLOSED: the 0107 deferral fixture's
/// made-up "r_record" tap now refuses INTRINSICALLY, exit 1, path-addressed
/// and vocabulary-enumerating. The old test drove `campaign run` on a file
/// target, so this pin drives the same path: the refusal fires at the run's
/// parse-valid gate (`parse_valid_campaign` — the same `validate_campaign` +
/// `doc_fault_prose` composition `campaign validate` shares), before
/// registration, before the referential tier, before any member runs — and
/// therefore before the (Task-3-doomed) loud-deferral note, whose absence is
/// pinned here.
#[test]
fn campaign_run_persist_taps_deferred_loudly() {
fn campaign_run_refuses_unknown_tap_at_validate() {
let _fixture = project_lock();
let dir = built_project();
let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok();
let _cleanup = ScratchGuard(vec![
ScratchPath::Dir(runs_dir.clone()),
ScratchPath::File(dir.join("badtap.process.json")),
ScratchPath::File(dir.join("badtap.campaign.json")),
]);
let bp_id = seed_blueprint(dir, "campaign-run-badtap-seed");
let proc_id = register_process_doc(dir, "badtap.process.json", SWEEP_ONLY_PROCESS_DOC);
write_doc(
dir,
"badtap.campaign.json",
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"r_record\"", ""),
);
let (out, code) = run_code_in(dir, &["campaign", "run", "badtap.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("campaign document invalid:"), "stdout/stderr: {out}");
assert!(
out.contains(
"presentation.persist_taps[0]: unknown tap \"r_record\" \
(taps: equity | exposure | r_equity | net_r_equity)"
),
"the refusal is path-addressed and enumerates the vocabulary: {out}"
);
assert!(
!out.contains("not yet honored"),
"an invalid document must never reach the deferral note: {out}"
);
assert!(!out.contains("UnknownTap"), "Debug leak: {out}");
}
/// 0109 split (b) — the control: an in-vocabulary tap ("equity") sails
/// through the new intrinsic gate (no UnknownTap prose) and the run proceeds
/// to refuse at the member-data seam, exactly as a tap-less run over the
/// [1, 2] 1970 epoch-ms window does (data-less host: missing geometry;
/// data-ful host: no archive file overlaps) — exit 1 either way, and no
/// trace output (tracing never starts on a run that dies before members;
/// there is no "traces persisted" summary line). NOTE: the 0107
/// loud-deferral eprintln still fires in this task's tree and is
/// deliberately NOT asserted either way here — Task 3 deletes it and
/// tightens this test to pin its absence.
#[test]
fn campaign_run_valid_tap_reaches_the_member_data_seam() {
let _fixture = project_lock();
let dir = built_project();
let runs_dir = dir.join("runs");
@@ -1225,18 +1328,22 @@ fn campaign_run_persist_taps_deferred_loudly() {
write_doc(
dir,
"taps.campaign.json",
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"r_record\"", ""),
&campaign_doc_json(&bp_id, &proc_id, (1, 2), "\"equity\"", ""),
);
let (out, code) = run_code_in(dir, &["campaign", "run", "taps.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(
out.contains("aura: persist_taps not yet honored (1 tap(s) ignored)"),
"the tap note must precede the member-data refusal: {out}"
!out.contains("unknown tap"),
"\"equity\" is in the closed vocabulary and must pass validate: {out}"
);
assert!(
out.contains("no recorded geometry") || out.contains("no data for instrument"),
"the refusal names the data condition: {out}"
);
assert!(
!out.contains("traces persisted"),
"no trace summary on a run that never reached a member: {out}"
);
}
/// Gated real-data e2e (the `cli_run.rs` skip idiom): a full
+49 -6
View File
@@ -29,9 +29,9 @@ mod compat;
mod lineage;
pub use lineage::{
group_families, mc_member_reports, sweep_member_reports, walkforward_member_reports,
CampaignGeneralization, CampaignRunRecord, CellRealization, Family, FamilyKind,
FamilyRunRecord, StageBootstrap, StageRealization, StageSelection,
derive_trace_name, group_families, mc_member_reports, sweep_member_reports,
walkforward_member_reports, CampaignGeneralization, CampaignRunRecord, CellRealization,
Family, FamilyKind, FamilyRunRecord, StageBootstrap, StageRealization, StageSelection,
};
mod trace_store;
@@ -1711,6 +1711,7 @@ mod tests {
seed: 7,
cells: vec![],
generalizations: vec![],
trace_name: None,
}
}
@@ -1790,6 +1791,7 @@ mod tests {
],
}],
generalizations: vec![],
trace_name: None,
};
let run = reg.append_campaign_run(&record).expect("append");
assert_eq!(run, 0);
@@ -1865,6 +1867,7 @@ mod tests {
],
missing: vec!["US500".to_string()],
}],
trace_name: None,
};
let run = reg.append_campaign_run(&record).expect("append annotator record");
assert_eq!(run, 0);
@@ -1875,9 +1878,10 @@ mod tests {
);
}
/// A pre-0108 stored line — no `bootstrap`, no `generalizations` — still
/// parses (the C14/C23 serde-default widening convention): existing
/// `campaign_runs.jsonl` stores survive the record widening unchanged.
/// A pre-0109 stored line — no `bootstrap`, no `generalizations`, no
/// `trace_name` — still parses (the C14/C23 serde-default widening
/// convention): existing `campaign_runs.jsonl` stores survive the record
/// widening unchanged.
#[test]
fn campaign_run_line_without_new_fields_still_parses() {
let path = temp_family_dir("campaign_run_pre_widening");
@@ -1889,5 +1893,44 @@ mod tests {
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].cells[0].stages[0].bootstrap, None);
assert!(loaded[0].generalizations.is_empty());
assert_eq!(
loaded[0].trace_name, None,
"pre-0109 line: an absent trace_name parses to None"
);
}
/// The 0109 name composition (#201 d5 / the spec's seam note): a `Some`
/// trace_name on the input record — the executor's claim sentinel,
/// content ignored — is replaced on the STORED line with the derived
/// `"{campaign8}-{run}"` (prefix from the record's own `campaign`, run
/// from the store's per-campaign counter); `None` stays `None`. The
/// executor guarantees a 64-hex campaign id, but the registry seam must
/// not panic on a shorter one — the safe-prefix case is pinned here.
#[test]
fn append_campaign_run_composes_trace_name_from_a_claim() {
let path = temp_family_dir("campaign_run_trace_name");
let reg = Registry::open(&path);
let long = "aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999";
// claim sentinel (empty string) -> derived name, per-campaign counter
let mut claimed = campaign_run_record(long);
claimed.trace_name = Some(String::new());
assert_eq!(reg.append_campaign_run(&claimed).expect("append claim 0"), 0);
assert_eq!(reg.append_campaign_run(&claimed).expect("append claim 1"), 1);
// no claim -> None stays None
assert_eq!(reg.append_campaign_run(&campaign_run_record(long)).expect("append plain"), 2);
// shorter-than-8-chars campaign id: composes from the whole id, no panic
let mut short = campaign_run_record("abc");
short.trace_name = Some("sentinel content is ignored".to_string());
assert_eq!(reg.append_campaign_run(&short).expect("append short-id claim"), 0);
let stored = reg.load_campaign_runs().expect("load");
assert_eq!(
stored.iter().map(|r| r.trace_name.as_deref()).collect::<Vec<_>>(),
vec![Some("aaaabbbb-0"), Some("aaaabbbb-1"), None, Some("abc-0")],
"a claim composes {{campaign8}}-{{run}} on the stored line; None stays None",
);
}
}
+31 -4
View File
@@ -87,6 +87,15 @@ pub struct CampaignRunRecord {
/// serde-default widening keeps existing stored lines parseable.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub generalizations: Vec<CampaignGeneralization>,
/// The TraceStore family name this realization claims when the document
/// requests persist_taps — a pure derivation ("{campaign8}-{run}"): the
/// executor hands a claim sentinel (`Some`, any content) and
/// [`Registry::append_campaign_run`] composes the derived name onto the
/// stored line; the consumer persists the bytes (#201 d5). `None` when
/// the document requests no taps. Absent pre-0109 — the serde-default
/// widening keeps existing stored lines parseable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trace_name: Option<String>,
}
/// One realized (strategy, instrument, window) cell: the pipeline prefix that
@@ -224,9 +233,11 @@ impl Registry {
/// [`Registry::append_family`] counter pattern), write the record carrying
/// that assigned run as ONE JSONL line to the campaign-run store (a sibling
/// of the flat runs store, `campaign_runs.jsonl`), and return the assigned
/// run. The input record's own `run` field is ignored. Reads the store once
/// to pick the run index (a read-before-write; single-process CLI
/// invocations do not race).
/// run. The input record's own `run` field is ignored; a `Some`
/// `trace_name` (the executor's claim sentinel, cycle 0109) is replaced
/// with the derived `"{campaign8}-{run}"` on the stored line, `None`
/// stays `None`. Reads the store once to pick the run index (a
/// read-before-write; single-process CLI invocations do not race).
pub fn append_campaign_run(
&self,
record: &CampaignRunRecord,
@@ -238,7 +249,10 @@ impl Registry {
fs::create_dir_all(parent)?;
}
let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?;
let stored = CampaignRunRecord { run, ..record.clone() };
// A `Some` trace_name (any content — the executor's claim sentinel,
// #201 d5) is replaced with the derived name; `None` stays `None`.
let trace_name = record.trace_name.as_ref().map(|_| derive_trace_name(&record.campaign, run));
let stored = CampaignRunRecord { run, trace_name, ..record.clone() };
let line = serde_json::to_string(&stored).expect("a finite CampaignRunRecord serializes");
writeln!(file, "{line}")?;
Ok(run)
@@ -294,6 +308,19 @@ fn next_campaign_run(campaign: &str, records: &[CampaignRunRecord]) -> usize {
.unwrap_or(0)
}
/// The TraceStore family name a campaign realization claims:
/// `"{campaign8}-{run}"` — the safe 8-char prefix of `campaign` (or the whole
/// id if shorter; the registry seam must not panic on a shorter id even
/// though the executor guarantees a 64-hex one) joined with `run` (#201 d5).
/// The single source of truth for the composition: [`Registry::
/// append_campaign_run`] calls it for the stored line, and
/// `aura-campaign::execute` calls it to mirror the same name onto the
/// returned copy.
pub fn derive_trace_name(campaign: &str, run: usize) -> String {
let prefix = campaign.get(..8).unwrap_or(campaign);
format!("{prefix}-{run}")
}
/// Group member records into families by their `(family, run)` key (first-seen
/// family order; each family's members ordinal-sorted), deriving the family's
/// `id` once at construction. The round-trip: `append_family(...)` then
@@ -77,6 +77,7 @@ fn campaign_run(campaign: &str) -> CampaignRunRecord {
}],
}],
generalizations: vec![],
trace_name: None,
}
}
+112 -2
View File
@@ -142,6 +142,8 @@ pub enum SlotKind {
ContentRef,
Axes,
EmitKinds,
/// The closed tap vocabulary (`tap_vocabulary()` entries).
TapKinds,
}
/// One typed slot of a block.
@@ -588,6 +590,14 @@ 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 {
@@ -606,6 +616,7 @@ pub enum DocFault {
EmptyAxes { strategy: usize },
EmptyAxis { strategy: usize, axis: String },
UnknownEmitKind(String),
UnknownTap { index: usize, tap: String },
ProcessRefMustBeContentId,
}
@@ -704,6 +715,11 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec<DocFault> {
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
}
@@ -741,7 +757,7 @@ pub const CAMPAIGN_SECTIONS: &[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: "persist_taps", kind: SlotKind::TapKinds, required: true },
SlotInfo { name: "emit", kind: SlotKind::EmitKinds, required: true },
],
},
@@ -763,6 +779,7 @@ pub fn slot_kind_label(kind: SlotKind) -> &'static str {
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",
}
}
@@ -917,7 +934,13 @@ pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
slots.push(open("seed", "required, non-negative integer"));
}
if v.get("presentation").is_none() {
slots.push(open("presentation", "required section: persist_taps + emit"));
slots.push(open(
"presentation",
format!(
"required section: persist_taps ({}) + emit",
tap_vocabulary().join(" | ")
),
));
}
Ok(slots)
}
@@ -1349,6 +1372,93 @@ mod tests {
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();