Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05e9a00afc | |||
| 120d116982 | |||
| 938397295d | |||
| 98342246f6 | |||
| fa7453dd9f | |||
| 8dbca82756 | |||
| 73ad87b08a | |||
| 7943b123ae | |||
| 7cc3ce0d9e | |||
| e482f0ec35 | |||
| 1baee774bb | |||
| 9124275bf3 | |||
| d26f0c84a4 | |||
| 162bf849ce | |||
| 829a1984e6 |
@@ -18,12 +18,37 @@ 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 <T>"#;
|
||||
|
||||
/// 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
|
||||
/// `ScalarKind` form (`"F64"`) — both the #155 representations.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "lowercase")]
|
||||
#[serde(tag = "op", rename_all = "lowercase", deny_unknown_fields)]
|
||||
enum OpDoc {
|
||||
Source { role: String, kind: ScalarKind },
|
||||
Input { role: String },
|
||||
@@ -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<String, String> {
|
||||
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,34 @@ 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 <T> | --unwired | --params <FILE|ID> | --content-id [FILE] | --identity-id (the two id flags may be combined)"
|
||||
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --folds | --params <FILE|ID> | --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?).
|
||||
// A rostered id that fails to resolve is an invariant breach; better
|
||||
// loud than a silent C29-incomplete bare row.
|
||||
for t in env.type_ids() {
|
||||
println!("{t}");
|
||||
let b = env.resolve(t).expect("every rostered type id resolves to a builder");
|
||||
println!("{t:<18} {}", b.schema().doc);
|
||||
}
|
||||
} else if cmd.folds {
|
||||
// The fold vocabulary binds at graph-declared taps (C27), so the
|
||||
// graph introspect namespace is its discovery surface (#315). #332:
|
||||
// this renders the fold-REGISTRY roster — the same roster `aura run
|
||||
// --tap TAP=FOLD` resolves labels against (aura-runner's layered
|
||||
// `FoldRegistry`) — not the aura-std `FoldKind` table: labels here
|
||||
// must be exactly what `--tap` accepts (lowercase), including the
|
||||
// `record` entry that has no `FoldKind` counterpart.
|
||||
for (label, doc) in aura_runner::FoldRegistry::core().roster() {
|
||||
println!("{label:<7} — {doc}");
|
||||
}
|
||||
} else if let Some(type_id) = cmd.node.as_deref() {
|
||||
match introspect_node(type_id, env) {
|
||||
|
||||
+131
-8
@@ -46,7 +46,7 @@ use aura_measurement::information_coefficient;
|
||||
// (a sibling module reaching it via `crate::blueprint_axis_probe_reopened`,
|
||||
// the crate-root re-export this `use` gives it), not from this module itself.
|
||||
use aura_runner::member::{blueprint_axis_probe, blueprint_axis_probe_reopened, run_signal_r, wrapped_bound_names, RunData};
|
||||
use aura_runner::TapPlan;
|
||||
use aura_runner::{TapPlan, TapSubscription};
|
||||
#[cfg(test)]
|
||||
use aura_runner::member::{run_blueprint_member, wrap_r, SYNTHETIC_PIP_SIZE};
|
||||
// The family builders (blueprint sweep / walk-forward / MC), the shared
|
||||
@@ -105,6 +105,7 @@ use aura_std::Sma;
|
||||
#[cfg(test)]
|
||||
use std::sync::mpsc;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::BTreeSet;
|
||||
#[cfg(test)]
|
||||
use std::collections::BTreeMap;
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
@@ -1026,11 +1027,32 @@ 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 (<commit>)`.
|
||||
/// 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 are the convenience surface —
|
||||
over --real data, sweep, walkforward, mc, and generalize desugar to
|
||||
registered process/campaign documents and execute them (run is their
|
||||
single-backtest sibling; synthetic runs execute in-process). 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 +1066,43 @@ 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: over --real data this 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: over --real data this 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: over --real data this 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 +1224,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 +1237,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 +1246,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.
|
||||
@@ -1297,6 +1344,12 @@ struct RunCmd {
|
||||
/// Not accepted — CLI-side trace persistence is retired (see #224).
|
||||
#[arg(long)]
|
||||
trace: Option<String>,
|
||||
/// Subscribe a declared tap to a fold for this run (repeatable,
|
||||
/// TAP=FOLD; e.g. --tap signal=mean). Replaces the record-all
|
||||
/// default: only listed taps are bound, unlisted taps stay unbound.
|
||||
/// Fold roster: `aura graph introspect --folds`.
|
||||
#[arg(long = "tap", value_name = "TAP=FOLD")]
|
||||
tap: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -1421,7 +1474,7 @@ fn is_blueprint_file(arg: &Option<String>) -> Option<&str> {
|
||||
/// mirroring the old `parse_blueprint_run_args` window guard (`--from`/`--to`
|
||||
/// require `--real`; empty symbol rejected). Refuses in place (stderr + exit 2).
|
||||
fn run_data_from(real: Option<&str>, from: Option<i64>, to: Option<i64>) -> RunData {
|
||||
let usage = "Usage: aura run <blueprint.json> [--params <json-cell-array>] [--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]]";
|
||||
let usage = "Usage: aura run <blueprint.json> [--params <json-cell-array>] [--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--tap <TAP=FOLD> …]";
|
||||
match real {
|
||||
Some(s) if !s.is_empty() => RunData::Real { symbol: s.to_string(), from, to },
|
||||
Some(_) => {
|
||||
@@ -1715,18 +1768,44 @@ fn campaign_window_ms(choice: DataChoice, env: &aura_runner::project::Env) -> (i
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the run's tap plan from repeated `--tap TAP=FOLD` selections
|
||||
/// (#310). No selections → the record-all default (today's behaviour).
|
||||
/// Any selection → an explicit plan that REPLACES the default entirely:
|
||||
/// only listed taps are bound; unlisted declared taps stay unbound,
|
||||
/// which C27 defines as inert, not an error. Tap/label existence is
|
||||
/// bind_tap_plan's to validate (roster refusal / UndeclaredTap), before
|
||||
/// any store I/O.
|
||||
fn tap_plan_from_args(args: &[String]) -> Result<TapPlan, String> {
|
||||
if args.is_empty() {
|
||||
return Ok(TapPlan::record_all());
|
||||
}
|
||||
let mut plan = TapPlan::empty();
|
||||
let mut seen = BTreeSet::new();
|
||||
for raw in args {
|
||||
let (tap, label) = match raw.split_once('=') {
|
||||
Some((t, l)) if !t.is_empty() && !l.is_empty() => (t, l),
|
||||
_ => return Err(format!("--tap expects TAP=FOLD, got \"{raw}\"")),
|
||||
};
|
||||
if !seen.insert(tap.to_string()) {
|
||||
return Err(format!("--tap names tap \"{tap}\" twice"));
|
||||
}
|
||||
plan.subscribe(tap, TapSubscription::named(label));
|
||||
}
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// `aura run`: the loaded-blueprint branch (an existing `.json` first-positional) or
|
||||
/// the built-in harness-kind dispatch.
|
||||
fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) {
|
||||
match is_blueprint_file(&a.blueprint) {
|
||||
Some(path) => {
|
||||
// The loaded-blueprint grammar takes only --params/--seed/--real/--from/--to;
|
||||
// The loaded-blueprint grammar takes only --params/--seed/--real/--from/--to/--tap;
|
||||
// the built-in-only flags are rejected here (exit 2), never silently dropped —
|
||||
// mirroring the sweep/mc blueprint branches, which reject their non-branch flags
|
||||
// exhaustively (refuse-don't-guess). clap's optional `[blueprint]` positional
|
||||
// makes these structurally parseable, so the guard is re-asserted at dispatch.
|
||||
if a.trace.is_some() {
|
||||
eprintln!("aura: Usage: aura run <blueprint.json> [--params <json-cell-array>] [--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]]");
|
||||
eprintln!("aura: Usage: aura run <blueprint.json> [--params <json-cell-array>] [--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--tap <TAP=FOLD> …]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let doc = std::fs::read_to_string(path).unwrap_or_else(|e| {
|
||||
@@ -1751,6 +1830,13 @@ fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) {
|
||||
// probing a no-`bias` signal would panic on UnknownOutPort).
|
||||
let has_bias = signal.output().iter().any(|o| o.name == "bias");
|
||||
let has_tap = !signal.taps().is_empty();
|
||||
let tap_plan = match tap_plan_from_args(&a.tap) {
|
||||
Ok(p) => p,
|
||||
Err(m) => {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
if has_bias {
|
||||
// Refuse an open (free-knob) blueprint at the dispatch boundary,
|
||||
// mirroring `blueprint_mc_family`'s closed-guard: `run` bootstraps
|
||||
@@ -1773,7 +1859,7 @@ fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) {
|
||||
None => Vec::new(),
|
||||
};
|
||||
let data = run_data_from(a.real.as_deref(), a.from, a.to);
|
||||
let report = run_signal_r(signal, ¶ms, data, a.seed.unwrap_or(0), env, TapPlan::record_all());
|
||||
let report = run_signal_r(signal, ¶ms, data, a.seed.unwrap_or(0), env, tap_plan);
|
||||
println!("{}", report.to_json());
|
||||
} else if has_tap {
|
||||
// Measurement path: wrap_r-free closed guard via the signal's own
|
||||
@@ -1794,7 +1880,7 @@ fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) {
|
||||
None => Vec::new(),
|
||||
};
|
||||
let data = run_data_from(a.real.as_deref(), a.from, a.to);
|
||||
let report = run_measurement(signal, ¶ms, data, a.seed.unwrap_or(0), env, TapPlan::record_all());
|
||||
let report = run_measurement(signal, ¶ms, data, a.seed.unwrap_or(0), env, tap_plan);
|
||||
println!("{}", report.to_json());
|
||||
} else {
|
||||
eprintln!(
|
||||
@@ -1807,7 +1893,8 @@ fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) {
|
||||
None => {
|
||||
eprintln!(
|
||||
"aura: Usage: aura run <blueprint.json> [--params <json-cell-array>] \
|
||||
[--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]]"
|
||||
[--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] \
|
||||
[--tap <TAP=FOLD> …]"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
@@ -3813,5 +3900,41 @@ mod tests {
|
||||
assert_eq!(stop_length, R_SMA_STOP_LENGTH, "omitted --stop-length defaults to the regime");
|
||||
assert_eq!(stop_k, R_SMA_STOP_K, "omitted --stop-k defaults to the regime");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tap_plan_from_args_empty_is_record_all_ok() {
|
||||
assert!(tap_plan_from_args(&[]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tap_plan_from_args_refuses_a_pair_without_equals() {
|
||||
let err = match tap_plan_from_args(&["fast_tapmean".to_string()]) {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("expected an error"),
|
||||
};
|
||||
assert_eq!(err, "--tap expects TAP=FOLD, got \"fast_tapmean\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tap_plan_from_args_refuses_empty_tap_or_label() {
|
||||
assert!(tap_plan_from_args(&["=mean".to_string()]).is_err());
|
||||
assert!(tap_plan_from_args(&["fast_tap=".to_string()]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tap_plan_from_args_refuses_the_same_tap_twice() {
|
||||
let args = vec!["a=mean".to_string(), "a=last".to_string()];
|
||||
let err = match tap_plan_from_args(&args) {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("expected an error"),
|
||||
};
|
||||
assert_eq!(err, "--tap names tap \"a\" twice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tap_plan_from_args_accepts_distinct_selections() {
|
||||
let args = vec!["a=mean".to_string(), "b=record".to_string()];
|
||||
assert!(tap_plan_from_args(&args).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 <name>` 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
|
||||
|
||||
@@ -354,6 +354,21 @@ fn graph_build_bad_stdin_content_is_runtime_exit_1() {
|
||||
assert!(stderr.contains("Nope"), "still names the cause: {stderr}");
|
||||
}
|
||||
|
||||
/// Property (#326): an op-list element carrying an unknown/typo'd key (e.g.
|
||||
/// `params` where the schema expects `bind`) is refused at parse — not
|
||||
/// silently dropped. Previously the unrecognized key vanished, the field it
|
||||
/// meant to set kept its default, and the wrong graph built with zero
|
||||
/// signal. Same content-fault family as `graph_build_bad_stdin_content_is_
|
||||
/// runtime_exit_1` (both fire from the same top-level deserialize), so the
|
||||
/// exit class matches: exit 1, stderr names the offending key.
|
||||
#[test]
|
||||
fn graph_build_rejects_an_unknown_op_field() {
|
||||
let doc = r#"[{"op":"add","type":"Const","params":{"value":{"F64":1.0}}}]"#;
|
||||
let (stderr, code) = run_code(&["graph", "build"], doc);
|
||||
assert_eq!(code, Some(1), "unknown op field is a content fault -> exit 1; stderr: {stderr}");
|
||||
assert!(stderr.contains("params"), "names the unknown key: {stderr}");
|
||||
}
|
||||
|
||||
/// Property (exit-code partition, #175 iteration 2): within the SAME `graph
|
||||
/// introspect` subcommand, an invalid `--node <T>` flag VALUE is a USAGE error (a
|
||||
/// command-line fault the user must fix in argv) — exit 2 — distinct from bad stdin
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! 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 <args>` (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 <T>`'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}");
|
||||
}
|
||||
|
||||
/// #332: `--folds` renders the fold-REGISTRY roster (the same roster
|
||||
/// `aura run --tap TAP=FOLD` resolves labels against), not the aura-std
|
||||
/// `FoldKind` table — lowercase, parseable labels, including the `record`
|
||||
/// entry that has no `FoldKind` counterpart. Previously this surface printed
|
||||
/// capitalized `FoldKind` ids (`Mean`) and omitted `record`, so the help
|
||||
/// text's own discovery surface described input `--tap` refused.
|
||||
#[test]
|
||||
fn graph_introspect_folds_lists_the_fold_registry_roster() {
|
||||
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(), 8, "one line per registry entry (record + 7 folds): {out}");
|
||||
assert!(
|
||||
!out.split_whitespace().any(|w| w == "Mean"),
|
||||
"no capitalized FoldKind id leaks through: {out}"
|
||||
);
|
||||
let record = lines.iter().find(|l| l.starts_with("record ")).expect("record row");
|
||||
assert!(record.contains("series"), "record row meaning: {record}");
|
||||
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);
|
||||
}
|
||||
@@ -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");
|
||||
@@ -995,7 +996,7 @@ fn campaign_introspect_unwired_reports_the_spec_example_slots() {
|
||||
write_doc(&dir, "draft.campaign.json", draft);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "draft.campaign.json"]);
|
||||
assert_eq!(code, Some(0));
|
||||
assert!(out.contains("open slot: process.ref (required, content id of a process document)"));
|
||||
assert!(out.contains("open slot: process.ref (required, { content_id }: content id of a process document)"));
|
||||
assert!(out.contains("open slot: strategies[0].axes.slow (axis declared empty — a P1 axis is a non-empty finite set)"));
|
||||
}
|
||||
|
||||
@@ -1021,7 +1022,7 @@ fn campaign_introspect_unwired_reports_placeholder_refs() {
|
||||
"strategy `ref: null` placeholder not enumerated: {out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("open slot: process.ref (required, content id of a process document)"),
|
||||
out.contains("open slot: process.ref (required, { content_id }: content id of a process document)"),
|
||||
"process `ref: {{}}` placeholder not enumerated: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,3 +56,28 @@ fn measurement_blueprint_runs_bare_and_emits_its_tap() {
|
||||
assert_eq!(trace["tap"], "fast_tap");
|
||||
assert_eq!(trace["kinds"], serde_json::json!(["F64"]));
|
||||
}
|
||||
|
||||
/// #310: the measurement arm honours the `--tap` selector — a `last`
|
||||
/// fold lands one summary row instead of the full series.
|
||||
#[test]
|
||||
fn measurement_run_honours_the_tap_selector() {
|
||||
let cwd = temp_cwd("selector-last");
|
||||
let bp_path = cwd.join("measurement.json");
|
||||
std::fs::write(&bp_path, measurement_blueprint_json()).expect("write measurement blueprint");
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=last"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
|
||||
.expect("read fold trace");
|
||||
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
|
||||
let rows = trace["columns"][0].as_array().expect("columns[0] array");
|
||||
assert_eq!(rows.len(), 1, "a fold lands exactly one summary row");
|
||||
// last warm SMA(2) value over the synthetic stream = (1.0097 + 1.0092) / 2
|
||||
let got = rows[0].as_f64().expect("f64 row");
|
||||
let expected = (1.0097_f64 + 1.0092) / 2.0;
|
||||
assert!((got - expected).abs() < 1e-9, "last row: got {got}, expected {expected}");
|
||||
}
|
||||
|
||||
@@ -124,6 +124,21 @@ fn duplicate_tap_blueprint_json() -> String {
|
||||
serde_json::to_string(&v).expect("re-serialize duplicate-tap blueprint")
|
||||
}
|
||||
|
||||
/// The shipped `examples/r_sma.json` blueprint with two distinctly named
|
||||
/// declared taps: `fast_tap` on node 0 ("fast", SMA(2)) and `slow_tap` on
|
||||
/// node 1 ("slow") — the smallest fixture on which an explicit `--tap`
|
||||
/// plan and the record-all default can be compared file-by-file (#310).
|
||||
fn two_tap_blueprint_json() -> String {
|
||||
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
|
||||
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
|
||||
v["blueprint"]["taps"] = serde_json::json!([
|
||||
{"name": "fast_tap", "from": {"node": 0, "field": 0}},
|
||||
{"name": "slow_tap", "from": {"node": 1, "field": 0}},
|
||||
]);
|
||||
serde_json::to_string(&v).expect("re-serialize two-tap blueprint")
|
||||
}
|
||||
|
||||
/// Property: **a blueprint declaring two taps under the same name is refused
|
||||
/// on the single-run path with a named error and exit code 1, before any
|
||||
/// trace is persisted** — the CLI-owned `TapBindError::DuplicateBind` dedup
|
||||
@@ -212,3 +227,196 @@ fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
|
||||
);
|
||||
assert!(!run_dir.join("index.json").exists(), "no index — treat-as-absent crash shape");
|
||||
}
|
||||
|
||||
/// #310: `--tap fast_tap=mean` subscribes the declared tap to the `mean`
|
||||
/// fold instead of the record-all default — the trace store holds ONE
|
||||
/// summary row whose value is the mean of the full SMA(2) series, and
|
||||
/// `index.json` lists exactly the subscribed tap.
|
||||
#[test]
|
||||
fn run_tap_selector_persists_a_fold_summary_row() {
|
||||
let cwd = temp_cwd("tap-selector-mean");
|
||||
let bp_path = cwd.join("tapped_r_sma.json");
|
||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let index_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
|
||||
.expect("read index.json");
|
||||
let index: serde_json::Value = serde_json::from_str(&index_text).expect("parse index.json");
|
||||
assert_eq!(index["taps"], serde_json::json!(["fast_tap"]));
|
||||
|
||||
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
|
||||
.expect("read fold trace");
|
||||
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
|
||||
assert_eq!(trace["tap"], "fast_tap");
|
||||
let rows = trace["columns"][0].as_array().expect("columns[0] array");
|
||||
assert_eq!(rows.len(), 1, "a fold lands exactly one summary row");
|
||||
let sma: Vec<f64> = (1..R_SMA_PRICES.len())
|
||||
.map(|i| (R_SMA_PRICES[i - 1] + R_SMA_PRICES[i]) / 2.0)
|
||||
.collect();
|
||||
let expected = sma.iter().sum::<f64>() / sma.len() as f64;
|
||||
let got = rows[0].as_f64().expect("f64 row");
|
||||
assert!((got - expected).abs() < 1e-9, "mean row: got {got}, expected {expected}");
|
||||
}
|
||||
|
||||
/// #310: an all-`record` explicit plan is byte-identical to the no-flag
|
||||
/// record-all default — the selector replaces the default without
|
||||
/// changing what `record` itself writes (C1 determinism across runs).
|
||||
#[test]
|
||||
fn run_explicit_record_plan_matches_the_record_all_default() {
|
||||
let cwd_a = temp_cwd("record-default");
|
||||
let cwd_b = temp_cwd("record-explicit");
|
||||
for cwd in [&cwd_a, &cwd_b] {
|
||||
std::fs::write(cwd.join("two_tap.json"), two_tap_blueprint_json())
|
||||
.expect("write two-tap blueprint");
|
||||
}
|
||||
let run = |cwd: &std::path::Path, extra: &[&str]| {
|
||||
let mut args = vec!["run", "two_tap.json"];
|
||||
args.extend_from_slice(extra);
|
||||
let out = Command::new(BIN)
|
||||
.args(&args)
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
};
|
||||
run(&cwd_a, &[]);
|
||||
run(&cwd_b, &["--tap", "fast_tap=record", "--tap", "slow_tap=record"]);
|
||||
|
||||
for file in ["fast_tap.json", "slow_tap.json"] {
|
||||
let a = std::fs::read(cwd_a.join("runs/traces/sma_signal").join(file)).expect("default trace");
|
||||
let b = std::fs::read(cwd_b.join("runs/traces/sma_signal").join(file)).expect("explicit trace");
|
||||
assert_eq!(a, b, "{file} must be byte-identical across default and explicit record plans");
|
||||
}
|
||||
let taps_of = |cwd: &std::path::Path| -> serde_json::Value {
|
||||
let text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
|
||||
.expect("read index.json");
|
||||
serde_json::from_str::<serde_json::Value>(&text).expect("parse index.json")["taps"].clone()
|
||||
};
|
||||
assert_eq!(taps_of(&cwd_a), taps_of(&cwd_b));
|
||||
}
|
||||
|
||||
/// #310: an unknown fold label is refused through the registry's
|
||||
/// roster-enumerating refusal, before any store write.
|
||||
#[test]
|
||||
fn run_tap_selector_refuses_an_unknown_label_with_the_roster() {
|
||||
let cwd = temp_cwd("tap-selector-unknown-label");
|
||||
let bp_path = cwd.join("tapped_r_sma.json");
|
||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=medain"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(!out.status.success(), "an unknown fold label must refuse");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("medain") && stderr.contains("mean"),
|
||||
"refusal must name the label and enumerate the roster: {stderr}"
|
||||
);
|
||||
assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
|
||||
}
|
||||
|
||||
/// #310/#333: a selection naming a tap the blueprint does not declare is
|
||||
/// refused typed (UndeclaredTap), before any store write, and the refusal
|
||||
/// enumerates the blueprint's declared taps (the same way the unknown-fold
|
||||
/// refusal enumerates the fold-registry roster) — on the two-tap fixture,
|
||||
/// both `fast_tap` and `slow_tap` must be named so the author does not have
|
||||
/// to reopen the blueprint JSON.
|
||||
#[test]
|
||||
fn run_tap_selector_refuses_an_undeclared_tap() {
|
||||
let cwd = temp_cwd("tap-selector-undeclared");
|
||||
let bp_path = cwd.join("two_tap.json");
|
||||
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "nope=mean"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(!out.status.success(), "an undeclared tap must refuse");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("nope"), "refusal must name the offending tap: {stderr}");
|
||||
assert!(
|
||||
stderr.contains("fast_tap") && stderr.contains("slow_tap"),
|
||||
"refusal must enumerate the blueprint's declared taps: {stderr}"
|
||||
);
|
||||
assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
|
||||
}
|
||||
|
||||
/// #334: an explicit `--tap` plan that leaves a declared tap unbound emits
|
||||
/// the C14 benign skipped-tap note on stderr, per unbound tap, naming it —
|
||||
/// exit code stays 0 (this is a note, not a refusal). `fast_tap` (subscribed)
|
||||
/// gets no such note; `slow_tap` (left unlisted) does.
|
||||
#[test]
|
||||
fn run_tap_selector_notes_unbound_declared_taps_on_stderr() {
|
||||
let cwd = temp_cwd("tap-selector-unbound-note");
|
||||
let bp_path = cwd.join("two_tap.json");
|
||||
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("aura: note: declared tap \"slow_tap\" unbound this run"),
|
||||
"stderr must note the unbound declared tap: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("\"fast_tap\" unbound"),
|
||||
"the subscribed tap must not be noted as unbound: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #334: the record-all default (no `--tap` flag) leaves no declared tap
|
||||
/// unbound — nothing is skipped, so the note must never appear.
|
||||
#[test]
|
||||
fn run_with_no_tap_flag_emits_no_unbound_note() {
|
||||
let cwd = temp_cwd("tap-selector-default-no-note");
|
||||
let bp_path = cwd.join("two_tap.json");
|
||||
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
!stderr.contains("unbound this run"),
|
||||
"the record-all default must never note a skipped tap: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #310: a malformed or duplicate `--tap` is a usage error (exit 2)
|
||||
/// before anything runs.
|
||||
#[test]
|
||||
fn run_tap_selector_refuses_malformed_and_duplicate_pairs() {
|
||||
let cwd = temp_cwd("tap-selector-usage");
|
||||
let bp_path = cwd.join("tapped_r_sma.json");
|
||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||
for args in [
|
||||
vec!["--tap", "fast_tapmean"],
|
||||
vec!["--tap", "fast_tap=mean", "--tap", "fast_tap=last"],
|
||||
] {
|
||||
let mut full = vec!["run", bp_path.to_str().expect("utf-8 path")];
|
||||
full.extend(args);
|
||||
let out = Command::new(BIN)
|
||||
.args(&full)
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert_eq!(out.status.code(), Some(2), "usage errors exit 2");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("--tap"), "usage message names the flag: {stderr}");
|
||||
assert!(!cwd.join("runs").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1102,7 +1102,7 @@ pub fn slot_kind_label(kind: SlotKind) -> &'static str {
|
||||
SlotKind::Strings => "list of strings",
|
||||
SlotKind::Windows => "list of { from_ms, to_ms }",
|
||||
SlotKind::Ref => "one of { content_id } | { identity_id }",
|
||||
SlotKind::ContentRef => "content id of a process document",
|
||||
SlotKind::ContentRef => "{ content_id }: 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",
|
||||
@@ -1301,8 +1301,10 @@ pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
|
||||
}
|
||||
if ref_slot_is_open(v.get("process").and_then(|p| p.get("ref"))) {
|
||||
// deliberately narrower than the strategy-ref hint: validate_campaign
|
||||
// refuses an identity_id process ref, so the guide must not offer it
|
||||
slots.push(open("process.ref", "required, content id of a process document"));
|
||||
// refuses an identity_id process ref, so the guide must not offer it.
|
||||
// The tagged { content_id } shape is stated explicitly (#329) so the
|
||||
// hint cannot be misread as accepting a bare id string.
|
||||
slots.push(open("process.ref", "required, { content_id }: content id of a process document"));
|
||||
}
|
||||
if v.get("seed").and_then(|s| s.as_u64()).is_none() {
|
||||
slots.push(open("seed", "required, non-negative integer"));
|
||||
@@ -1326,6 +1328,14 @@ fn envelope_open_slots(v: &serde_json::Value, kind: &str, slots: &mut Vec<OpenSl
|
||||
if v.get("kind").and_then(|k| k.as_str()) != Some(kind) {
|
||||
slots.push(open("kind", format!("required, must be \"{kind}\"")));
|
||||
}
|
||||
// C29 (#323): the description slot is enumerable like every other
|
||||
// envelope slot — optional, so only listed while absent.
|
||||
if v.get("description").is_none() {
|
||||
slots.push(open(
|
||||
"description",
|
||||
"optional, string — a one-line meaning (C29-gated when present)",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1470,6 +1480,7 @@ mod tests {
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "ger40-momentum-screen",
|
||||
"description": "GER40 momentum screen over the fast/slow SMA grid.",
|
||||
"data": {
|
||||
"instruments": ["GER40"],
|
||||
"windows": [ { "from_ms": 1704067200000, "to_ms": 1719792000000 } ]
|
||||
@@ -2365,6 +2376,14 @@ mod tests {
|
||||
label.contains("content"),
|
||||
"process_ref describe must still name the content-id form: {label}"
|
||||
);
|
||||
// #329: the label must state the TAGGED shape ({ content_id: ... }) the
|
||||
// parser actually requires, not read like a bare id string — mirroring
|
||||
// the `{ content_id } | { identity_id }` bracket notation std::strategy
|
||||
// already uses, narrowed to the one key process.ref accepts.
|
||||
assert!(
|
||||
label.contains("{ content_id }"),
|
||||
"process_ref describe must state the tagged {{ content_id }} shape, not a bare id: {label}"
|
||||
);
|
||||
|
||||
let strategy = describe_block("std::strategy").expect("strategy describable");
|
||||
let strat_ref =
|
||||
@@ -2421,7 +2440,7 @@ mod tests {
|
||||
"presentation": { "persist_taps": [], "emit": [] } }"#;
|
||||
let slots = open_slots_campaign(draft).unwrap();
|
||||
assert!(slots.iter().any(|s| s.path == "process.ref"
|
||||
&& s.hint == "required, content id of a process document"));
|
||||
&& s.hint == "required, { content_id }: content id of a process document"));
|
||||
assert!(slots.iter().any(|s| s.path == "strategies[0].axes.slow"
|
||||
&& s.hint == "axis declared empty — a P1 axis is a non-empty finite set"));
|
||||
|
||||
@@ -2457,7 +2476,7 @@ mod tests {
|
||||
// ...and the `{}` process ref reports the narrower content-id-only hint.
|
||||
assert!(
|
||||
slots.iter().any(|s| s.path == "process.ref"
|
||||
&& s.hint == "required, content id of a process document"),
|
||||
&& s.hint == "required, { content_id }: content id of a process document"),
|
||||
"process `ref: {{}}` placeholder not reported as an open slot: {slots:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1728,9 +1728,14 @@ mod tests {
|
||||
Ok(_) => panic!("unknown tap must be refused"),
|
||||
};
|
||||
assert!(
|
||||
matches!(err, TapPlanError::UnknownTap { ref name } if name == "no_such_tap"),
|
||||
matches!(err, TapPlanError::UnknownTap { ref name, .. } if name == "no_such_tap"),
|
||||
"{err:?}"
|
||||
);
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("fast_tap"),
|
||||
"unknown-tap refusal enumerates the declared taps (fixture declares only fast_tap): {msg}"
|
||||
);
|
||||
|
||||
// Unknown label — the refusal enumerates the roster.
|
||||
let mut flat2 = tapped_r_sma().compile_with_params(&[]).expect("tapped r_sma compiles");
|
||||
|
||||
@@ -161,17 +161,21 @@ impl FoldRegistry {
|
||||
}),
|
||||
});
|
||||
for (label, doc, fold) in [
|
||||
("count", "number of warm rows (any kind; i64 row)", FoldKind::Count),
|
||||
("sum", "sum of the series (f64 taps; f64 row)", FoldKind::Sum),
|
||||
("mean", "arithmetic mean of the series (f64 taps; f64 row)", FoldKind::Mean),
|
||||
("min", "minimum of the series (f64 taps; f64 row)", FoldKind::Min),
|
||||
("max", "maximum of the series (f64 taps; f64 row)", FoldKind::Max),
|
||||
("count", "number of warm rows (any kind; i64 row); one row at the last warm ts", FoldKind::Count),
|
||||
("sum", "sum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Sum),
|
||||
(
|
||||
"mean",
|
||||
"arithmetic mean of the series (f64 taps; f64 row); one row at the last warm ts",
|
||||
FoldKind::Mean,
|
||||
),
|
||||
("min", "minimum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Min),
|
||||
("max", "maximum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Max),
|
||||
(
|
||||
"first",
|
||||
"first warm value, at its own timestamp (any kind; kind-preserving row)",
|
||||
FoldKind::First,
|
||||
),
|
||||
("last", "last warm value (any kind; kind-preserving row)", FoldKind::Last),
|
||||
("last", "last warm value, at its own timestamp (any kind; kind-preserving row)", FoldKind::Last),
|
||||
] {
|
||||
r.register(FoldEntry {
|
||||
label,
|
||||
@@ -217,7 +221,7 @@ impl FoldRegistry {
|
||||
/// `aura: ` + exit-1 refusal register via `Display`.
|
||||
pub enum TapPlanError {
|
||||
/// The plan names a tap the blueprint does not declare.
|
||||
UnknownTap { name: String },
|
||||
UnknownTap { name: String, declared: Vec<String> },
|
||||
/// The plan names a label the registry does not carry.
|
||||
UnknownLabel { label: String, roster: Vec<&'static str> },
|
||||
/// The entry's bind rule rejects the tap's column kind.
|
||||
@@ -237,8 +241,12 @@ pub enum TapPlanError {
|
||||
impl fmt::Display for TapPlanError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
TapPlanError::UnknownTap { name } => {
|
||||
write!(f, "the tap plan names '{name}', but the blueprint declares no such tap")
|
||||
TapPlanError::UnknownTap { name, declared } => {
|
||||
write!(
|
||||
f,
|
||||
"the tap plan names '{name}', but the blueprint declares no such tap — declared taps: {}",
|
||||
declared.join(", ")
|
||||
)
|
||||
}
|
||||
TapPlanError::UnknownLabel { label, roster } => {
|
||||
write!(f, "unknown fold '{label}' — available: {}", roster.join(", "))
|
||||
@@ -402,7 +410,10 @@ pub fn bind_tap_plan(
|
||||
// Unknown-tap guard: every plan name must be declared.
|
||||
for name in plan.by_name.keys() {
|
||||
if !declared_taps.iter().any(|t| &t.name == name) {
|
||||
return Err(TapPlanError::UnknownTap { name: name.clone() });
|
||||
return Err(TapPlanError::UnknownTap {
|
||||
name: name.clone(),
|
||||
declared: declared_taps.iter().map(|t| t.name.clone()).collect(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,7 +433,18 @@ pub fn bind_tap_plan(
|
||||
Some((label, params)) => {
|
||||
Resolved::Named { label: label.clone(), params: params.clone() }
|
||||
}
|
||||
None => continue, // unbound, inert (C27)
|
||||
// Unbound, inert (C27) — but only reachable when `plan` carries
|
||||
// no default (an EXPLICIT plan, e.g. from `--tap`): record-all
|
||||
// (`default_named` = `Some(("record", …))`) always resolves the
|
||||
// Some arm above, so this arm never fires under record-all and
|
||||
// the note is exactly the C14 benign skipped-tap class (#334).
|
||||
// Emitted here (aura-runner), beside the pre-existing
|
||||
// runner-side `eprintln!` registers in this module/`member.rs`/
|
||||
// `measure.rs` — the runner→CLI print migration is #297.
|
||||
None => {
|
||||
eprintln!("aura: note: declared tap \"{}\" unbound this run", tap.name);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
if let Resolved::Named { label, params } = &sub {
|
||||
@@ -504,6 +526,64 @@ mod tests {
|
||||
assert!(r.roster().iter().all(|(_, doc)| !doc.is_empty()), "every entry documents itself");
|
||||
}
|
||||
|
||||
/// C29 entry seam for the registry roster: every entry ships a gate-clean
|
||||
/// one-line meaning, and the bind/output prose inside it matches the
|
||||
/// entry's executable rules — the drift-pin the retired aura-std
|
||||
/// `fold_vocabulary` table carried, moved here with the surface (#332).
|
||||
#[test]
|
||||
fn roster_docs_pass_the_doc_gate_and_match_the_executable_rules() {
|
||||
let r = FoldRegistry::core();
|
||||
for entry in r.entries.values() {
|
||||
aura_core::doc_gate(entry.label, entry.doc)
|
||||
.unwrap_or_else(|f| panic!("fold {} doc fails the gate: {f:?}", entry.label));
|
||||
if entry.doc.contains("f64 taps") {
|
||||
assert!(
|
||||
(entry.binds_at)(ScalarKind::F64) && !(entry.binds_at)(ScalarKind::I64),
|
||||
"{} claims f64-only but binds wider",
|
||||
entry.label
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
entry.doc.contains("any kind"),
|
||||
"{}: bind prose must be 'f64 taps' or 'any kind': {}",
|
||||
entry.label,
|
||||
entry.doc
|
||||
);
|
||||
assert!(
|
||||
(entry.binds_at)(ScalarKind::I64) && (entry.binds_at)(ScalarKind::Bool),
|
||||
"{} claims any-kind but refuses a kind",
|
||||
entry.label
|
||||
);
|
||||
}
|
||||
if entry.doc.contains("i64 row") {
|
||||
assert!(
|
||||
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::I64)),
|
||||
"{} claims an i64 row but outputs otherwise",
|
||||
entry.label
|
||||
);
|
||||
} else if entry.doc.contains("f64 row") {
|
||||
assert!(
|
||||
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::F64)),
|
||||
"{} claims an f64 row but outputs otherwise",
|
||||
entry.label
|
||||
);
|
||||
} else if entry.doc.contains("kind-preserving row") {
|
||||
assert!(
|
||||
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::F64))
|
||||
&& matches!((entry.output)(ScalarKind::I64), FoldOutput::Row(ScalarKind::I64)),
|
||||
"{} claims kind-preserving but outputs otherwise",
|
||||
entry.label
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
matches!((entry.output)(ScalarKind::F64), FoldOutput::Series),
|
||||
"{}: doc names no row kind, so it must be the series entry",
|
||||
entry.label
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_label_refusal_enumerates_the_roster() {
|
||||
let r = FoldRegistry::core();
|
||||
|
||||
@@ -29,6 +29,7 @@ impl CarryCost {
|
||||
"CarryCost",
|
||||
Vec::new(),
|
||||
vec![ParamSpec { name: "carry_per_cycle".into(), kind: ScalarKind::F64 }],
|
||||
"cost-model node: cost accrued per held cycle (param carry_per_cycle)",
|
||||
|p| Box::new(CarryCost::new(p[0].f64())),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ impl ConstantCost {
|
||||
"ConstantCost",
|
||||
Vec::new(),
|
||||
vec![ParamSpec { name: "cost_per_trade".into(), kind: ScalarKind::F64 }],
|
||||
"cost-model node: fixed cost per trade (param cost_per_trade), charged at close",
|
||||
|p| Box::new(ConstantCost::new(p[0].f64())),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -214,26 +214,20 @@ impl<F: CostNode> Node for CostRunner<F> {
|
||||
}
|
||||
|
||||
/// Assemble a cost-node `PrimitiveBuilder`: the 4 geometry inputs ++ the factor's
|
||||
/// extra inputs, the standard 3-field cost output, the given params, and a build
|
||||
/// closure. The single home for the cost-node schema shape.
|
||||
/// extra inputs, the standard 3-field cost output, the given params, a
|
||||
/// factor-specific one-line `doc` (C29 — each cost node names its own charge
|
||||
/// basis rather than sharing one generic sentence, #330), and a build closure.
|
||||
/// The single home for the cost-node schema shape.
|
||||
pub fn cost_node_builder(
|
||||
name: &'static str,
|
||||
extra_inputs: Vec<PortSpec>,
|
||||
params: Vec<ParamSpec>,
|
||||
doc: &'static str,
|
||||
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
|
||||
) -> PrimitiveBuilder {
|
||||
let mut inputs = geometry_input_ports();
|
||||
inputs.extend(extra_inputs);
|
||||
PrimitiveBuilder::new(
|
||||
name,
|
||||
NodeSchema {
|
||||
inputs,
|
||||
output: cost_output_fields(),
|
||||
params,
|
||||
doc: "cost-model node: charges its cost in R from position geometry, at close or accrued per held cycle",
|
||||
},
|
||||
build,
|
||||
)
|
||||
PrimitiveBuilder::new(name, NodeSchema { inputs, output: cost_output_fields(), params, doc }, build)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -501,6 +495,7 @@ mod tests {
|
||||
"Probe",
|
||||
extra.clone(),
|
||||
params.clone(),
|
||||
"cost-model node: probe factor for the builder-assembly test",
|
||||
|_p| -> Box<dyn Node> { Box::new(CostRunner::new(StubCost(0.0))) },
|
||||
);
|
||||
let schema = builder.schema();
|
||||
@@ -537,6 +532,21 @@ mod tests {
|
||||
assert_eq!(a, "volatility");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_node_descriptions_are_pairwise_distinct() {
|
||||
// C29: `aura graph introspect --vocabulary` must be able to tell the
|
||||
// three cost nodes apart by their charge basis (flat-per-trade vs
|
||||
// per-held-cycle-accrual vs volatility-scaled), not read one
|
||||
// identical generic sentence three times (#330).
|
||||
use crate::{CarryCost, ConstantCost, VolSlippageCost};
|
||||
let constant_doc = ConstantCost::builder().schema().doc;
|
||||
let carry_doc = CarryCost::builder().schema().doc;
|
||||
let vol_slippage_doc = VolSlippageCost::builder().schema().doc;
|
||||
assert_ne!(constant_doc, carry_doc, "ConstantCost vs CarryCost doc must differ");
|
||||
assert_ne!(constant_doc, vol_slippage_doc, "ConstantCost vs VolSlippageCost doc must differ");
|
||||
assert_ne!(carry_doc, vol_slippage_doc, "CarryCost vs VolSlippageCost doc must differ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn producer_and_aggregator_share_the_triple() {
|
||||
// The structural lockstep: producer output and aggregator output/inputs all
|
||||
|
||||
@@ -43,6 +43,7 @@ impl VolSlippageCost {
|
||||
"VolSlippageCost",
|
||||
volatility_input_ports(),
|
||||
vec![ParamSpec { name: "slip_vol_mult".into(), kind: ScalarKind::F64 }],
|
||||
"cost-model node: slippage proportional to volatility (slip_vol_mult × volatility input), charged at close",
|
||||
|p| Box::new(VolSlippageCost::new(p[0].f64())),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# C27 — Declared taps: named measurement points bind sinks run-mode-aware: history
|
||||
|
||||
> FROZEN HISTORICAL RECORD. Each block below was true as of its cycle/date stamp
|
||||
> and may be superseded; this file is NOT current truth and NOT a grounding
|
||||
> surface. Current contract: [c27-declared-taps.md](c27-declared-taps.md).
|
||||
|
||||
**Current-state tap-plan sentence (2026-07-21, #283; superseded 2026-07-24 by
|
||||
the #310 `--tap` selector):** "…and the shared `bind_tap_plan`/`BoundTaps`
|
||||
pair called by both declared-tap entry points, `run_signal_r`
|
||||
(`aura-runner::member`) and `run_measurement` (`aura-runner::measure`); both
|
||||
CLI verbs pass a record-all plan."
|
||||
@@ -68,12 +68,28 @@ and the roster-enumerating refusal — plus a scalar-typed param schema; all cor
|
||||
entries are param-less today, the seam ships in every entry's build signature),
|
||||
and the shared `bind_tap_plan`/`BoundTaps` pair called by both declared-tap entry
|
||||
points, `run_signal_r` (`aura-runner::member`) and `run_measurement`
|
||||
(`aura-runner::measure`); both CLI verbs pass a record-all plan. The `record`
|
||||
(`aura-runner::measure`) — both arms of the single CLI verb `aura run`, whose
|
||||
repeatable `--tap TAP=FOLD` selector (#310) makes the `Named` selection
|
||||
data-reachable: no flag keeps the record-all default, any flag replaces the
|
||||
plan entirely (unlisted taps stay unbound/inert). The boundary is thereby
|
||||
fixed in place: *selecting* a subscription is run-mode authority, exercised
|
||||
by the run-mode owner — on the one-shot path the CLI invocation itself, a
|
||||
projection exercising this contract's authority, not a second home for
|
||||
intent under [C25](c25-role-model.md) — while *adding* a fold stays a Rust
|
||||
entry (role 2). The campaign/document carrier, and the reconciliation of the
|
||||
presentation-tap namespace (`persist_taps`) with declared taps it requires,
|
||||
is deferred to the Measurement-reachable milestone (#312/#327, minuted on
|
||||
#312). The `record`
|
||||
consumer (`aura-runner::tap_recorder::TapRecorder`) holds the trace store's
|
||||
streaming writer in-graph — `initialize` opens (deferred acquisition), `eval`
|
||||
appends `(Timestamp, Cell)` (zero per-cycle heap, #77), `finalize` reports
|
||||
exactly one terminal outcome; fold consumers (`aura-std::TapFold`) land one
|
||||
summary row; live closures run inline (`aura-std::TapLive`). The sweep/reduce
|
||||
summary row, emitted at finalize and stamped with the instant of the last
|
||||
contributing (warm) value — `first` alone pins the first contributing
|
||||
instant; `min`/`max` deliberately do not carry the extremum's timestamp (a
|
||||
whole-window row privileges no interior instant) — ratified as-is, #335;
|
||||
live closures run inline
|
||||
(`aura-std::TapLive`). The sweep/reduce
|
||||
path never calls `bind_tap`.
|
||||
|
||||
The chain-pruning benefit — a sweep paying zero for the study wires behind an
|
||||
|
||||
@@ -67,6 +67,8 @@ for whom the binary is the only always-present teacher. Field evidence
|
||||
execution semantics and document schemas by CAS forensics — the removed
|
||||
failure class is exactly that forensic recovery.
|
||||
|
||||
Rendering of the carried texts on the help/introspection surfaces is #315;
|
||||
the generated agent bootstrap card is #267; a fold introspection surface
|
||||
is blocked on #310.
|
||||
Rendering of the carried texts on the help/introspection surfaces shipped
|
||||
with #315; the agent bootstrap card was retired as superseded (#267 — the
|
||||
C29 surfaces themselves carry it); the fold introspection surface shipped
|
||||
with #310 and renders the fold-registry roster — labels exactly as the
|
||||
`--tap` selector accepts them, doc lines from the registry entries (#332).
|
||||
|
||||
+1
-1
@@ -327,7 +327,7 @@ A named recorded stream produced by a recording `sink` — the addressable label
|
||||
|
||||
Since C27 (#282) the word also names a second, author-facing sense: a **declared tap** — a `Composite.taps` entry `Tap { name, from: {node, field} }` a hand-authored blueprint declares on an interior output wire, the output-side twin of an `input_role`. It is a pure declaration (no channel endpoint); the harness binds it run-mode-aware (a single `aura run` constructs a recorder at each and persists the series through the trace store; a sweep leaves it unbound and inert). This is an OPEN, per-blueprint author-declared name — distinct from the CLOSED campaign `persist_taps` vocabulary above, which selects among fixed observables of the standard R-harness. Both senses land as `ColumnarTrace`s in the same trace store; the closed vocabulary is what a `campaign document` selects, the declared tap is what a blueprint author names.
|
||||
|
||||
Since #283 what CONSUMES a declared tap is itself declared per run by a **tap plan**: a subscription is either `Named { label, params }` — resolved against a layered **fold registry** whose core vocabulary is `record | count | sum | mean | min | max | first | last`, each entry carrying a doc line (the help surface and the roster-enumerating refusal) and a scalar-typed param schema (all core entries param-less; growth is a new Rust entry per C25, and higher layers register entries without touching the core) — or `Live(closure)`, the single non-data variant (an in-process consumer with consumer-owned loss policy). `record` streams the full series to the trace store at constant memory (no buffer-then-drain); folds keep an O(1) accumulator and land one summary row at finalize. Both CLI verbs (`aura run`, `aura measure`) pass a record-all plan, so their semantics are unchanged.
|
||||
Since #283 what CONSUMES a declared tap is itself declared per run by a **tap plan**: a subscription is either `Named { label, params }` — resolved against a layered **fold registry** whose core vocabulary is `record | count | sum | mean | min | max | first | last`, each entry carrying a doc line (the help surface and the roster-enumerating refusal) and a scalar-typed param schema (all core entries param-less; growth is a new Rust entry per C25, and higher layers register entries without touching the core) — or `Live(closure)`, the single non-data variant (an in-process consumer with consumer-owned loss policy). `record` streams the full series to the trace store at constant memory (no buffer-then-drain); folds keep an O(1) accumulator and land one summary row at finalize. Both declared-tap entry points are arms of the single verb `aura run` (`aura measure` is the post-hoc IC analysis over already-persisted traces and constructs no tap plan); `aura run` subscribes every declared tap to `record` by default, and its repeatable `--tap TAP=FOLD` selector (#310) replaces that default with an explicit plan — only listed taps are bound, unlisted taps stay unbound/inert. A fold's one summary row is emitted at finalize and stamped with the instant of the last contributing (warm) value — `first` alone pins the first contributing instant, and `min`/`max` deliberately do not carry the extremum's instant (ratified #335).
|
||||
|
||||
### topology hash
|
||||
**Avoid:** —
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "add", "type": "SMA", "name": "fast", "bind": {"length": {"I64": 3}}},
|
||||
{"op": "add", "type": "SMA", "name": "slow", "bind": {"length": {"I64": 6}}},
|
||||
{"op": "add", "type": "SMA", "name": "px", "bind": {"length": {"I64": 1}}},
|
||||
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series", "px.series"]},
|
||||
{"op": "add", "type": "Sub", "name": "sub"},
|
||||
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
|
||||
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
|
||||
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 0.5}}},
|
||||
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
|
||||
{"op": "tap", "from": "sub.value", "as": "signal"},
|
||||
{"op": "tap", "from": "px.value", "as": "price"},
|
||||
{"op": "expose", "from": "bias.bias", "as": "bias"}
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
[
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "add", "type": "SMA", "name": "fast", "bind": {"length": {"I64": 3}}},
|
||||
{"op": "add", "type": "SMA", "name": "slow", "bind": {"length": {"I64": 6}}},
|
||||
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
|
||||
{"op": "add", "type": "Gt", "name": "cross"},
|
||||
{"op": "connect", "from": "fast.value", "to": "cross.a"},
|
||||
{"op": "connect", "from": "slow.value", "to": "cross.b"},
|
||||
{"op": "add", "type": "Sign", "name": "sgn"},
|
||||
{"op": "add", "type": "Sub", "name": "sub"},
|
||||
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
|
||||
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
|
||||
{"op": "connect", "from": "sub.value", "to": "sgn.value"},
|
||||
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 1.0}}},
|
||||
{"op": "connect", "from": "sgn.value", "to": "bias.signal"},
|
||||
{"op": "tap", "from": "cross.value", "as": "above"},
|
||||
{"op": "expose", "from": "bias.bias", "as": "bias"}
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{"op": "doc", "text": "SMA crossover bias with two measurement taps for fold discovery"},
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "add", "type": "SMA", "name": "fast", "bind": {"length": {"I64": 3}}},
|
||||
{"op": "add", "type": "SMA", "name": "slow", "bind": {"length": {"I64": 6}}},
|
||||
{"op": "add", "type": "SMA", "name": "px", "bind": {"length": {"I64": 1}}},
|
||||
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series", "px.series"]},
|
||||
{"op": "add", "type": "Sub", "name": "sub"},
|
||||
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
|
||||
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
|
||||
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 0.5}}},
|
||||
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
|
||||
{"op": "tap", "from": "sub.value", "as": "spread"},
|
||||
{"op": "tap", "from": "px.value", "as": "price"},
|
||||
{"op": "expose", "from": "bias.bias", "as": "bias"}
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "add", "type": "SMA", "name": "fast", "params": {"length": {"I64": 3}}},
|
||||
{"op": "add", "type": "SMA", "name": "slow", "bind": {"length": {"I64": 6}}},
|
||||
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
|
||||
{"op": "add", "type": "Sub", "name": "sub"},
|
||||
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
|
||||
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
|
||||
{"op": "add", "type": "Bias", "name": "bias"},
|
||||
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
|
||||
{"op": "expose", "from": "bias.bias", "as": "bias"}
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{"op": "doc", "text": "SMA crossover exposing three measurement taps to probe the skipped-tap note"},
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "add", "type": "SMA", "name": "fast", "bind": {"length": {"I64": 3}}},
|
||||
{"op": "add", "type": "SMA", "name": "slow", "bind": {"length": {"I64": 6}}},
|
||||
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
|
||||
{"op": "add", "type": "Sub", "name": "sub"},
|
||||
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
|
||||
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
|
||||
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 1.0}}},
|
||||
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
|
||||
{"op": "tap", "from": "fast.value", "as": "fast_ma"},
|
||||
{"op": "tap", "from": "slow.value", "as": "slow_ma"},
|
||||
{"op": "tap", "from": "sub.value", "as": "spread"},
|
||||
{"op": "expose", "from": "bias.bias", "as": "bias"}
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Milestone 36 fieldtest — "Self-description: every surface explains itself"
|
||||
|
||||
Empirical, binary-only check of the milestone promise: that a deployment-posture
|
||||
agent with **only** the `aura` binary (no engine sources, no repo docs, no
|
||||
hand-written bootstrap card) can bootstrap from the surfaces alone.
|
||||
|
||||
Every artefact here was produced touching nothing but the release binary
|
||||
(`d26f0c8`) and what it emits (help text, `aura new` scaffold, introspection
|
||||
output, error messages). One child dir per scenario; each README states the
|
||||
property it protects. Full findings + verdict: `docs/specs/fieldtest-m1-self-description.md`.
|
||||
|
||||
| dir | axis | outcome |
|
||||
|-----|------|---------|
|
||||
| s1_bootstrap_author_validate | cold bootstrap → author → validate | green |
|
||||
| s2_execution_pulse_idiom | execution semantics + pulse idiom | green (1 friction) |
|
||||
| s3_vocabulary_breadth | vocabulary self-description breadth| pass (1 friction) |
|
||||
| s4_document_ramp | document-first ramp from `{}` | green (2 gaps) |
|
||||
| s5_trace_measurement | trace / measurement path | dead-end (1 bug, 1 gap) |
|
||||
@@ -0,0 +1,33 @@
|
||||
# s1 — cold bootstrap → author → validate
|
||||
|
||||
**Protected property:** starting from `aura --help` alone (no repo, no docs), a
|
||||
downstream agent can scaffold a project, author a new strategy over the
|
||||
discovered op-grammar vocabulary, and drive it to a green backtest — using only
|
||||
what the binary emits.
|
||||
|
||||
## Trail (binary-only)
|
||||
1. `aura --help` — states what aura is, the two-layer model, and the execution
|
||||
model (bias in [-1,+1], held as target position, stop = R).
|
||||
2. `aura new demo-strat` — scaffolds `Aura.toml`, `blueprints/signal.json`, and a
|
||||
machine-written `CLAUDE.md` bootstrap card.
|
||||
3. `aura graph build --help` — the op-list authoring grammar (one line of meaning
|
||||
per op).
|
||||
4. `aura graph introspect --vocabulary` / `--node <T>` — node types and port
|
||||
schemas.
|
||||
5. Author `ema_cross.oplist.json` (this dir); build it:
|
||||
`aura graph build < ema_cross.oplist.json > blueprints/ema_cross.json`
|
||||
6. Validate green:
|
||||
- synthetic: `aura run blueprints/ema_cross.json` → exit 0 (18-cycle synthetic
|
||||
stream → 0 trades; all-zero metrics, no note that the stream is tiny).
|
||||
- real: `aura run blueprints/ema_cross.json --real EURUSD --from 1577836800000
|
||||
--to 1583020800000` → 3944 trades, valid metrics JSON, exit 0.
|
||||
|
||||
`ema_cross.blueprint.json` is the built artefact `graph build` emitted.
|
||||
|
||||
## Notes
|
||||
- The harness auto-wraps the bias with `sim-optimal+risk-executor` and reports R
|
||||
metrics even though the blueprint emits only a bias — confirming the documented
|
||||
execution model without any extra authoring.
|
||||
- Minor friction: `graph build` has no op to set the blueprint name; it defaults
|
||||
to `graph`, and that name leaks into the sweep axis namespace
|
||||
(`graph.fast.length`).
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"graph","doc":"EMA(5)/EMA(20) spread clamped to a directional bias","nodes":[{"primitive":{"type":"EMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":5}}]}},{"primitive":{"type":"EMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":20}}]}},{"primitive":{"type":"Sub","name":"spread"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.25}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"EMA","name":"fast","bind":{"length":{"I64":5}}},
|
||||
{"op":"add","type":"EMA","name":"slow","bind":{"length":{"I64":20}}},
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"add","type":"Sub","name":"spread"},
|
||||
{"op":"connect","from":"fast.value","to":"spread.lhs"},
|
||||
{"op":"connect","from":"slow.value","to":"spread.rhs"},
|
||||
{"op":"add","type":"Bias","name":"bias","bind":{"scale":{"F64":0.25}}},
|
||||
{"op":"connect","from":"spread.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"},
|
||||
{"op":"doc","text":"EMA(5)/EMA(20) spread clamped to a directional bias"}
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
# s2 — execution semantics + one-shot/pulse idiom
|
||||
|
||||
**Protected property:** from the surfaces alone a consumer can learn (a) what the
|
||||
strategy's output stream means, (b) how it is executed against a protective stop,
|
||||
and (c) the signal-side idiom the surfaces recommend when a held one-shot / pulse
|
||||
behaviour is wanted — and can author that idiom to a green run.
|
||||
|
||||
## What the surfaces say
|
||||
- `aura --help`: "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."
|
||||
- Node schemas make the execution layer concrete: `Bias` (clamp to [-1,+1]),
|
||||
`FixedStop` (`price → stop_distance`, defines R), `PositionManagement`
|
||||
(`bias + price + stop_distance + size → managed position in R`). The built-in
|
||||
`run`/`sweep` harness supplies this risk executor automatically.
|
||||
- The scaffolded `CLAUDE.md`: "Entry signals become held state via the signal-side
|
||||
latch/edge-pulse idiom (see `aura graph introspect --vocabulary`)."
|
||||
|
||||
## Authored idioms (both build + validate green)
|
||||
- `latch_hold.oplist.json` — momentary crossover events → held state via `Latch`
|
||||
(`set = fast>slow`, `reset = slow>fast`), held level → `Bias`. Runs green
|
||||
(2543 trades over EURUSD 2020-01..03).
|
||||
- `edge_pulse.oplist.json` — the complementary one-shot pulse, built as the
|
||||
first-difference of the latched level (`Delay(lag=1)` + `Sub`) fed to `Bias`
|
||||
(+1 on entry edge, −1 on exit edge, 0 held). Builds + validates green.
|
||||
|
||||
## Friction recorded
|
||||
The `CLAUDE.md` names the "latch/edge-pulse idiom" and points to
|
||||
`graph introspect --vocabulary`, but that surface only lists node one-liners —
|
||||
it lists `Latch` but no `edge`/`pulse` primitive and no composition recipe. The
|
||||
pulse *is* constructible (Latch→Delay→Sub→Bias), but the consumer must infer the
|
||||
first-difference recipe; the pointer resolves to a list, not a recipe.
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"graph","doc":"edge-pulse: first-difference of the latched level, +1 on entry edge -1 on exit edge","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":5}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":20}}]}},{"primitive":{"type":"Gt","name":"cross_up"}},{"primitive":{"type":"Gt","name":"cross_dn"}},{"primitive":{"type":"Latch","name":"hold"}},{"primitive":{"type":"Delay","name":"prev","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Sub","name":"edge"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":1.0}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":0,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":4,"slot":1,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":6,"to":7,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}]}}
|
||||
@@ -0,0 +1,24 @@
|
||||
[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":5}}},
|
||||
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":20}}},
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"add","type":"Gt","name":"cross_up"},
|
||||
{"op":"connect","from":"fast.value","to":"cross_up.a"},
|
||||
{"op":"connect","from":"slow.value","to":"cross_up.b"},
|
||||
{"op":"add","type":"Gt","name":"cross_dn"},
|
||||
{"op":"connect","from":"slow.value","to":"cross_dn.a"},
|
||||
{"op":"connect","from":"fast.value","to":"cross_dn.b"},
|
||||
{"op":"add","type":"Latch","name":"hold"},
|
||||
{"op":"connect","from":"cross_up.value","to":"hold.set"},
|
||||
{"op":"connect","from":"cross_dn.value","to":"hold.reset"},
|
||||
{"op":"add","type":"Delay","name":"prev","bind":{"lag":{"I64":1}}},
|
||||
{"op":"connect","from":"hold.value","to":"prev.series"},
|
||||
{"op":"add","type":"Sub","name":"edge"},
|
||||
{"op":"connect","from":"hold.value","to":"edge.lhs"},
|
||||
{"op":"connect","from":"prev.value","to":"edge.rhs"},
|
||||
{"op":"add","type":"Bias","name":"bias","bind":{"scale":{"F64":1.0}}},
|
||||
{"op":"connect","from":"edge.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"},
|
||||
{"op":"doc","text":"edge-pulse: first-difference of the latched level, +1 on entry edge -1 on exit edge"}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"graph","doc":"latched long: set on fast>slow, reset on slow>fast, held as bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":5}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":20}}]}},{"primitive":{"type":"Gt","name":"cross_up"}},{"primitive":{"type":"Gt","name":"cross_dn"}},{"primitive":{"type":"Latch","name":"hold"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":1.0}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":0,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":4,"slot":1,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":5,"field":0,"name":"bias"}]}}
|
||||
@@ -0,0 +1,19 @@
|
||||
[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":5}}},
|
||||
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":20}}},
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"add","type":"Gt","name":"cross_up"},
|
||||
{"op":"connect","from":"fast.value","to":"cross_up.a"},
|
||||
{"op":"connect","from":"slow.value","to":"cross_up.b"},
|
||||
{"op":"add","type":"Gt","name":"cross_dn"},
|
||||
{"op":"connect","from":"slow.value","to":"cross_dn.a"},
|
||||
{"op":"connect","from":"fast.value","to":"cross_dn.b"},
|
||||
{"op":"add","type":"Latch","name":"hold"},
|
||||
{"op":"connect","from":"cross_up.value","to":"hold.set"},
|
||||
{"op":"connect","from":"cross_dn.value","to":"hold.reset"},
|
||||
{"op":"add","type":"Bias","name":"bias","bind":{"scale":{"F64":1.0}}},
|
||||
{"op":"connect","from":"hold.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"},
|
||||
{"op":"doc","text":"latched long: set on fast>slow, reset on slow>fast, held as bias"}
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# s3 — vocabulary self-description breadth
|
||||
|
||||
**Protected property:** every closed-vocabulary entry class the binary exposes is
|
||||
reachable and carries a non-empty one-line meaning, and no entry's meaning is a
|
||||
byte-identical (non-discriminating) duplicate of a sibling in the same class.
|
||||
|
||||
`breadth_probe.sh` drives only `aura` introspection output; `breadth_probe.out`
|
||||
is its captured result. Run with `AURA=<path-to-release-aura> bash breadth_probe.sh`.
|
||||
|
||||
## Result (see breadth_probe.out)
|
||||
- 33/33 node types resolve via `graph introspect --node <T>` with a
|
||||
`"<Name> — <meaning>"` schema. PASS.
|
||||
- 7/7 folds, 6/6 process blocks, 6/6 campaign blocks, 17/17 metrics reachable and
|
||||
carry a meaning (metrics also carry applicability tags). PASS.
|
||||
- **One blemish:** three distinct node types — `CarryCost`, `ConstantCost`,
|
||||
`VolSlippageCost` — share the byte-identical one-liner
|
||||
*"cost-model node: charges its cost in R from position geometry, at close or
|
||||
accrued per held cycle"*. From `--vocabulary` (the primary discovery surface)
|
||||
a consumer cannot tell them apart; the distinguishing detail (the param
|
||||
`carry_per_cycle` vs `cost_per_trade` vs `slip_vol_mult`, and the extra
|
||||
`volatility` input) lives only in `--node` and in the campaign `cost` slot
|
||||
prose. The one-liner exists (C29 satisfied) but is not entry-specific.
|
||||
@@ -0,0 +1,45 @@
|
||||
== node vocabulary: every node resolves via --node with a meaning ==
|
||||
nodes probed: 33
|
||||
== duplicate node one-liners (non-discriminating meanings) ==
|
||||
x3 identical: cost-model node: charges its cost in R from position geometry, at close or accrued per held cycle
|
||||
== folds carry a meaning ==
|
||||
ok: Count
|
||||
ok: Sum
|
||||
ok: Mean
|
||||
ok: Min
|
||||
ok: Max
|
||||
ok: First
|
||||
ok: Last
|
||||
== process blocks resolve via --block ==
|
||||
ok: std::sweep
|
||||
ok: std::gate
|
||||
ok: std::walk_forward
|
||||
ok: std::monte_carlo
|
||||
ok: std::generalize
|
||||
ok: std::grid
|
||||
== campaign blocks resolve via --block ==
|
||||
ok: std::data
|
||||
ok: std::risk
|
||||
ok: std::cost
|
||||
ok: std::strategy
|
||||
ok: std::process_ref
|
||||
ok: std::presentation
|
||||
== metrics carry an applicability tag + meaning ==
|
||||
ok: expectancy_r
|
||||
ok: win_rate
|
||||
ok: avg_win_r
|
||||
ok: avg_loss_r
|
||||
ok: profit_factor
|
||||
ok: max_r_drawdown
|
||||
ok: sqn
|
||||
ok: sqn_normalized
|
||||
ok: net_expectancy_r
|
||||
ok: n_trades
|
||||
ok: n_open_at_end
|
||||
ok: total_pips
|
||||
ok: max_drawdown
|
||||
ok: bias_sign_flips
|
||||
ok: deflated_score
|
||||
ok: overfit_probability
|
||||
ok: neighbourhood_score
|
||||
RESULT: fail=0
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Scenario 3 — vocabulary self-description breadth probe.
|
||||
# Protected property: every closed-vocabulary entry class the binary exposes is
|
||||
# REACHABLE and carries a non-empty one-line meaning; no entry's meaning is a
|
||||
# byte-for-byte duplicate of another entry in the SAME class (a duplicate is a
|
||||
# non-discriminating meaning).
|
||||
# Binary-only: uses nothing but `aura` introspection output.
|
||||
set -u
|
||||
AURA="${AURA:?set AURA to the release binary path}"
|
||||
|
||||
fail=0
|
||||
|
||||
echo "== node vocabulary: every node resolves via --node with a meaning =="
|
||||
nodes=$("$AURA" graph introspect --vocabulary | awk '{print $1}')
|
||||
for n in $nodes; do
|
||||
line=$("$AURA" graph introspect --node "$n" 2>&1 | head -1)
|
||||
case "$line" in
|
||||
"$n — "*) : ;; # "<Name> — <meaning>"
|
||||
*) echo " MISSING/BAD meaning for node $n: [$line]"; fail=1 ;;
|
||||
esac
|
||||
done
|
||||
echo " nodes probed: $(echo "$nodes" | wc -w)"
|
||||
|
||||
echo "== duplicate node one-liners (non-discriminating meanings) =="
|
||||
"$AURA" graph introspect --vocabulary \
|
||||
| sed -E 's/^[A-Za-z0-9_]+ +//' \
|
||||
| sort | uniq -c | sort -rn \
|
||||
| awk '$1 > 1 { print " x"$1" identical: "substr($0, index($0,$2)) }'
|
||||
|
||||
echo "== folds carry a meaning =="
|
||||
"$AURA" graph introspect --folds | awk 'NF==0{next} {print " ok: "$1}'
|
||||
|
||||
echo "== process blocks resolve via --block =="
|
||||
for b in $("$AURA" process introspect --vocabulary | awk '{print $1}'); do
|
||||
"$AURA" process introspect --block "$b" >/dev/null 2>&1 && echo " ok: $b" || { echo " BAD: $b"; fail=1; }
|
||||
done
|
||||
|
||||
echo "== campaign blocks resolve via --block =="
|
||||
for b in $("$AURA" campaign introspect --vocabulary | awk '{print $1}'); do
|
||||
"$AURA" campaign introspect --block "$b" >/dev/null 2>&1 && echo " ok: $b" || { echo " BAD: $b"; fail=1; }
|
||||
done
|
||||
|
||||
echo "== metrics carry an applicability tag + meaning =="
|
||||
"$AURA" process introspect --metrics | awk '{print " ok: "$1}'
|
||||
|
||||
echo "RESULT: fail=$fail"
|
||||
@@ -0,0 +1,38 @@
|
||||
# s4 — document-first ramp (grow from `{}` to a runnable campaign)
|
||||
|
||||
**Protected property:** using only `--unwired` introspection and validation
|
||||
diagnostics, a consumer can grow a process document and a campaign document from a
|
||||
bare `{}` to a registered, runnable state, and run the campaign green.
|
||||
|
||||
## Trail (binary-only)
|
||||
1. `echo '{}' > x.json; aura process introspect --unwired x.json` — lists the
|
||||
whole envelope (`format_version`, `kind`, `name`, `pipeline`).
|
||||
2. Grow `process.json`: `--unwired` on a doc with `pipeline:[]` says "a process
|
||||
needs at least one stage"; a stage `{}` is told `pipeline[0].block (required,
|
||||
block id)` → correct shape is `{"block":"std::sweep"}`.
|
||||
`aura process validate` → valid; `aura process register` → content id.
|
||||
3. Grow `campaign.json` similarly (`aura campaign introspect --unwired`,
|
||||
`--block std::*`). `aura campaign validate` passes all three tiers
|
||||
(intrinsic / referential / executable); `register` → content id;
|
||||
`campaign run <id>` → 3 members, green.
|
||||
|
||||
`process.json` and `campaign.json` in this dir are the final registered forms;
|
||||
`ema_open.oplist.json` is the strategy authored with an **open** `fast.length`
|
||||
param (see friction 2).
|
||||
|
||||
## Frictions recorded
|
||||
1. **`process.ref` shape under-documented.** `campaign introspect --block
|
||||
std::process_ref` and `--unwired` both describe it as *"content id of a process
|
||||
document"* (reads as a bare string), but the validator rejects the bare string:
|
||||
`unknown variant '<id>', expected 'content_id' or 'identity_id'` — the tagged
|
||||
`{"content_id": "<id>"}` form is required and appears nowhere in the
|
||||
introspection. Recoverable only via the validate error.
|
||||
2. **Two-layer axis-namespace seam.** `aura sweep <bp> --list-axes` presents
|
||||
`graph.fast.length` as sweepable (overriding the *bound* default, #246), and
|
||||
the sugar `aura sweep --axis graph.fast.length=…` accepts it. But the canonical
|
||||
**document** form rejects that same name: `axis "graph.fast.length" is not in
|
||||
the param space` — a campaign axis must be an **open** param, named **without**
|
||||
the `graph.` prefix (`fast.length`, per `graph introspect --params`, which
|
||||
prints *nothing* for a fully-bound blueprint). The name a consumer learns from
|
||||
the sugar (`graph.fast.length`) is not the name the document layer wants
|
||||
(`fast.length`), and no surface reconciles the two.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"format_version":1,
|
||||
"kind":"campaign",
|
||||
"name":"ema_axis_sweep",
|
||||
"data":{"instruments":["EURUSD"],"windows":[{"from_ms":1577836800000,"to_ms":1583020800000}]},
|
||||
"strategies":[{"ref":{"content_id":"2c94edd4e3a3253fa446f2700f5ada1cb4cb9ceafcd99494d8d8e5244e3148a6"},"axes":{"fast.length":{"kind":"I64","values":[3,5,8]}}}],
|
||||
"process":{"ref":{"content_id":"b3c0f7548731f4078ac6f0ba4444781ff51b067b4dd25567a12c49423b5d63ef"}},
|
||||
"seed":0,
|
||||
"presentation":{"persist_taps":["r_equity"],"emit":["family_table"]}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"EMA","name":"fast"},
|
||||
{"op":"add","type":"EMA","name":"slow","bind":{"length":{"I64":20}}},
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"add","type":"Sub","name":"spread"},
|
||||
{"op":"connect","from":"fast.value","to":"spread.lhs"},
|
||||
{"op":"connect","from":"slow.value","to":"spread.rhs"},
|
||||
{"op":"add","type":"Bias","name":"bias","bind":{"scale":{"F64":0.25}}},
|
||||
{"op":"connect","from":"spread.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"},
|
||||
{"op":"doc","text":"EMA(open fast)/EMA(20) spread bias, fast.length swept by campaign"}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":1,"kind":"process","name":"minimal_sweep","pipeline":[{"block":"std::sweep"}]}
|
||||
@@ -0,0 +1,39 @@
|
||||
# s5 — trace / measurement path
|
||||
|
||||
**Protected property:** from the surfaces a consumer can discover which verbs
|
||||
persist per-cycle traces and reach a signal-quality measurement — the milestone's
|
||||
own headline being "signal quality is measured in R".
|
||||
|
||||
## What the surfaces say
|
||||
`aura --help`: "Traces: `sweep --real --trace` / `walkforward --real --trace`
|
||||
record per-cycle taps under runs/, consumed by `aura chart` and `aura measure`."
|
||||
`aura measure ic --help`: "Information Coefficient of a **signal** tap against
|
||||
forward returns of a **price** tap" — requires `--signal <SIGNAL>` and
|
||||
`--price <PRICE>`.
|
||||
|
||||
## What actually happens
|
||||
- Discovery works: `runs families` lists the traced families; `measure ic`'s
|
||||
`<RUN>` is the member path **relative to** `runs/traces/`
|
||||
(`<family>/<instr-window>/<param-combo>`); the missing-tap error is clear and
|
||||
lists available taps. `measure ic` runs green on the persisted taps.
|
||||
- **Dead-end for the intended inputs.** The only taps any surface-reachable verb
|
||||
persists are the closed presentation set `equity` / `exposure` / `r_equity`
|
||||
(`net_r_equity`) — execution-side R/pip curves, **not** the strategy's bias
|
||||
signal nor the instrument price. So `measure ic` can only be fed, e.g.,
|
||||
`--signal exposure --price equity`, which is not a signal-vs-price IC at all
|
||||
(result ≈ noise, 0.0019).
|
||||
- **Declared taps are silently dropped.** `graph build --help` documents the op
|
||||
`tap` as *"declare a **recordable** tap on a wire"*. `tapped.oplist.json` (this
|
||||
dir) declares `tap bias.bias as signal` and `tap fast.value as price`, yet after
|
||||
`sweep --real --trace` the persisted set is still `["equity","exposure",
|
||||
"r_equity"]`; both `measure ic --signal signal` and `chart --tap signal` report
|
||||
the tap does not exist. There is no surface-documented path to persist a genuine
|
||||
signal or price tap, so `measure ic`'s stated contract is unreachable end-to-end.
|
||||
|
||||
## Evidence (verbatim)
|
||||
```
|
||||
$ aura measure ic --signal signal --price price <tapped-run>
|
||||
aura: run '…' has no tap 'signal' (taps: ["equity", "exposure", "r_equity"])
|
||||
$ aura chart 010f8afa-0 --tap signal
|
||||
aura: no family member has a tap named 'signal' (exit 0)
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":5}}},
|
||||
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":20}}},
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"add","type":"Sub","name":"spread"},
|
||||
{"op":"connect","from":"fast.value","to":"spread.lhs"},
|
||||
{"op":"connect","from":"slow.value","to":"spread.rhs"},
|
||||
{"op":"add","type":"Bias","name":"bias","bind":{"scale":{"F64":0.25}}},
|
||||
{"op":"connect","from":"spread.value","to":"bias.signal"},
|
||||
{"op":"tap","from":"bias.bias","as":"signal"},
|
||||
{"op":"tap","from":"fast.value","as":"price"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"},
|
||||
{"op":"doc","text":"tapped: bias exposed and tapped as signal, fast tapped as price"}
|
||||
]
|
||||
Reference in New Issue
Block a user