From ab3f16879b659087438355d852cf2a4ff4313705 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 23 Jul 2026 17:19:01 +0200 Subject: [PATCH] feat(runner, cli, research): metric/tap schema carriers + the C29 coverage walk C29 compile/unit seam, tasks 6-10 -- iteration 1 of the self-description plan complete: every engine-shipped vocabulary entry now carries a gate-clean one-line meaning. - aura-runner: the internal tap recording sink threads its doc (the last unthreaded NodeSchema site) -- first full workspace build since the field became required. - aura-cli scaffold: the sample node's template carries a doc line plus a maintenance comment, so every scaffolded extension crate starts gate-clean; both extension-vocabulary fixture exemplars thread real meaning lines (the load seam walks these in iteration 2). New E2E: the scaffold's own doc must pass its own gate -- an alibi doc in the template would teach the wrong pattern to every new project, and the compiler checks only the field's presence, never its shape. - aura-research: metric_vocabulary()/tap_vocabulary() promote from bare name arrays to MetricSchema/TapSchema carriers (id + doc, 17 + 4 authored lines); callers adapt to .id with byte-identical output (introspection goldens unchanged). The #190 guard keeps its triple -- the doc column adds no fourth roster site. - Coverage: tests/self_description.rs walks all five vocabularies (blocks, metrics, taps, folds, std nodes) through doc_gate -- the guard that keeps any future edit from blanking a doc. Adjudicated review residue: the 17 metric doc strings are surfaced by no CLI path yet -- deliberate; task 8 pins callers byte-identical, the surfacing belongs to the CLI self-description work (#315). Plan-cited coordinates lib.rs:1003/:1283 resolved to the real caller sites (:1019/:1299); the plan's literal test code was reconciled to the real accessors (FoldRegistry::core().roster(), Env::std()). Gates: cargo test --workspace green (0 failed, incl. the new coverage walk + scaffold E2E); cargo clippy --workspace --all-targets -- -D warnings clean. refs #316 --- .../tests/metric_vocabulary_e2e.rs | 4 +- crates/aura-cli/src/research_docs.rs | 9 ++- crates/aura-cli/src/scaffold.rs | 3 + crates/aura-cli/src/verb_sugar.rs | 4 +- .../tests/fixtures/demo-project/src/lib.rs | 1 + .../nested-nodes-project/nodecrate/src/lib.rs | 1 + crates/aura-cli/tests/project_nodes.rs | 30 ++++++++ crates/aura-cli/tests/self_description.rs | 43 +++++++++++ crates/aura-research/src/lib.rs | 77 ++++++++++++------- crates/aura-runner/src/runner.rs | 2 +- crates/aura-runner/src/tap_plan.rs | 1 + 11 files changed, 139 insertions(+), 36 deletions(-) create mode 100644 crates/aura-cli/tests/self_description.rs diff --git a/crates/aura-campaign/tests/metric_vocabulary_e2e.rs b/crates/aura-campaign/tests/metric_vocabulary_e2e.rs index e599858..72896ab 100644 --- a/crates/aura-campaign/tests/metric_vocabulary_e2e.rs +++ b/crates/aura-campaign/tests/metric_vocabulary_e2e.rs @@ -85,7 +85,7 @@ fn metric_vocabulary_covers_all_shipped_scalar_metrics() { scalar_fields.extend(selection_scalars); let vocabulary: BTreeSet = - aura_research::metric_vocabulary().iter().map(|s| s.to_string()).collect(); + aura_research::metric_vocabulary().iter().map(|m| m.id.to_string()).collect(); let missing_from_vocabulary: Vec<_> = scalar_fields.difference(&vocabulary).collect(); let stale_in_vocabulary: Vec<_> = vocabulary.difference(&scalar_fields).collect(); @@ -128,7 +128,7 @@ fn rankable_roster_is_single_sourced_and_nested() { } for name in aura_campaign::PER_MEMBER_METRICS { assert!( - aura_research::metric_vocabulary().contains(name), + aura_research::metric_vocabulary().iter().any(|m| m.id == *name), "per-member metric '{name}' missing from the research vocabulary" ); } diff --git a/crates/aura-cli/src/research_docs.rs b/crates/aura-cli/src/research_docs.rs index bdae1ed..a70f9eb 100644 --- a/crates/aura-cli/src/research_docs.rs +++ b/crates/aura-cli/src/research_docs.rs @@ -92,9 +92,10 @@ fn guard_one_mode(cmd: &DocIntrospectCmd, family: &str) { /// vocabulary, but the mode rides the shared introspect struct and answers /// for both families. fn print_metric_roster() { - for name in aura_research::metric_vocabulary() { - let rankable = aura_campaign::RANKABLE_METRICS.contains(name); - let gate = aura_campaign::PER_MEMBER_METRICS.contains(name); + for m in aura_research::metric_vocabulary() { + let name = m.id; + let rankable = aura_campaign::RANKABLE_METRICS.contains(&name); + let gate = aura_campaign::PER_MEMBER_METRICS.contains(&name); // The generalize applicability is the registry's own R-expectancy // predicate — no fourth roster site (#190/#207). let generalize = aura_registry::check_r_metric(name).is_ok(); @@ -181,7 +182,7 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String { 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(" | ") + tap_vocabulary().iter().map(|t| t.id).collect::>().join(" | ") ), DocFault::UnknownBindingColumn { role, column } => format!( "data.bindings.{role}: \"{column}\" names no archive column (columns: {})", diff --git a/crates/aura-cli/src/scaffold.rs b/crates/aura-cli/src/scaffold.rs index 324794c..a224f61 100644 --- a/crates/aura-cli/src/scaffold.rs +++ b/crates/aura-cli/src/scaffold.rs @@ -152,6 +152,8 @@ impl Scale { pub fn new(factor: f64) -> Self { Self { factor, out: [Cell::from_f64(0.0)] } } + // Replace `doc` below when renaming this sample node — it must keep + // describing the node's own behaviour, not this scaffolding step. pub fn builder() -> PrimitiveBuilder { PrimitiveBuilder::new( "__NS__::Scale", @@ -163,6 +165,7 @@ impl Scale { }], output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], params: vec![ParamSpec { name: "factor".into(), kind: ScalarKind::F64 }], + doc: "scalar gain: emits the input times the factor param", }, |p| Box::new(Scale::new(p[0].f64())), ) diff --git a/crates/aura-cli/src/verb_sugar.rs b/crates/aura-cli/src/verb_sugar.rs index 0c4b8ff..dc2b964 100644 --- a/crates/aura-cli/src/verb_sugar.rs +++ b/crates/aura-cli/src/verb_sugar.rs @@ -60,9 +60,9 @@ fn persist_taps_from(trace: bool) -> Vec { tap_vocabulary() .iter() .filter(|t| { - aura_runner::runner::tap_channel(t) != Some(aura_runner::runner::TapChannel::Net) + aura_runner::runner::tap_channel(t.id) != Some(aura_runner::runner::TapChannel::Net) }) - .map(|t| t.to_string()) + .map(|t| t.id.to_string()) .collect() } else { vec![] diff --git a/crates/aura-cli/tests/fixtures/demo-project/src/lib.rs b/crates/aura-cli/tests/fixtures/demo-project/src/lib.rs index 305aafe..e57d7b3 100644 --- a/crates/aura-cli/tests/fixtures/demo-project/src/lib.rs +++ b/crates/aura-cli/tests/fixtures/demo-project/src/lib.rs @@ -26,6 +26,7 @@ impl Identity { }], output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], params: vec![], + doc: "one-input f64 pass-through", }, |_| Box::new(Identity::new()), ) diff --git a/crates/aura-cli/tests/fixtures/nested-nodes-project/nodecrate/src/lib.rs b/crates/aura-cli/tests/fixtures/nested-nodes-project/nodecrate/src/lib.rs index 4946f33..850c8b3 100644 --- a/crates/aura-cli/tests/fixtures/nested-nodes-project/nodecrate/src/lib.rs +++ b/crates/aura-cli/tests/fixtures/nested-nodes-project/nodecrate/src/lib.rs @@ -29,6 +29,7 @@ impl Identity { }], output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], params: vec![], + doc: "one-input f64 pass-through", }, |_| Box::new(Identity::new()), ) diff --git a/crates/aura-cli/tests/project_nodes.rs b/crates/aura-cli/tests/project_nodes.rs index ba2bcb8..f53326f 100644 --- a/crates/aura-cli/tests/project_nodes.rs +++ b/crates/aura-cli/tests/project_nodes.rs @@ -211,3 +211,33 @@ fn attached_crate_namespace_resolves_after_build() { "{}", String::from_utf8_lossy(&out.stdout) ); } + +/// Property (#316 self-description): the scaffold's own seed node ships a +/// `doc:` line that itself passes the C29 shape gate (`aura_core::doc_gate`) +/// — non-empty and not a bare restatement of the type name. This exact text +/// is what a new extension author copy-pastes as the very first node of +/// their crate; if the scaffold's own doc failed the gate it would teach the +/// wrong pattern (an alibi doc) to every project built from it, and nothing +/// else would catch that — `NodeSchema.doc` is required at compile time +/// (E0063 without it) but its *shape* is never checked by the compiler. +#[test] +fn nodes_new_scaffold_doc_passes_the_doc_gate() { + let cwd = temp_cwd("doc-gate"); + assert!(aura(&["new", "lab"], &cwd).status.success()); + let proj = cwd.join("lab"); + let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../.."; + assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success()); + let lib = std::fs::read_to_string(cwd.join("lab-nodes/src/lib.rs")).unwrap(); + let doc_line = lib + .lines() + .find(|l| l.trim_start().starts_with("doc:")) + .unwrap_or_else(|| panic!("scaffold must carry a doc: field: {lib}")); + let doc_text = doc_line + .split_once('"') + .and_then(|(_, rest)| rest.rsplit_once('"')) + .map(|(text, _)| text) + .unwrap_or_else(|| panic!("doc: field must be a string literal: {doc_line}")); + aura_core::doc_gate("lab_nodes::Scale", doc_text).unwrap_or_else(|f| { + panic!("scaffold's own doc line fails its own gate: {f:?} — {doc_text:?}") + }); +} diff --git a/crates/aura-cli/tests/self_description.rs b/crates/aura-cli/tests/self_description.rs new file mode 100644 index 0000000..08a08e2 --- /dev/null +++ b/crates/aura-cli/tests/self_description.rs @@ -0,0 +1,43 @@ +//! C29 coverage: every engine-shipped vocabulary entry carries a +//! gate-passing one-line meaning. Blocks, metrics, tap slots, folds, and +//! the std node vocabulary — the compile/unit seam of the self-description +//! contract. + +use aura_core::doc_gate; + +#[test] +fn every_shipped_vocabulary_entry_passes_the_doc_gate() { + // process + campaign blocks (BlockSchema.doc — the pre-existing model) + for b in aura_research::process_vocabulary() + .iter() + .chain(aura_research::campaign_vocabulary().iter()) + { + doc_gate(b.id, b.doc) + .unwrap_or_else(|f| panic!("block {}: {:?}", b.id, f)); + } + // metrics + for m in aura_research::metric_vocabulary() { + doc_gate(m.id, m.doc) + .unwrap_or_else(|f| panic!("metric {}: {:?}", m.id, f)); + } + // tap slots + for t in aura_research::tap_vocabulary() { + doc_gate(t.id, t.doc) + .unwrap_or_else(|f| panic!("tap {}: {:?}", t.id, f)); + } + // tap folds (the #283 registry roster already carries docs) + for (name, doc) in aura_runner::tap_plan::FoldRegistry::core().roster() { + doc_gate(name, doc) + .unwrap_or_else(|f| panic!("fold {}: {:?}", name, f)); + } + // std node vocabulary: resolve each shipped type id to its schema + let env = aura_runner::project::Env::std(); + for &type_id in aura_vocabulary::std_vocabulary_types() { + let builder = env + .resolve(type_id) + .unwrap_or_else(|| panic!("std type {type_id} must resolve")); + let schema = builder.schema(); + doc_gate(type_id, schema.doc) + .unwrap_or_else(|f| panic!("node {type_id}: {:?}", f)); + } +} diff --git a/crates/aura-research/src/lib.rs b/crates/aura-research/src/lib.rs index cea174e..a44e718 100644 --- a/crates/aura-research/src/lib.rs +++ b/crates/aura-research/src/lib.rs @@ -727,6 +727,14 @@ pub fn content_id_of(canonical: &str) -> String { // Intrinsic validation (store-free). // --------------------------------------------------------------------------- +/// A shipped metric with its one-line meaning (C29). One carrier — the +/// #190 guard keeps this list, the RMetrics serde fields, and the CLI +/// count in lockstep; the doc column adds no fourth roster site. +pub struct MetricSchema { + pub id: &'static str, + pub doc: &'static str, +} + /// 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 @@ -746,25 +754,25 @@ pub fn content_id_of(canonical: &str) -> String { /// hand when the upstream field sets change (this list stays a hand-copy by /// the crate's ratified core-only isolation; #147 single-sourced the rankable /// roster elsewhere and the extended #190 guard pins this list's nesting). -pub fn metric_vocabulary() -> &'static [&'static str] { +pub fn metric_vocabulary() -> &'static [MetricSchema] { &[ - "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", + MetricSchema { id: "expectancy_r", doc: "mean realized R per closed trade — the headline E[R]" }, + MetricSchema { id: "win_rate", doc: "fraction of closed trades with positive realized R" }, + MetricSchema { id: "avg_win_r", doc: "mean realized R over winning trades" }, + MetricSchema { id: "avg_loss_r", doc: "mean realized R over losing trades" }, + MetricSchema { id: "profit_factor", doc: "gross winning R divided by absolute gross losing R" }, + MetricSchema { id: "max_r_drawdown", doc: "deepest peak-to-trough decline of the cumulative R curve" }, + MetricSchema { id: "sqn", doc: "System Quality Number: mean R over its std deviation, scaled by trade count" }, + MetricSchema { id: "sqn_normalized", doc: "SQN normalized to a 100-trade basis for n-independent comparison" }, + MetricSchema { id: "net_expectancy_r", doc: "expectancy net of the cost model's per-trade cost in R" }, + MetricSchema { id: "n_trades", doc: "count of closed trades in the run" }, + MetricSchema { id: "n_open_at_end", doc: "positions still open when the run ends" }, + MetricSchema { id: "total_pips", doc: "summed pip result over all closed trades" }, + MetricSchema { id: "max_drawdown", doc: "deepest peak-to-trough decline of the pip equity curve" }, + MetricSchema { id: "bias_sign_flips", doc: "count of sign changes in the strategy's bias stream" }, + MetricSchema { id: "deflated_score", doc: "selection score deflated for multiple testing against the bootstrap null" }, + MetricSchema { id: "overfit_probability", doc: "probability the selected edge is overfit, from the bootstrap null" }, + MetricSchema { id: "neighbourhood_score", doc: "plateau robustness: score aggregated over the param-space neighbourhood" }, ] } @@ -773,12 +781,23 @@ pub fn emit_vocabulary() -> &'static [&'static str] { &["family_table", "selection_report"] } +/// A closed-vocabulary persisted-tap slot with its one-line meaning (C29). +pub struct TapSchema { + pub id: &'static str, + pub doc: &'static str, +} + /// 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"] +pub fn tap_vocabulary() -> &'static [TapSchema] { + &[ + TapSchema { id: "equity", doc: "cumulative pip equity per cycle" }, + TapSchema { id: "exposure", doc: "signed position exposure per cycle" }, + TapSchema { id: "r_equity", doc: "cumulative gross R per cycle" }, + TapSchema { id: "net_r_equity", doc: "cumulative net R per cycle, after the cost model" }, + ] } /// The closed archive-column vocabulary a campaign `data.bindings` VALUE may @@ -824,7 +843,7 @@ pub enum DocFault { } fn is_known_metric(name: &str) -> bool { - metric_vocabulary().contains(&name) + metric_vocabulary().iter().any(|m| m.id == name) } /// Intrinsic validation of a process document (P1 constraints included). @@ -1000,7 +1019,7 @@ pub fn validate_campaign(doc: &CampaignDoc) -> Vec { } } for (i, t) in doc.presentation.persist_taps.iter().enumerate() { - if !tap_vocabulary().contains(&t.as_str()) { + if !tap_vocabulary().iter().any(|k| k.id == t.as_str()) { faults.push(DocFault::UnknownTap { index: i, tap: t.clone() }); } } @@ -1280,7 +1299,7 @@ pub fn open_slots_campaign(text: &str) -> Result, DocError> { "presentation", format!( "required section: persist_taps ({}) + emit", - tap_vocabulary().join(" | ") + tap_vocabulary().iter().map(|t| t.id).collect::>().join(" | ") ), )); } @@ -2072,7 +2091,8 @@ mod tests { /// 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"]); + let ids: Vec<&str> = tap_vocabulary().iter().map(|t| t.id).collect(); + assert_eq!(ids, ["equity", "exposure", "r_equity", "net_r_equity"]); } /// Each `presentation.persist_taps` entry outside `tap_vocabulary()` is an @@ -2092,7 +2112,7 @@ mod tests { ); // every in-vocabulary name passes let mut all = ok.clone(); - all.presentation.persist_taps = tap_vocabulary().iter().map(|t| t.to_string()).collect(); + all.presentation.persist_taps = tap_vocabulary().iter().map(|t| t.id.to_string()).collect(); assert_eq!(validate_campaign(&all), Vec::new()); } @@ -2157,7 +2177,10 @@ mod tests { assert_eq!(slot.kind, SlotKind::TapKinds); assert_eq!( slot_kind_label(slot.kind), - format!("list of: {}", tap_vocabulary().join(" | ")) + format!( + "list of: {}", + tap_vocabulary().iter().map(|t| t.id).collect::>().join(" | ") + ) ); assert_eq!( slot_kind_label(slot.kind), @@ -2188,7 +2211,7 @@ mod tests { pres.hint, format!( "required section: persist_taps ({}) + emit", - tap_vocabulary().join(" | ") + tap_vocabulary().iter().map(|t| t.id).collect::>().join(" | ") ) ); assert_eq!( diff --git a/crates/aura-runner/src/runner.rs b/crates/aura-runner/src/runner.rs index c791c15..2908e08 100644 --- a/crates/aura-runner/src/runner.rs +++ b/crates/aura-runner/src/runner.rs @@ -247,7 +247,7 @@ pub fn tap_channel(tap: &str) -> Option { debug_assert!( aura_research::tap_vocabulary() .iter() - .all(|t| matches!(*t, "equity" | "exposure" | "r_equity" | "net_r_equity")), + .all(|t| matches!(t.id, "equity" | "exposure" | "r_equity" | "net_r_equity")), "tap_vocabulary drifted from the channels persist_campaign_traces routes" ); match tap { diff --git a/crates/aura-runner/src/tap_plan.rs b/crates/aura-runner/src/tap_plan.rs index 74541ce..72467a3 100644 --- a/crates/aura-runner/src/tap_plan.rs +++ b/crates/aura-runner/src/tap_plan.rs @@ -324,6 +324,7 @@ fn tap_sink_schema(kind: ScalarKind) -> NodeSchema { inputs: vec![PortSpec { kind, firing: Firing::Any, name: "in".to_string() }], output: vec![], params: vec![], + doc: "internal recording sink bound to a declared tap", } }