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:
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user