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
This commit is contained in:
@@ -85,7 +85,7 @@ fn metric_vocabulary_covers_all_shipped_scalar_metrics() {
|
||||
scalar_fields.extend(selection_scalars);
|
||||
|
||||
let vocabulary: BTreeSet<String> =
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>().join(" | ")
|
||||
),
|
||||
DocFault::UnknownBindingColumn { role, column } => format!(
|
||||
"data.bindings.{role}: \"{column}\" names no archive column (columns: {})",
|
||||
|
||||
@@ -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())),
|
||||
)
|
||||
|
||||
@@ -60,9 +60,9 @@ fn persist_taps_from(trace: bool) -> Vec<String> {
|
||||
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![]
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
@@ -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:?}")
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<DocFault> {
|
||||
}
|
||||
}
|
||||
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<Vec<OpenSlot>, DocError> {
|
||||
"presentation",
|
||||
format!(
|
||||
"required section: persist_taps ({}) + emit",
|
||||
tap_vocabulary().join(" | ")
|
||||
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().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::<Vec<_>>().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::<Vec<_>>().join(" | ")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -247,7 +247,7 @@ pub fn tap_channel(tap: &str) -> Option<TapChannel> {
|
||||
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 {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user