diff --git a/crates/aura-campaign/src/exec.rs b/crates/aura-campaign/src/exec.rs index 1a658a1..d5403e6 100644 --- a/crates/aura-campaign/src/exec.rs +++ b/crates/aura-campaign/src/exec.rs @@ -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 }) } diff --git a/crates/aura-campaign/tests/execute.rs b/crates/aura-campaign/tests/execute.rs index 77f9ed8..8c3d614 100644 --- a/crates/aura-campaign/tests/execute.rs +++ b/crates/aura-campaign/tests/execute.rs @@ -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(), ®) + .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(), ®) + .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())); +} diff --git a/crates/aura-cli/src/research_docs.rs b/crates/aura-cli/src/research_docs.rs index 4056abb..edc5022 100644 --- a/crates/aura-cli/src/research_docs.rs +++ b/crates/aura-cli/src/research_docs.rs @@ -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}"); + } } diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index 84d991c..b872817 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -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 ` 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 diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index bfdd0a5..5e77e3c 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -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![Some("aaaabbbb-0"), Some("aaaabbbb-1"), None, Some("abc-0")], + "a claim composes {{campaign8}}-{{run}} on the stored line; None stays None", + ); } } diff --git a/crates/aura-registry/src/lineage.rs b/crates/aura-registry/src/lineage.rs index 410ee06..3f0c1e5 100644 --- a/crates/aura-registry/src/lineage.rs +++ b/crates/aura-registry/src/lineage.rs @@ -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, + /// 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, } /// 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 diff --git a/crates/aura-registry/tests/campaign_run_store_e2e.rs b/crates/aura-registry/tests/campaign_run_store_e2e.rs index 6e671eb..d655228 100644 --- a/crates/aura-registry/tests/campaign_run_store_e2e.rs +++ b/crates/aura-registry/tests/campaign_run_store_e2e.rs @@ -77,6 +77,7 @@ fn campaign_run(campaign: &str) -> CampaignRunRecord { }], }], generalizations: vec![], + trace_name: None, } } diff --git a/crates/aura-research/src/lib.rs b/crates/aura-research/src/lib.rs index ef0a075..70f009d 100644 --- a/crates/aura-research/src/lib.rs +++ b/crates/aura-research/src/lib.rs @@ -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 { 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, 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();