From 829a1984e61b954f9df6ab9ebee6ce986581a455 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 24 Jul 2026 10:02:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(cli,=20std,=20research):=20self-describing?= =?UTF-8?q?=20surfaces=20=E2=80=94=20two-layer=20help,=20per-entry=20meani?= =?UTF-8?q?ngs,=20op=20reference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The binary is the only always-present teacher (C29); the 2026-07-22 external field test showed the help teaching nothing but the verb inventory. This cycle makes every closed vocabulary the binary ships listable with a one-line meaning, and opens the help with the concepts the field agent had to discover forensically. #315 — help + introspection: - `aura --help` opens with the two-layer concepts paragraph (research verbs as sugar over registered process/campaign documents; the directly authorable data plane; bias-as-target-position + protective-stop execution model in R; how traces come to exist). - Each document-bridged sugar verb's long help names the process shape it desugars to (sweep -> std::sweep; walkforward -> [std::grid, std::walk_forward]; mc -> + std::monte_carlo; generalize -> [std::sweep (selection), std::generalize]). `run` is not verb_sugar-bridged, so its help points at the canonical document-first form instead of claiming a desugaring it does not perform. - `graph introspect --vocabulary` lists each node type with its schema doc (bare names taught nothing); `--node `'s head line carries the meaning. - `graph introspect --folds` (new): the tap-fold vocabulary with bind rule, output kind, and meaning — it existed only as source comments. The rows live in aura-std (`fold_vocabulary`), unit-pinned against `fold_binds_at`/`fold_output_kind` so the prose cannot drift from the executable rules, and doc_gate-checked (C29 entry seam). The discovery surface is graph introspect because folds bind at graph-declared taps (C27); the document layer still refuses a folds slot pending #310. - `process introspect --metrics` lines carry each metric's meaning (the doc column existed since #316 but was never printed). - `campaign introspect --block std::presentation` lists the four persisted taps' meanings under the persist_taps slot. - The scaffolded project CLAUDE.md teaches the execution semantics (bias as held target, protective stop, R) and the document data plane. #323 — C29 authoring surfaces: - `introspect --unwired` enumerates the optional `description` envelope slot for both document kinds (absent-only, like risk/cost); the campaign lib fixture gains a description, and the risk/cost-bound e2e binds description too so "no open slots" keeps meaning complete. - `graph build --help` carries the op-list reference: all nine op kinds with fields and a worked element each (OP_REFERENCE lives beside OpDoc so a new variant is one screen from its help line). - The authoring guide's S0 worked literal is byte-pinned to the scaffold's LIB_RS template (rendered with the guide's my_lab namespace); the scaffold e2es compile that template for real, so the pin is a transitive compile guard. The template gains the guide's C29 comment, the guide gains the template's rename note — one worked example, two surfaces, zero drift. Deliberately out of scope (ratified #319, 2026-07-24): no doc-strings for the quintet's 48 flag fields — the sugar surface retires; only the pointer lines above bridge toward the document layer. Verification: full workspace suite green, clippy -D warnings clean, aura-bench exit 0 with all fingerprints OK (help_ms +1.6% — the longer help text, in tolerance), cargo doc warning count unchanged (7 pre-existing). 9 new e2e pins in tests/help_self_description.rs; guide + CLAUDE.md pins in scaffold unit tests; fold-vocabulary rules + doc_gate unit-pinned in aura-std. closes #315 closes #323 --- crates/aura-cli/src/graph_construct.rs | 44 ++++- crates/aura-cli/src/main.rs | 45 ++++- crates/aura-cli/src/research_docs.rs | 13 +- crates/aura-cli/src/scaffold.rs | 45 +++++ .../aura-cli/tests/help_self_description.rs | 165 ++++++++++++++++++ crates/aura-cli/tests/research_docs.rs | 5 +- crates/aura-research/src/lib.rs | 9 + crates/aura-std/src/lib.rs | 2 +- crates/aura-std/src/tap_fold.rs | 67 +++++++ docs/authoring-guide.md | 3 +- 10 files changed, 388 insertions(+), 10 deletions(-) create mode 100644 crates/aura-cli/tests/help_self_description.rs diff --git a/crates/aura-cli/src/graph_construct.rs b/crates/aura-cli/src/graph_construct.rs index 5b025f5..75fcb98 100644 --- a/crates/aura-cli/src/graph_construct.rs +++ b/crates/aura-cli/src/graph_construct.rs @@ -18,6 +18,31 @@ use serde::Deserialize; #[cfg(test)] use aura_vocabulary::std_vocabulary; +/// The op-list reference `aura graph build --help` appends (#323): the nine +/// op kinds with their fields and one worked element each. Lives beside +/// [`OpDoc`] so a new op variant is one screen away from its help line. +pub const OP_REFERENCE: &str = r#"Op-list reference (stdin: a JSON array of op objects, applied in order): + {"op":"source","role":"price","kind":"F64"} + declare a bound root input role of a scalar kind + {"op":"input","role":"price"} + declare an open input role (wired by an enclosing graph) + {"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}} + instantiate a node ("name" optional; "bind" maps param -> typed scalar) + {"op":"feed","role":"price","into":["fast.series","slow.series"]} + wire a role into one or more input slots + {"op":"connect","from":"fast.value","to":"sub.lhs"} + wire a node output into an input slot + {"op":"expose","from":"sub.value","as":"bias"} + name a graph output field + {"op":"tap","from":"sub.value","as":"spread"} + declare a recordable tap on a wire (expose's output-side twin) + {"op":"gang","as":"length","into":["fast.length","slow.length"]} + fuse two or more sibling params into one public knob + {"op":"doc","text":"..."} + declare the composite's one-line meaning (C29) + +Node types and their ports: aura graph introspect --vocabulary | --node "#; + /// The wire DTO for one construction op — the document's by-identifier shape, /// internally tagged by `"op"`, mapped into the serde-free engine `Op`. Bind /// values are the typed `Scalar` form (`{"I64":2}`); `kind` the capitalized @@ -201,7 +226,8 @@ pub fn build_cmd(env: &aura_runner::project::Env) { pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result { let builder = env.resolve(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?; let schema = builder.schema(); - let mut out = format!("{}\n", builder.label()); + // C29 (#315): the head line carries the node's one-line meaning. + let mut out = format!("{} — {}\n", builder.label(), schema.doc); for port in &schema.inputs { out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind)); } @@ -239,17 +265,29 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project let count = cmd.vocabulary as usize + cmd.node.is_some() as usize + cmd.unwired as usize + + cmd.folds as usize + cmd.params.is_some() as usize + (cmd.content_id.is_some() || cmd.identity_id) as usize; if count != 1 { eprintln!( - "aura: Usage: aura graph introspect --vocabulary | --node | --unwired | --params | --content-id [FILE] | --identity-id (the two id flags may be combined)" + "aura: Usage: aura graph introspect --vocabulary | --node | --unwired | --folds | --params | --content-id [FILE] | --identity-id (the two id flags may be combined)" ); std::process::exit(2); } if cmd.vocabulary { + // C29 (#315): each type id carries its schema's one-line meaning — + // bare names teach nothing (What do Latch, When, Select, Bias do?). for t in env.type_ids() { - println!("{t}"); + match env.resolve(t) { + Some(b) => println!("{t:<18} {}", b.schema().doc), + None => println!("{t}"), + } + } + } else if cmd.folds { + // The fold vocabulary binds at graph-declared taps (C27), so the + // graph introspect namespace is its discovery surface (#315). + for f in aura_std::fold_vocabulary() { + println!("{:<7} binds {:<9} out {:<9} — {}", f.id, f.binds, f.out, f.doc); } } else if let Some(type_id) = cmd.node.as_deref() { match introspect_node(type_id, env) { diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index eab7587..71aaeb4 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -1026,11 +1026,30 @@ fn version_string() -> &'static str { /// The `aura` root parser. `version` is built from `CARGO_PKG_VERSION` (the /// workspace `0.1.0`) plus the parenthesized `ENGINE_COMMIT`, so /// `aura --version` prints `aura 0.1.0 ()`. +/// The two-layer concepts paragraph `aura --help` opens with (#315): the +/// zero-setup self-description a reader without repo access gets. +const CONCEPTS_HELP: &str = "\ +Author, backtest, and validate trading strategies — research CLI. + +Two layers, one vocabulary: the research verbs (run, sweep, walkforward, mc, +generalize) are sugar — each writes registered process/campaign documents and +executes them. That document data plane is directly authorable: `aura process` +/ `aura campaign` (validate | introspect | register | run), growing a document +from a bare {} via `introspect --unwired`. + +Execution model: a strategy emits a bias in [-1,+1] per cycle, held as the +continuously-tracked target position; a protective stop defines the risk unit +R, and signal quality is measured in R. + +Traces: `sweep --real --trace` / `walkforward --real --trace` record per-cycle +taps under runs/, consumed by `aura chart` and `aura measure`."; + #[derive(Parser)] #[command( name = "aura", version = version_string(), about = "Author, backtest, and validate trading strategies — research CLI", + long_about = CONCEPTS_HELP, infer_long_args = true )] struct Cli { @@ -1044,22 +1063,42 @@ struct Cli { #[derive(Subcommand)] enum Command { /// Run a single backtest (built-in harness or a loaded blueprint). + /// + /// Part of the research sugar surface; the canonical, document-first form + /// of any research run is a registered process + campaign pair — see + /// `aura process` / `aura campaign`. Run(RunCmd), /// Render a recorded run's trace to an HTML chart. Chart(ChartCmd), /// Emit / construct / introspect a graph. Graph(GraphCmd), /// Sweep a parameter grid over a strategy or a loaded blueprint. + /// + /// Sugar over the document layer: desugars to a registered process + /// (std::sweep) + campaign pair — author the documents directly via + /// `aura process` / `aura campaign`. Sweep(SweepCmd), /// Walk-forward validation over a strategy or a loaded blueprint. Over --real, the /// fixed 90/30-day roller fits to a --from/--to window shorter than it, preserving /// the 3:1 IS:OOS ratio, instead of refusing the window outright. + /// + /// Sugar over the document layer: desugars to a registered process + /// ([std::grid, std::walk_forward]) + campaign pair — author the documents + /// directly via `aura process` / `aura campaign`. Walkforward(WalkforwardCmd), /// Grade one candidate across multiple instruments. + /// + /// Sugar over the document layer: desugars to a registered process + /// ([std::sweep (selection), std::generalize]) + campaign pair — author + /// the documents directly via `aura process` / `aura campaign`. Generalize(GeneralizeCmd), /// Monte-Carlo over synthetic draws, an R-bootstrap, or a loaded blueprint. Over /// --real, the fixed 90/30-day walk-forward roller fits to a --from/--to window /// shorter than it, preserving the 3:1 IS:OOS ratio, instead of refusing outright. + /// + /// Sugar over the document layer: desugars to a registered process + /// ([std::grid, std::walk_forward, std::monte_carlo]) + campaign pair — + /// author the documents directly via `aura process` / `aura campaign`. Mc(McCmd), /// List or inspect recorded run families. Runs(RunsCmd), @@ -1181,6 +1220,7 @@ struct GraphCmd { #[derive(Subcommand)] enum GraphSub { /// Construct a graph from a stdin op-list. + #[command(after_help = crate::graph_construct::OP_REFERENCE)] Build, /// Introspect a graph. Introspect(GraphIntrospectCmd), @@ -1193,7 +1233,7 @@ enum GraphSub { #[derive(Args)] struct GraphIntrospectCmd { - /// List the closed node vocabulary (one node type per line). + /// List the closed node vocabulary (one node type + its meaning per line). #[arg(long)] vocabulary: bool, /// Describe one node type's ports by name. @@ -1202,6 +1242,9 @@ struct GraphIntrospectCmd { /// List the graph's unwired (unbound) ports. #[arg(long)] unwired: bool, + /// List the closed tap-fold vocabulary (fold, bind rule, output kind, meaning). + #[arg(long)] + folds: bool, /// Print the graph's content id (topology hash). With FILE, read the /// document from it (a blueprint envelope or an op-list, shape- /// discriminated, #196); without FILE, read a stdin op-list as before. diff --git a/crates/aura-cli/src/research_docs.rs b/crates/aura-cli/src/research_docs.rs index cc306d1..89228ba 100644 --- a/crates/aura-cli/src/research_docs.rs +++ b/crates/aura-cli/src/research_docs.rs @@ -107,10 +107,12 @@ fn print_metric_roster() { (true, false) => "rankable", (false, false) => "annotation", }; + // C29 (#315): the roster carries each metric's one-line meaning, not + // only where it may be used. if generalize { - println!("{name:<24} {base} | generalize"); + println!("{name:<24} {base} | generalize — {}", m.doc); } else { - println!("{name:<24} {base}"); + println!("{name:<24} {base} — {}", m.doc); } } } @@ -372,6 +374,13 @@ fn describe_one_block(id: &str) -> Result<(), String> { for s in schema.slots { let req = if s.required { "required" } else { "optional" }; println!(" {:<20} {req}, {}", s.name, slot_kind_label(s.kind)); + // C29 (#315): the tap slot's closed value vocabulary carries its + // per-entry meanings, indented under the slot line. + if matches!(s.kind, aura_research::SlotKind::TapKinds) { + for t in aura_research::tap_vocabulary() { + println!(" {:<14} {}", t.id, t.doc); + } + } } Ok(()) } diff --git a/crates/aura-cli/src/scaffold.rs b/crates/aura-cli/src/scaffold.rs index 8d705de..a621a67 100644 --- a/crates/aura-cli/src/scaffold.rs +++ b/crates/aura-cli/src/scaffold.rs @@ -165,6 +165,8 @@ impl Scale { }], output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], params: vec![ParamSpec { name: "factor".into(), kind: ScalarKind::F64 }], + // C29: every vocabulary entry ships a one-line meaning — the + // doc field is required (E0063 without it) and gated at load. doc: "scalar gain: emits the input times the factor param", }, |p| Box::new(Scale::new(p[0].f64())), @@ -238,6 +240,14 @@ std vocabulary, anchored by `Aura.toml`. There is no crate and no build step. `aura nodes new ` scaffolds a node crate beside this project and attaches it via `[nodes]` in `Aura.toml` (build it with `cargo build`). - Topology is data (`blueprints/*.json`); results land in `runs/`. +- Execution model: a strategy emits a bias in [-1,+1] per cycle, held as the + continuously-tracked target position; a protective stop defines the risk + unit R, and quality metrics are R-based. Entry signals become held state + via the signal-side latch/edge-pulse idiom (see + `aura graph introspect --vocabulary`). +- Data plane: the research verbs are sugar over registered process/campaign + documents — author them directly with `aura process` / `aura campaign`, + growing one from a bare `{}` via `aura campaign introspect --unwired`. "#; /// Render a data-only project template: only `__NAME__`/`__NAME_SNAKE__` @@ -455,6 +465,41 @@ mod tests { assert!(claude.contains("demo_lab_signal.fast.length")); } + /// #315: the scaffolded project CLAUDE.md teaches the execution semantics + /// (bias as held target position, protective stop, R) and the document + /// data plane — the two things the 2026-07-22 field agent had to discover + /// forensically. + #[test] + fn project_claude_md_teaches_execution_semantics_and_the_data_plane() { + let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab"); + assert!(claude.contains("target position"), "names the held-target model: {claude}"); + assert!(claude.contains("protective stop"), "names the stop that defines R: {claude}"); + assert!(claude.contains("aura process"), "points at the document layer: {claude}"); + assert!(claude.contains("introspect --unwired"), "names the bare-{{}} ramp: {claude}"); + } + + /// #323: the authoring guide's S0 worked literal is byte-identical to the + /// scaffold's `LIB_RS` template (rendered with the guide's `my_lab` + /// namespace). The scaffold e2e tests compile that template for real, so + /// this equality pin is a transitive compile guard — the guide literal + /// can no longer drift silently from the schema (it broke against the + /// required `NodeSchema.doc` once). + #[test] + fn guide_worked_example_matches_the_scaffold_template() { + let guide = include_str!("../../../docs/authoring-guide.md"); + let anchor = "### Worked example: `Scale`"; + let after = &guide[guide.find(anchor).expect("guide keeps the S0 worked example")..]; + let start = after.find("```rust\n").expect("worked example is a rust fence") + 8; + let fence = &after[start..]; + let fence = &fence[..fence.find("\n```").expect("fence closes") + 1]; + let body = &LIB_RS[LIB_RS.find("use aura_core::{").expect("template body starts at the import")..]; + let expected = body.replace("__NS__", "my_lab"); + assert_eq!( + fence, expected, + "docs/authoring-guide.md S0 literal drifted from the compiled scaffold template" + ); + } + /// Property (#183, fieldtest F2 gap): the scaffold's own starter node teaches /// the bound-param build closure. Its rendered `src/lib.rs` declares a param in /// the node schema and reads a bound value in the build closure (not the diff --git a/crates/aura-cli/tests/help_self_description.rs b/crates/aura-cli/tests/help_self_description.rs new file mode 100644 index 0000000..5b2ece9 --- /dev/null +++ b/crates/aura-cli/tests/help_self_description.rs @@ -0,0 +1,165 @@ +//! End-to-end pins for the self-description surfaces (#315, #323): the help +//! opens with the two-layer concept, the sugar verbs name the document shape +//! they desugar to, every introspection roster carries per-entry meanings, +//! and `graph build --help` carries the op-list reference. Driven over the +//! built binary — the zero-setup surface a reader without repo access gets. + +use std::process::Command; + +const BIN: &str = env!("CARGO_BIN_EXE_aura"); + +/// Run `aura ` (no stdin); return (stdout, stderr, success). +fn run(args: &[&str]) -> (String, String, bool) { + let out = Command::new(BIN).args(args).output().expect("spawn aura"); + ( + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + out.status.success(), + ) +} + +/// #315: `aura --help` opens with the concepts paragraph — the two layers, +/// the bias-as-target-position + protective-stop execution model, and how +/// traces come to exist. Each pin sits on one authored line of the explicit +/// `long_about` string, so clap's wrapping cannot split it. +#[test] +fn top_level_help_opens_with_the_two_layer_concept() { + let (out, err, ok) = run(&["--help"]); + assert!(ok, "aura --help failed: {err}"); + assert!(out.contains("research verbs"), "names the sugar layer: {out}"); + assert!(out.contains("directly authorable"), "names the document data plane: {out}"); + assert!(out.contains("bias in [-1,+1]"), "names the bias output: {out}"); + assert!(out.contains("defines the risk unit"), "names the protective stop / R: {out}"); + assert!(out.contains("introspect --unwired"), "names the bare-{{}} ramp: {out}"); + assert!(out.contains("aura chart"), "names the trace consumers: {out}"); +} + +/// #315: each document-bridged sugar verb's long help names the process +/// shape it desugars to and points at the document layer. `run` (not +/// document-bridged) points at the canonical document-first form instead. +#[test] +fn sugar_verbs_name_their_document_shape() { + for (verb, needle) in [ + ("sweep", "std::sweep"), + ("walkforward", "std::walk_forward"), + ("mc", "std::monte_carlo"), + ("generalize", "std::generalize"), + ] { + let (out, err, ok) = run(&[verb, "--help"]); + assert!(ok, "aura {verb} --help failed: {err}"); + assert!(out.contains("Sugar"), "{verb} --help names the sugar relation: {out}"); + assert!(out.contains(needle), "{verb} --help names {needle}: {out}"); + assert!(out.contains("aura campaign"), "{verb} --help points at the documents: {out}"); + } + let (out, err, ok) = run(&["run", "--help"]); + assert!(ok, "aura run --help failed: {err}"); + assert!(out.contains("document-first"), "run --help names the canonical form: {out}"); +} + +/// #323: `graph build --help` carries the op-list reference — the op kinds +/// and fields are learnable from the binary, not only from serde refusals. +#[test] +fn graph_build_help_carries_the_op_reference() { + let (out, err, ok) = run(&["graph", "build", "--help"]); + assert!(ok, "aura graph build --help failed: {err}"); + for op in [ + r#"{"op":"source""#, + r#"{"op":"input""#, + r#"{"op":"add""#, + r#"{"op":"feed""#, + r#"{"op":"connect""#, + r#"{"op":"expose""#, + r#"{"op":"tap""#, + r#"{"op":"gang""#, + r#"{"op":"doc""#, + ] { + assert!(out.contains(op), "op reference carries {op}: {out}"); + } + assert!(out.contains("graph introspect --vocabulary"), "points at the node roster: {out}"); +} + +/// #315: the node vocabulary listing carries each type's one-line meaning — +/// no more bare names that teach nothing. +#[test] +fn graph_introspect_vocabulary_carries_meanings() { + let (out, err, ok) = run(&["graph", "introspect", "--vocabulary"]); + assert!(ok, "vocabulary listing failed: {err}"); + let sma = out + .lines() + .find(|l| l.split_whitespace().next() == Some("SMA")) + .unwrap_or_else(|| panic!("no SMA line: {out}")); + assert!(sma.contains("moving average"), "SMA line carries its meaning: {sma}"); + for l in out.lines().filter(|l| !l.trim().is_empty()) { + assert!(l.split_whitespace().count() >= 2, "bare name without meaning: {l}"); + } +} + +/// #315: `--node `'s head line carries the type's meaning beside its label. +#[test] +fn graph_introspect_node_head_line_carries_the_meaning() { + let (out, err, ok) = run(&["graph", "introspect", "--node", "SMA"]); + assert!(ok, "node introspect failed: {err}"); + let head = out.lines().next().unwrap_or(""); + assert!(head.contains("moving average"), "head line carries the meaning: {out}"); +} + +/// #315: the fold vocabulary is introspectable from the binary — name, bind +/// rule, output kind, and meaning per fold (it lived only in source comments). +#[test] +fn graph_introspect_folds_lists_the_fold_vocabulary() { + let (out, err, ok) = run(&["graph", "introspect", "--folds"]); + assert!(ok, "folds listing failed: {err}"); + let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect(); + assert_eq!(lines.len(), 7, "one line per fold kind: {out}"); + let mean = lines.iter().find(|l| l.starts_with("Mean")).expect("Mean row"); + assert!(mean.contains("f64") && mean.contains("mean"), "Mean row rules + meaning: {mean}"); + let count = lines.iter().find(|l| l.starts_with("Count")).expect("Count row"); + assert!(count.contains("i64") && count.contains("any"), "Count row rules: {count}"); +} + +/// #315: the metric roster carries each metric's one-line meaning beside its +/// applicability tags. +#[test] +fn process_introspect_metrics_carries_meanings() { + let (out, err, ok) = run(&["process", "introspect", "--metrics"]); + assert!(ok, "metric roster failed: {err}"); + let er = out + .lines() + .find(|l| l.split_whitespace().next() == Some("expectancy_r")) + .unwrap_or_else(|| panic!("no expectancy_r line: {out}")); + assert!(er.contains("mean realized R"), "meaning text on the roster line: {er}"); + for l in out.lines().filter(|l| !l.trim().is_empty()) { + assert!(l.contains(" — "), "roster line without meaning: {l}"); + } +} + +/// #315: the persisted-tap vocabulary's meanings are readable where the taps +/// are advertised — under the `std::presentation` block's slot listing. +#[test] +fn campaign_introspect_block_presentation_lists_tap_meanings() { + let (out, err, ok) = run(&["campaign", "introspect", "--block", "std::presentation"]); + assert!(ok, "presentation describe failed: {err}"); + assert!(out.contains("cumulative pip equity"), "equity tap meaning: {out}"); + assert!(out.contains("after the cost model"), "net_r_equity tap meaning: {out}"); +} + +/// #323: `introspect --unwired` enumerates the optional C29 `description` +/// slot for both document kinds — the authoring side of the gate is +/// advertised where every other slot is. +#[test] +fn introspect_unwired_lists_the_description_slot() { + let dir = std::env::temp_dir().join(format!("aura-selfdesc-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let bare = dir.join("bare.json"); + std::fs::write(&bare, "{}").unwrap(); + for family in ["process", "campaign"] { + let (out, err, ok) = run(&[family, "introspect", "--unwired", bare.to_str().unwrap()]); + assert!(ok, "{family} --unwired failed: {err}"); + assert!( + out.contains("open slot: description"), + "{family} --unwired lists the description slot: {out}" + ); + assert!(out.contains("C29-gated"), "{family} hint names the gate: {out}"); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index 1188a82..06e6b73 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -871,13 +871,14 @@ fn campaign_introspect_unwired_lists_the_optional_risk_slot_on_bare_envelope() { } /// #216/#234: bound (non-empty) risk AND cost lists close both optional -/// slots — a complete document with both reports no open slots at all. +/// slots — with the optional C29 description also present (#323), a complete +/// document reports no open slots at all. #[test] fn campaign_introspect_unwired_omits_bound_risk_and_cost_slots() { let dir = temp_cwd("campaign-introspect-unwired-risk-bound"); let with_both = CAMPAIGN_DOC.replacen( "\"seed\": 1,", - "\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 2.0 } } ],", + "\"seed\": 1,\n \"description\": \"a screen with bound risk and cost\",\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 2.0 } } ],", 1, ); assert_ne!(with_both, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field"); diff --git a/crates/aura-research/src/lib.rs b/crates/aura-research/src/lib.rs index 7079696..58a9723 100644 --- a/crates/aura-research/src/lib.rs +++ b/crates/aura-research/src/lib.rs @@ -1326,6 +1326,14 @@ fn envelope_open_slots(v: &serde_json::Value, kind: &str, slots: &mut Vec ScalarKind { } } +/// One fold entry's introspection row (C29): id, bind/output rules in prose, +/// one-line meaning. `fold_vocabulary` is the binary's discovery surface for +/// [`FoldKind`] (`aura graph introspect --folds`, #315); the unit tests pin +/// each row's rule prose against [`fold_binds_at`] / [`fold_output_kind`] so +/// the table cannot drift from the executable rules. +pub struct FoldSchema { + pub id: &'static str, + pub kind: FoldKind, + pub binds: &'static str, + pub out: &'static str, + pub doc: &'static str, +} + +/// The closed fold vocabulary as introspection rows, in [`FoldKind`] order. +pub fn fold_vocabulary() -> &'static [FoldSchema] { + &[ + FoldSchema { id: "Count", kind: FoldKind::Count, binds: "any tap", out: "i64", doc: "number of rows the tap recorded" }, + FoldSchema { id: "Sum", kind: FoldKind::Sum, binds: "f64 taps", out: "f64", doc: "sum of the tap's recorded values" }, + FoldSchema { id: "Mean", kind: FoldKind::Mean, binds: "f64 taps", out: "f64", doc: "arithmetic mean of the tap's recorded values" }, + FoldSchema { id: "Min", kind: FoldKind::Min, binds: "f64 taps", out: "f64", doc: "smallest recorded value" }, + FoldSchema { id: "Max", kind: FoldKind::Max, binds: "f64 taps", out: "f64", doc: "largest recorded value" }, + FoldSchema { id: "First", kind: FoldKind::First, binds: "any tap", out: "tap kind", doc: "earliest recorded value, kind-preserving" }, + FoldSchema { id: "Last", kind: FoldKind::Last, binds: "any tap", out: "tap kind", doc: "latest recorded value, kind-preserving" }, + ] +} + /// Owned accumulator for every fold kind at once (a handful of words; keeping /// it total avoids a per-kind state enum). Sequential `sum += v` matches a /// slice-driven mean's operation order, so streamed and slice `Mean` agree @@ -164,6 +190,47 @@ mod tests { use aura_core::AnyColumn; use std::sync::mpsc; + /// The vocabulary rows are prose renderings of the executable rules — + /// pin them against `fold_binds_at` / `fold_output_kind` so the + /// introspection surface cannot drift from what the engine enforces. + #[test] + fn fold_vocabulary_rows_match_the_executable_rules() { + let rows = fold_vocabulary(); + assert_eq!(rows.len(), 7, "one row per FoldKind"); + for row in rows { + let f64_only = fold_binds_at(row.kind, ScalarKind::F64) + && !fold_binds_at(row.kind, ScalarKind::I64); + match row.binds { + "f64 taps" => assert!(f64_only, "{} claims f64-only but binds wider", row.id), + "any tap" => assert!( + fold_binds_at(row.kind, ScalarKind::I64) + && fold_binds_at(row.kind, ScalarKind::Bool), + "{} claims any-tap but refuses a kind", + row.id + ), + other => panic!("unknown binds prose {other:?} on {}", row.id), + } + match row.out { + "i64" => assert_eq!(fold_output_kind(row.kind, ScalarKind::F64), ScalarKind::I64), + "f64" => assert_eq!(fold_output_kind(row.kind, ScalarKind::F64), ScalarKind::F64), + "tap kind" => { + assert_eq!(fold_output_kind(row.kind, ScalarKind::F64), ScalarKind::F64); + assert_eq!(fold_output_kind(row.kind, ScalarKind::I64), ScalarKind::I64); + } + other => panic!("unknown out prose {other:?} on {}", row.id), + } + } + } + + /// C29 entry seam: every fold row ships a gate-clean one-line meaning. + #[test] + fn fold_vocabulary_docs_pass_the_doc_gate() { + for row in fold_vocabulary() { + aura_core::doc_gate(row.id, row.doc) + .unwrap_or_else(|f| panic!("fold {} doc fails the gate: {f:?}", row.id)); + } + } + /// Drive a fold over an f64 series and return the emitted rows. fn run_f64_fold(fold: FoldKind, values: &[f64]) -> Vec<(Timestamp, Vec)> { let (tx, rx) = mpsc::channel(); diff --git a/docs/authoring-guide.md b/docs/authoring-guide.md index 4ae9f26..8f0fd47 100644 --- a/docs/authoring-guide.md +++ b/docs/authoring-guide.md @@ -122,7 +122,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( "my_lab::Scale",