Compare commits
20 Commits
26b3d689df
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cfe2965c0 | |||
| 32eb5a6a9e | |||
| 564a767974 | |||
| 4ff85b94e5 | |||
| 10570b75f8 | |||
| a851af993a | |||
| bbac29db2d | |||
| 5e32f3ccdf | |||
| 4474814fa7 | |||
| b3b7115825 | |||
| 3e1e7e21da | |||
| a3785a6ec6 | |||
| 697d81dd22 | |||
| eb2b0a132c | |||
| 14c43474ab | |||
| 6ca359ae00 | |||
| bc8fb46110 | |||
| a8b1ba45c5 | |||
| e84ad6d0d2 | |||
| f449cb06f2 |
Generated
+3
-2
@@ -160,6 +160,7 @@ dependencies = [
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-ingest",
|
||||
"aura-market",
|
||||
"aura-measurement",
|
||||
"aura-registry",
|
||||
"aura-research",
|
||||
@@ -167,6 +168,7 @@ dependencies = [
|
||||
"aura-std",
|
||||
"aura-strategy",
|
||||
"aura-vocabulary",
|
||||
"chrono-tz",
|
||||
"clap",
|
||||
"data-server",
|
||||
"libc",
|
||||
@@ -192,6 +194,7 @@ dependencies = [
|
||||
name = "aura-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono-tz",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -307,8 +310,6 @@ dependencies = [
|
||||
"aura-backtest",
|
||||
"aura-core",
|
||||
"aura-strategy",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -38,7 +38,7 @@ Invoke it as `aura <command> …` (examples below use the plain name).
|
||||
- **Family** — the set of runs one verb produces over a blueprint (a sweep grid,
|
||||
a Monte-Carlo seed set, a walk-forward window sequence). Families are persisted
|
||||
in a content-addressed store and can be listed, ranked, and reproduced.
|
||||
- **Axis** — one named, sweepable knob of a blueprint (e.g. `graph.fast.length`),
|
||||
- **Axis** — one named, sweepable knob of a blueprint (e.g. `fast.length`),
|
||||
bound with a comma-separated value list. A **gang** fuses several sibling
|
||||
knobs into one axis (one value drives all members).
|
||||
|
||||
@@ -122,7 +122,9 @@ The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, `tap`,
|
||||
`gang`, `doc`, and `use` (`tap` declares a recorded measurement point on an
|
||||
interior wire; `doc` declares the composite's one-line meaning, required at
|
||||
register — C29; `use` splices a registered blueprint in as a nested composite,
|
||||
by content id or label; see the authoring guide). See
|
||||
by content id or label; `add` additionally takes an `args` object for
|
||||
**arg-bearing** types (`Session`, `LinComb`, `CostSum`) — structural,
|
||||
non-scalar construction consumed before `bind`; see the authoring guide). See
|
||||
`aura graph introspect --node <T>` for a type's exact ports and the op-script
|
||||
grammar in the design ledger for the full semantics.
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"surface": "cli_fixed_cost",
|
||||
"metrics": {
|
||||
"help_ms": 1.5046899999999999,
|
||||
"run_ms": 3.63305
|
||||
"help_ms": 1.487171,
|
||||
"run_ms": 3.376192
|
||||
},
|
||||
"fingerprint": "run_line_fnv=6bb0d796f760d140",
|
||||
"fingerprint": "run_line_fnv=9bdbc3acf7b2926a",
|
||||
"reps": 3,
|
||||
"host": {
|
||||
"hostname": "Raki",
|
||||
"nproc": 24
|
||||
},
|
||||
"profile": "release",
|
||||
"commit": "9c7f60b",
|
||||
"date": "2026-07-23"
|
||||
"commit": "10570b7",
|
||||
"date": "2026-07-25"
|
||||
}
|
||||
|
||||
@@ -106,9 +106,9 @@ fn seed_blueprint(bin: &Path, dir: &Path, file: &str, name: &str) -> Result<Stri
|
||||
"sweep",
|
||||
file,
|
||||
"--axis",
|
||||
"sma_signal.fast.length=2,4",
|
||||
"fast.length=2,4",
|
||||
"--axis",
|
||||
"sma_signal.slow.length=8,16",
|
||||
"slow.length=8,16",
|
||||
"--name",
|
||||
name,
|
||||
],
|
||||
|
||||
@@ -65,6 +65,15 @@ zip = "2"
|
||||
# (#[cfg(test)] use aura_composites::StopRule in main.rs's ic_tests module),
|
||||
# so this rides the same dev-only-edge idiom as `aura-analysis`/`sha2` below.
|
||||
aura-composites = { path = "../aura-composites" }
|
||||
# aura-market: the shell's own use is test-only (#271's identity-bridge test,
|
||||
# `identity_id_bridges_the_rust_configured_session_and_args_script`, needs
|
||||
# `Session::configured` as the Rust-authored twin of the args op-script path)
|
||||
# — rides the same dev-only-edge idiom as `aura-composites`/`aura-analysis`.
|
||||
aura-market = { path = "../aura-market" }
|
||||
# chrono-tz: the SAME identity-bridge test needs a `chrono_tz::Tz` to pass
|
||||
# `Session::configured` — test-only, pinned to the same version every other
|
||||
# crate's chrono-tz entry uses.
|
||||
chrono-tz = { version = "0.10", default-features = false }
|
||||
# aura-analysis: pearson_corr, used only by the ic_tests unit-test fixtures
|
||||
# (mod ic_tests in main.rs) — a test-only edge, not a production one.
|
||||
aura-analysis = { path = "../aura-analysis" }
|
||||
|
||||
@@ -8,9 +8,11 @@ use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use aura_engine::{
|
||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, BindOpError,
|
||||
BlueprintDoc, CompileError, Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind,
|
||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, name_gate, replay, ArgOpError,
|
||||
BindOpError, BlueprintDoc, CompileError, Composite, GraphSession, LoadError, NameGateFault, Op,
|
||||
OpError, Scalar, ScalarKind,
|
||||
};
|
||||
use aura_runner::runner::render_value;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::research_docs::resolve_id_prefix;
|
||||
@@ -20,9 +22,10 @@ use crate::research_docs::resolve_id_prefix;
|
||||
#[cfg(test)]
|
||||
use aura_vocabulary::std_vocabulary;
|
||||
|
||||
/// The op-list reference `aura graph build --help` appends (#323): the ten
|
||||
/// 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.
|
||||
/// The op-list reference `aura graph build --help` appends (#323): the eleven
|
||||
/// op kinds with their fields and one worked element each (#331: `name` joins
|
||||
/// the roster, ten -> eleven). 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
|
||||
@@ -30,6 +33,9 @@ pub const OP_REFERENCE: &str = r#"Op-list reference (stdin: a JSON array of op o
|
||||
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":"add","type":"Session","name":"ny","args":{"tz":"America/New_York","open":"09:30"},"bind":{"period_minutes":{"I64":15}}}
|
||||
an arg-bearing type applies "args" (closed, per-type-declared string
|
||||
pairs) BEFORE "bind" — see graph introspect --node <T> for its args
|
||||
{"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"}
|
||||
@@ -45,6 +51,9 @@ pub const OP_REFERENCE: &str = r#"Op-list reference (stdin: a JSON array of op o
|
||||
{"op":"use","ref":{"name":"agree"},"name":"gate","bind":{"sma.length":{"I64":9}}}
|
||||
splice a registered blueprint (by "content_id" or "name") under an
|
||||
instance name ("bind" path-qualifies the spliced instance's params)
|
||||
{"op":"name","name":"ny_momentum"}
|
||||
set the composite's render name, at most once per script (default
|
||||
"graph" if omitted)
|
||||
|
||||
Node types and their ports: aura graph introspect --vocabulary | --node <T>"#;
|
||||
|
||||
@@ -64,6 +73,12 @@ enum OpDoc {
|
||||
// naming, not an aliasing (contrast `expose`'s `as`, which is a real alias).
|
||||
#[serde(rename = "name", default)]
|
||||
as_name: Option<String>,
|
||||
// Construction args (#271) — the typed, closed channel applied BEFORE
|
||||
// `bind`. A `BTreeMap` (deterministic iteration order into `Op::Add`'s
|
||||
// `Vec`, mirroring `bind`'s own convention) — absent for every
|
||||
// args-free type, so an old-style `add` document is unaffected.
|
||||
#[serde(default)]
|
||||
args: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
bind: BTreeMap<String, Scalar>,
|
||||
},
|
||||
@@ -100,6 +115,9 @@ enum OpDoc {
|
||||
#[serde(default)]
|
||||
bind: BTreeMap<String, Scalar>,
|
||||
},
|
||||
/// Set the composite's render name (#331) — script-level, at most once;
|
||||
/// the op-script twin of `replay`'s seeded default name (`"graph"`).
|
||||
Name { name: String },
|
||||
}
|
||||
|
||||
/// A `use` op's reference (#317) — exactly one of a store content id (full
|
||||
@@ -132,6 +150,7 @@ impl OpDoc {
|
||||
OpDoc::Doc { .. } => "doc".to_string(),
|
||||
OpDoc::Use { name: Some(n), .. } => format!("use {n:?}"),
|
||||
OpDoc::Use { name: None, .. } => "use".to_string(),
|
||||
OpDoc::Name { .. } => "name".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,9 +170,12 @@ impl From<OpDoc> for Op {
|
||||
match d {
|
||||
OpDoc::Source { role, kind } => Op::Source { role, kind },
|
||||
OpDoc::Input { role } => Op::Input { role },
|
||||
OpDoc::Add { type_id, as_name, bind } => {
|
||||
Op::Add { type_id, as_name, bind: bind.into_iter().collect() }
|
||||
}
|
||||
OpDoc::Add { type_id, as_name, args, bind } => Op::Add {
|
||||
type_id,
|
||||
as_name,
|
||||
args: args.into_iter().collect(),
|
||||
bind: bind.into_iter().collect(),
|
||||
},
|
||||
OpDoc::Feed { role, into } => Op::Feed { role, into },
|
||||
OpDoc::Connect { from, to } => Op::Connect { from, to },
|
||||
OpDoc::Expose { from, as_name } => Op::Expose { from, as_name },
|
||||
@@ -168,6 +190,25 @@ impl From<OpDoc> for Op {
|
||||
name,
|
||||
bind: bind.into_iter().collect(),
|
||||
},
|
||||
OpDoc::Name { name } => Op::Name { name },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Phrase one `ArgOpError` (#271) as a human cause, WITHOUT the node prefix —
|
||||
/// shared by `format_op_error`'s `BadArg` arm (the `add`-op path) and
|
||||
/// `blueprint_load_prose`'s `BadArg` arm (the blueprint LOAD path), so the two
|
||||
/// error families cannot phrase the same fault differently. Exhaustive over
|
||||
/// `ArgOpError`; `BadValue` names the arg, its declared `ArgKind`, AND the
|
||||
/// closed per-kind `hint()` line (the same hint `introspect --node` shows).
|
||||
fn arg_op_error_prose(err: &ArgOpError) -> String {
|
||||
match err {
|
||||
ArgOpError::NotArgBearing => "takes no construction args".to_string(),
|
||||
ArgOpError::UnknownArg(a) => format!("has no construction arg {a:?}"),
|
||||
ArgOpError::DuplicateArg(a) => format!("was given arg {a:?} more than once"),
|
||||
ArgOpError::MissingArg(a) => format!("is missing required arg {a:?}"),
|
||||
ArgOpError::BadValue { arg, kind, got } => {
|
||||
format!("arg {arg:?} expects {kind:?} ({}) — got {got:?}", kind.hint())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,6 +245,7 @@ fn format_op_error(e: &OpError) -> String {
|
||||
format!("cannot bind {node}.{param} — member of gang {gang:?}")
|
||||
}
|
||||
},
|
||||
OpError::BadArg { node, err } => format!("node {node} {}", arg_op_error_prose(err)),
|
||||
OpError::UnconnectedPort { node, slot } => format!("slot {node}.{slot} is unconnected"),
|
||||
OpError::RoleKindMismatch { role } => format!("input role {role} fans into slots of differing kinds"),
|
||||
OpError::GangKindMismatch { member, expected, got } => {
|
||||
@@ -224,9 +266,25 @@ fn format_op_error(e: &OpError) -> String {
|
||||
OpError::UnknownSubgraph { ref_id } => {
|
||||
format!("use: no subgraph for content id {ref_id:?}")
|
||||
}
|
||||
OpError::DuplicateName => "a script names its blueprint at most once".to_string(),
|
||||
OpError::BadName { name, fault } => name_gate_fault_prose(name, fault),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phrase a `name_gate` shape violation as prose (#331): shared by this
|
||||
/// module's op-intake `format_op_error` `BadName` arm and the
|
||||
/// blueprint-envelope intake's root-name gate (`composite_from_authored_text`
|
||||
/// below) — the shape rule has exactly one seam-independent cause per fault,
|
||||
/// so both data-borne birth routes for a name read identically.
|
||||
fn name_gate_fault_prose(name: &str, fault: &NameGateFault) -> String {
|
||||
let cause = match fault {
|
||||
NameGateFault::Empty => "must be non-empty",
|
||||
NameGateFault::ContainsSeparator => "must not contain '/' or '\\'",
|
||||
NameGateFault::DotSegment => "must not be \".\" or \"..\"",
|
||||
};
|
||||
format!("blueprint name {name:?} is invalid: {cause} (a single path segment)")
|
||||
}
|
||||
|
||||
/// Resolve one `use` op's [`UseRef`] to the full store content id (#317):
|
||||
/// verbatim if it already IS a 64-hex content id, else a unique content-id
|
||||
/// prefix (#302 semantics, reusing [`resolve_id_prefix`]) or a registry name
|
||||
@@ -439,6 +497,17 @@ pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result
|
||||
let schema = builder.schema();
|
||||
// C29 (#315): the head line carries the node's one-line meaning.
|
||||
let mut out = format!("{} — {}\n", builder.label(), schema.doc);
|
||||
// #271: an arg-bearing (pending) type declares its ArgSpecs but no real
|
||||
// ports/params yet — those form only once `try_args` runs (`make`). Show
|
||||
// the arg rows first (they must be supplied before anything else can be
|
||||
// discovered) plus the one-line note the spec's worked discovery example
|
||||
// pins.
|
||||
for spec in builder.arg_specs() {
|
||||
out.push_str(&format!(" arg {}: {:?} ({})\n", spec.name, spec.kind, spec.kind.hint()));
|
||||
}
|
||||
if builder.is_pending() {
|
||||
out.push_str(" note ports and params form at construction; args are required\n");
|
||||
}
|
||||
for port in &schema.inputs {
|
||||
out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind));
|
||||
}
|
||||
@@ -571,10 +640,12 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
|
||||
}
|
||||
}
|
||||
} else if let Some(target) = cmd.params.as_deref() {
|
||||
// --params <FILE|ID> (#196): the RAW composite param space — exactly the
|
||||
// namespace campaign axes are validated against (`validate_campaign_refs`
|
||||
// checks the raw space; the wrapped `--list-axes` namespace on `aura sweep`
|
||||
// is the sweep-verb view, not the campaign view).
|
||||
// --params <FILE|ID> (#196): the RAW composite param space — the one
|
||||
// namespace campaign axes are validated against
|
||||
// (`validate_campaign_refs`) and `aura sweep --list-axes` prints
|
||||
// (#328: the wrapped `<blueprint>.<node>.<param>` form was retired —
|
||||
// there is exactly one axis namespace now, so the two discovery
|
||||
// surfaces agree line-for-line, see `params_lines`'s own doc comment).
|
||||
match params_lines(target, env) {
|
||||
Ok(s) => print!("{s}"),
|
||||
Err(m) => {
|
||||
@@ -603,7 +674,7 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
match composite_from_any(&text, env) {
|
||||
match composite_from_authored_text(&text, env) {
|
||||
Ok(c) => c,
|
||||
Err(m) => {
|
||||
eprintln!("aura: {m}");
|
||||
@@ -662,6 +733,7 @@ pub(crate) fn blueprint_load_prose(e: &LoadError) -> String {
|
||||
}
|
||||
LoadError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
|
||||
LoadError::Gang(e) => format!("gang section invalid: {}", gang_fault_prose(e)),
|
||||
LoadError::BadArg(e) => format!("construction args invalid: {}", arg_op_error_prose(e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,6 +844,18 @@ pub(crate) fn blueprint_slot_prose(doc: &str, env: &aura_runner::project::Env) -
|
||||
/// envelope (a JSON object: `format_version` + `blueprint`) OR a construction
|
||||
/// op-list (a JSON array) — shape-discriminated on the top-level JSON type,
|
||||
/// each canonicalized by its own rules.
|
||||
///
|
||||
/// **Ungated by design (#331 review finding).** This is the STORE READ-BACK
|
||||
/// shape: `params_lines`'s content-id fetch (`resolve_blueprint_text`'s
|
||||
/// non-file branch) — `introspect --params <ID>` and, by the same
|
||||
/// convention, `validate_campaign_refs`'s already-ungated `blueprint_from_json`
|
||||
/// call — reads whatever is already sitting in the store, and C29 says a
|
||||
/// registered artifact is never retroactively invalidated. Freshly authored
|
||||
/// FILE text (a hand-edited document that has not yet passed through a gated
|
||||
/// intake) goes through [`composite_from_authored_text`] instead, never here
|
||||
/// — `params_lines`'s OWN file branch is such a case (#331 delta re-review:
|
||||
/// it used to call straight through to this fn, missing the gate; fixed by
|
||||
/// branching on `resolve_blueprint_text`'s file-vs-store flag).
|
||||
pub(crate) fn composite_from_any(text: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(text).map_err(|e| format!("invalid document: {e}"))?;
|
||||
@@ -789,12 +873,86 @@ pub(crate) fn composite_from_any(text: &str, env: &aura_runner::project::Env) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// The FILE-intake counterpart of [`composite_from_any`] (#331 review
|
||||
/// finding): identical parse, plus the blueprint-envelope root-name shape
|
||||
/// gate. Every call site that builds a `Composite` from text freshly read
|
||||
/// off disk (`graph register`, `graph introspect --content-id <FILE>`, the
|
||||
/// bare `aura graph <FILE>` viewer, and `graph introspect --params <FILE>`'s
|
||||
/// file branch) goes through this wrapper — never the ungated
|
||||
/// `composite_from_any` — because a hand-edited envelope with
|
||||
/// `"name":"../x"` would otherwise register/build cleanly and write
|
||||
/// `traces/../x/` at run time (`trace_store.rs` joins the name unsanitized).
|
||||
/// Gating the composite's name unconditionally (not just on the Object
|
||||
/// branch) is harmless: an op-script-built composite's name already passed
|
||||
/// the op-intake gate (`GraphSession::set_name`), so re-checking it here is
|
||||
/// redundant, not restrictive — it keeps this wrapper a single shared choke
|
||||
/// point rather than one that has to re-discriminate the JSON shape. Only
|
||||
/// the ROOT name is checked: the filesystem seam consumes exclusively the
|
||||
/// root name. Store read-back (reproduce, `use`-splice resolution from the
|
||||
/// registry, and `params_lines`'s content-id branch) never reaches this
|
||||
/// function — C29's "registered artifacts are never retroactively
|
||||
/// invalidated" stays intact.
|
||||
///
|
||||
/// **Deliberate exceptions — direct `gate_authored_root_name` callers
|
||||
/// (main.rs).** A handful of fresh-FILE intakes reach the same unsanitized
|
||||
/// `traces/<name>/` seam (or `put_blueprint` the loaded envelope straight
|
||||
/// into the registry) without going through this wrapper, because each
|
||||
/// already parses the document its own way and only needs the shared root-
|
||||
/// name gate bolted on, not the shape-discrimination `composite_from_any`
|
||||
/// does:
|
||||
/// - `aura run <blueprint.json>` (`dispatch_run`): envelope-only grammar (no
|
||||
/// op-script fallback), feeds `signal.name()` into
|
||||
/// `run_signal_r`/`run_measurement` -> `bind_tap_plan` ->
|
||||
/// `TraceStore::begin_run`.
|
||||
/// - `validate_and_register_axes` (shared by `generalize`,
|
||||
/// `sweep --real`, `walkforward --real`, `mc --real`): `put_blueprint`s
|
||||
/// the loaded envelope by topology hash before any of those four verbs
|
||||
/// touches an archive.
|
||||
/// - `run_blueprint_sweep` / `run_blueprint_walkforward` / `run_blueprint_mc`
|
||||
/// (the synthetic, no-`--real` family builders): same `put_blueprint`
|
||||
/// reason, on the routes that bypass `validate_and_register_axes`
|
||||
/// entirely (#331 cycle-close — these used to plant an ungated envelope
|
||||
/// in the store; see each fn's own comment).
|
||||
/// - `list_blueprint_axes` (`aura sweep <FILE> --list-axes`, main.rs): no
|
||||
/// registry write and no trace directory here, but a shape-violating root
|
||||
/// name otherwise mangles through `wrapped_to_raw_axis` into a printed,
|
||||
/// non-bindable axis name instead of refusing (#331 fieldtest finding
|
||||
/// c331_2e) — the class rule is every CLI intake reading an authored
|
||||
/// envelope from a file gates the root name, not only the writing ones.
|
||||
///
|
||||
/// All of these share the identical `name_gate` + `name_gate_fault_prose`
|
||||
/// primitives this wrapper uses, so the refusal wording is byte-identical
|
||||
/// across every authored-file-intake route even though the shape-
|
||||
/// discrimination step is not shared by them.
|
||||
pub(crate) fn composite_from_authored_text(
|
||||
text: &str,
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<Composite, String> {
|
||||
let composite = composite_from_any(text, env)?;
|
||||
gate_authored_root_name(composite.name())?;
|
||||
Ok(composite)
|
||||
}
|
||||
|
||||
/// The root-name shape gate itself (#331 delta re-review), factored out of
|
||||
/// [`composite_from_authored_text`] so `dispatch_run`'s narrower-grammar file
|
||||
/// intake (see that fn's doc comment) can share the exact `name_gate` call and
|
||||
/// `name_gate_fault_prose` wording without going through the shape-discriminating
|
||||
/// wrapper.
|
||||
pub(crate) fn gate_authored_root_name(name: &str) -> Result<(), String> {
|
||||
name_gate(name).map_err(|fault| name_gate_fault_prose(name, &fault))
|
||||
}
|
||||
|
||||
/// Resolve a blueprint document's bytes from a file path or a 64-hex content
|
||||
/// id in the project store (the campaign-run target-addressing convention).
|
||||
/// The id shape is `aura_runner::axes::is_content_id` — the same predicate
|
||||
/// `campaign run`'s target resolution uses, so the two FILE-or-id surfaces
|
||||
/// cannot drift apart on what counts as a store address.
|
||||
fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
/// cannot drift apart on what counts as a store address. The `bool` names
|
||||
/// which branch fired: `true` for a FRESH FILE read, `false` for a STORE
|
||||
/// (content-id) fetch — `params_lines` (#331 delta re-review) uses it to
|
||||
/// decide whether the root-name gate applies (a fresh file must gate; a
|
||||
/// store read-back must not, C29) without re-deriving the FILE-vs-id
|
||||
/// distinction a second time.
|
||||
fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Result<(String, bool), String> {
|
||||
// A CLI arg tolerates the `content:` display prefix (#194); doc ref
|
||||
// fields stay bare-only.
|
||||
let target = target
|
||||
@@ -804,11 +962,12 @@ fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Resu
|
||||
let path = Path::new(target);
|
||||
if path.is_file() {
|
||||
return std::fs::read_to_string(path)
|
||||
.map(|text| (text, true))
|
||||
.map_err(|e| format!("cannot read {}: {e}", path.display()));
|
||||
}
|
||||
if aura_runner::axes::is_content_id(target) {
|
||||
return match env.registry().get_blueprint(target) {
|
||||
Ok(Some(json)) => Ok(json),
|
||||
Ok(Some(json)) => Ok((json, false)),
|
||||
Ok(None) => Err(format!("no blueprint {target} in the project store")),
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
@@ -820,17 +979,28 @@ fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Resu
|
||||
/// line per open param of the RAW composite (no harness wrap) — the
|
||||
/// campaign-axis namespace `validate_campaign_refs` checks axes against. The
|
||||
/// kind renders via `ScalarKind`'s `Debug` (`I64`/`F64`/`Bool`/`Timestamp`),
|
||||
/// the same form `introspect --node` already uses for param kinds.
|
||||
/// the same form `introspect --node` already uses for param kinds. Followed
|
||||
/// by one `{name}:{kind:?} default={value}` line per BOUND param (#328):
|
||||
/// `bound_param_space()`'s names are already RAW (#203), so no blueprint-name
|
||||
/// concatenation is needed — line-identical to the reconciled `--list-axes`
|
||||
/// bound pass (`list_blueprint_axes`, main.rs), same `render_value` lexicon.
|
||||
/// #331 delta re-review: the FILE target is a FOURTH freshly-authored-file
|
||||
/// intake this fn's own `composite_from_any` call had missed gating — a FILE
|
||||
/// routes through [`composite_from_authored_text`] instead; a content id
|
||||
/// (STORE read-back) keeps the ungated `composite_from_any` (C29).
|
||||
fn params_lines(target: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
use std::fmt::Write as _;
|
||||
let text = resolve_blueprint_text(target, env)?;
|
||||
let (text, is_file) = resolve_blueprint_text(target, env)?;
|
||||
// Shape-discriminated like file-mode --content-id: an op-script (array)
|
||||
// builds through the one-build tail, an envelope (object) loads (#202).
|
||||
let composite = composite_from_any(&text, env)?;
|
||||
let composite = if is_file { composite_from_authored_text(&text, env)? } else { composite_from_any(&text, env)? };
|
||||
let mut out = String::new();
|
||||
for p in composite.param_space() {
|
||||
let _ = writeln!(out, "{}:{:?}", p.name, p.kind);
|
||||
}
|
||||
for b in composite.bound_param_space() {
|
||||
let _ = writeln!(out, "{}:{:?} default={}", b.name, b.kind, render_value(&b.value));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -864,7 +1034,9 @@ fn register_blueprint(
|
||||
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
|
||||
// Shape-discriminated like file-mode --content-id (#202): either shape
|
||||
// canonicalizes to the envelope form, so the stored id is shape-invariant.
|
||||
let composite = composite_from_any(&text, env)?;
|
||||
// Gated (#331 review finding): this text is freshly authored FILE bytes,
|
||||
// not a store read-back, so `composite_from_authored_text` applies.
|
||||
let composite = composite_from_authored_text(&text, env)?;
|
||||
let canonical = blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}"))?;
|
||||
let id = crate::content_id(&canonical);
|
||||
let registry = env.registry();
|
||||
|
||||
+404
-135
@@ -45,7 +45,7 @@ use aura_measurement::information_coefficient;
|
||||
// `blueprint_axis_probe_reopened` is production code only from `verb_sugar.rs`
|
||||
// (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::member::{blueprint_axis_probe, blueprint_axis_probe_reopened, run_signal_r, RunData};
|
||||
use aura_runner::{TapPlan, TapSubscription};
|
||||
#[cfg(test)]
|
||||
use aura_runner::member::{run_blueprint_member, wrap_r, SYNTHETIC_PIP_SIZE};
|
||||
@@ -813,17 +813,81 @@ impl IcReport {
|
||||
/// fully closed) — not only the fully-closed case: `--axis`/`override_paths`
|
||||
/// accepts a bound name regardless of how many knobs happen to be open
|
||||
/// alongside it, so the discovery surface must list it regardless too. Names
|
||||
/// are exactly what `--axis` binds.
|
||||
/// are RAW `<node>.<param>` paths (#328: the wrapped `<blueprint>.<node>.<param>`
|
||||
/// form is retired from the surface) — exactly what `--axis` binds.
|
||||
///
|
||||
/// **Gated (#331 fieldtest finding).** This is a FRESH-FILE intake exactly like
|
||||
/// `register` / `introspect --content-id <FILE>` / `introspect --params <FILE>`
|
||||
/// (`composite_from_authored_text`'s doc comment lists the class): a hand-edited
|
||||
/// envelope with a shape-violating root name (`"../x"`) must be refused before
|
||||
/// any axis line prints, not mangled through `wrapped_to_raw_axis` into a
|
||||
/// non-bindable name. Gated immediately after load, before any axis derivation
|
||||
/// — same placement discipline as the other sites — at exit 1, matching the
|
||||
/// register-route convention (this route never `put_blueprint`s anything,
|
||||
/// unlike the exit-2 sweep-EXECUTION route `run_blueprint_sweep` gates).
|
||||
fn list_blueprint_axes(doc: &str, env: &aura_runner::project::Env) {
|
||||
let space = blueprint_axis_probe(doc, env).param_space();
|
||||
for p in &space {
|
||||
println!("{}:{:?}", p.name, p.kind); // ScalarKind Debug -> I64/F64/Bool/Timestamp
|
||||
}
|
||||
let signal = blueprint_from_json(doc, &|t| env.resolve(t))
|
||||
.expect("doc parse-validated at the dispatch boundary");
|
||||
let bp = signal.name().to_string();
|
||||
if let Err(msg) = graph_construct::gate_authored_root_name(signal.name()) {
|
||||
eprintln!("aura: {msg}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let space = blueprint_axis_probe(doc, env).param_space();
|
||||
for p in &space {
|
||||
// Strip the wrapper's one leading node segment (#328): open names print
|
||||
// RAW, matching `--axis`'s own accepted namespace.
|
||||
println!("{}:{:?}", aura_runner::axes::wrapped_to_raw_axis(&p.name), p.kind); // ScalarKind Debug -> I64/F64/Bool/Timestamp
|
||||
}
|
||||
for b in signal.bound_param_space() {
|
||||
println!("{bp}.{}:{:?} default={}", b.name, b.kind, render_value(&b.value));
|
||||
// `bound_param_space()`'s `.name` is already RAW (#203) — no blueprint-name
|
||||
// concatenation (#328: the blueprint name stays out of axis paths, C23).
|
||||
println!("{}:{:?} default={}", b.name, b.kind, render_value(&b.value));
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-sentence prose for a refused WRAPPED `--axis` name (#328), shared
|
||||
/// by both intake routes so the wording carried by `refuse_wrapped_synthetic_axes`
|
||||
/// (the synthetic-sweep preflight) and `validate_and_register_axes` (the
|
||||
/// real-route/campaign preflight) cannot drift into two copies. Returns the
|
||||
/// message WITHOUT the leading `aura: ` — each caller prepends that itself
|
||||
/// (the synthetic route via its own `eprintln!`, the real route via
|
||||
/// `exit_axis_register_error`'s shared `eprintln!("aura: {m}")`), so the
|
||||
/// rendered bytes on both routes stay byte-identical.
|
||||
fn wrapped_axis_refusal(n: &str, raw: &str) -> String {
|
||||
format!(
|
||||
"axis \"{n}\": axis names are raw node.param paths — \
|
||||
use \"{raw}\" (the wrapped --list-axes form was retired, #328)"
|
||||
)
|
||||
}
|
||||
|
||||
/// `--axis` intake preflight for the SYNTHETIC (no `--real`) blueprint-sweep
|
||||
/// route (#328): this route bypasses `validate_and_register_axes` entirely (no
|
||||
/// project/registry touched), so it needs its own copy of the same WRAPPED-name
|
||||
/// refusal — the shared [`aura_runner::axes::classify_axis_intake`] predicate,
|
||||
/// intercepting only a `WrappedRetired` hit (echoing the translation pointer,
|
||||
/// exit 2, before the sweep ever runs). A name matching neither namespace is
|
||||
/// deliberately left unrefused HERE: `blueprint_sweep_family`'s own
|
||||
/// `override_paths` rejects it downstream with today's unchanged prose, so this
|
||||
/// preflight does not duplicate that message under a second wording.
|
||||
fn refuse_wrapped_synthetic_axes(
|
||||
doc: &str,
|
||||
env: &aura_runner::project::Env,
|
||||
axes: &[(String, Vec<Scalar>)],
|
||||
) {
|
||||
let wrapped_open = blueprint_axis_probe(doc, env).param_space();
|
||||
let raw_bound: HashSet<String> = blueprint_from_json(doc, &|t| env.resolve(t))
|
||||
.expect("doc parse-validated at the dispatch boundary")
|
||||
.bound_param_space()
|
||||
.into_iter()
|
||||
.map(|b| b.name)
|
||||
.collect();
|
||||
for (n, _) in axes {
|
||||
if let aura_runner::axes::AxisIntake::WrappedRetired(raw) =
|
||||
aura_runner::axes::classify_axis_intake(n, &wrapped_open, &raw_bound)
|
||||
{
|
||||
eprintln!("aura: {}", wrapped_axis_refusal(n, &raw));
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -851,6 +915,18 @@ fn run_blueprint_sweep(
|
||||
env: &aura_runner::project::Env,
|
||||
) {
|
||||
let _ = persist; // reserved for the deferred per-member trace path; the family record below is unconditional
|
||||
// #331 cycle-close: this fn `put_blueprint`s the loaded fresh-FILE
|
||||
// envelope into the registry below — gate the root name up front,
|
||||
// before the family ever builds (a shape-violating name can otherwise
|
||||
// confuse the wrapped/raw axis resolution the family build performs,
|
||||
// producing a misleading unrelated error instead of this refusal; see
|
||||
// `gate_authored_root_name`'s doc comment for the full route list).
|
||||
let blueprint = blueprint_from_json(doc, &|t| env.resolve(t))
|
||||
.expect("doc parse-validated at the dispatch boundary");
|
||||
if let Err(msg) = graph_construct::gate_authored_root_name(blueprint.name()) {
|
||||
eprintln!("aura: {msg}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let family = blueprint_sweep_family(doc, axes, &data, env).unwrap_or_else(|e| {
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(2);
|
||||
@@ -865,11 +941,7 @@ fn run_blueprint_sweep(
|
||||
.topology_hash
|
||||
.clone()
|
||||
.expect("a blueprint sweep stamps every member's topology_hash");
|
||||
let canonical = blueprint_to_json(
|
||||
&blueprint_from_json(doc, &|t| env.resolve(t))
|
||||
.expect("doc parse-validated at the dispatch boundary"),
|
||||
)
|
||||
.expect("a loaded blueprint re-serializes");
|
||||
let canonical = blueprint_to_json(&blueprint).expect("a loaded blueprint re-serializes");
|
||||
reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| {
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(1);
|
||||
@@ -897,6 +969,16 @@ fn run_blueprint_walkforward(
|
||||
doc: &str, axes: &[(String, Vec<Scalar>)], name: &str, data: DataSource, select: Selection,
|
||||
env: &aura_runner::project::Env,
|
||||
) {
|
||||
// #331 cycle-close: this fn `put_blueprint`s the loaded fresh-FILE
|
||||
// envelope into the registry below — gate the root name up front,
|
||||
// before the family ever builds (see `run_blueprint_sweep`'s identical
|
||||
// comment on why this must precede the family build, not just the
|
||||
// registry write).
|
||||
let blueprint = blueprint_from_json(doc, &|t| env.resolve(t)).expect("doc parse-validated at the dispatch boundary");
|
||||
if let Err(msg) = graph_construct::gate_authored_root_name(blueprint.name()) {
|
||||
eprintln!("aura: {msg}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let result = blueprint_walkforward_family(doc, axes, &data, select, env);
|
||||
let reg = env.registry();
|
||||
let topo = result.windows[0]
|
||||
@@ -906,10 +988,7 @@ fn run_blueprint_walkforward(
|
||||
.topology_hash
|
||||
.clone()
|
||||
.expect("a blueprint walk-forward stamps every member's topology_hash");
|
||||
let canonical = blueprint_to_json(
|
||||
&blueprint_from_json(doc, &|t| env.resolve(t)).expect("doc parse-validated at the dispatch boundary"),
|
||||
)
|
||||
.expect("a loaded blueprint re-serializes");
|
||||
let canonical = blueprint_to_json(&blueprint).expect("a loaded blueprint re-serializes");
|
||||
reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(1); });
|
||||
let id = match reg.append_family(name, FamilyKind::WalkForward, &walkforward_member_reports(&result)) {
|
||||
Ok(id) => id,
|
||||
@@ -933,6 +1012,17 @@ fn run_blueprint_walkforward(
|
||||
/// `FamilyKind::MonteCarlo` family (C18/C21 lineage), and print each draw's member line
|
||||
/// (carrying the seed) plus the aggregate — mirroring `run_blueprint_sweep`.
|
||||
fn run_blueprint_mc(doc: &str, n_seeds: u64, name: &str, data: DataSource, env: &aura_runner::project::Env) {
|
||||
// #331 cycle-close: this fn `put_blueprint`s the loaded fresh-FILE
|
||||
// envelope into the registry below — gate the root name up front,
|
||||
// before the family ever builds (see `run_blueprint_sweep`'s identical
|
||||
// comment on why this must precede the family build, not just the
|
||||
// registry write).
|
||||
let blueprint = blueprint_from_json(doc, &|t| env.resolve(t))
|
||||
.expect("doc parse-validated at the dispatch boundary");
|
||||
if let Err(msg) = graph_construct::gate_authored_root_name(blueprint.name()) {
|
||||
eprintln!("aura: {msg}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let family = blueprint_mc_family(doc, n_seeds, &data, env).unwrap_or_else(|e| {
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(2);
|
||||
@@ -945,11 +1035,7 @@ fn run_blueprint_mc(doc: &str, n_seeds: u64, name: &str, data: DataSource, env:
|
||||
.topology_hash
|
||||
.clone()
|
||||
.expect("a blueprint mc stamps every member's topology_hash");
|
||||
let canonical = blueprint_to_json(
|
||||
&blueprint_from_json(doc, &|t| env.resolve(t))
|
||||
.expect("doc parse-validated at the dispatch boundary"),
|
||||
)
|
||||
.expect("a loaded blueprint re-serializes");
|
||||
let canonical = blueprint_to_json(&blueprint).expect("a loaded blueprint re-serializes");
|
||||
reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| {
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(1);
|
||||
@@ -1172,15 +1258,15 @@ struct DataCmd {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum DataCommand {
|
||||
/// Report a symbol's monthly file-index coverage (span + interior gaps).
|
||||
Coverage(DataCoverageCmd),
|
||||
/// List the archive's known symbols, sorted, one per line.
|
||||
/// Print a symbol's recorded geometry as one flat JSON object.
|
||||
Info(DataInfoCmd),
|
||||
/// List the archive's known symbols as NDJSON, sorted, one per line.
|
||||
List,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct DataCoverageCmd {
|
||||
/// The symbol to inventory (e.g. `GER40`).
|
||||
struct DataInfoCmd {
|
||||
/// The symbol to inspect (e.g. `GER40`).
|
||||
symbol: String,
|
||||
}
|
||||
|
||||
@@ -1649,26 +1735,35 @@ fn parse_axes(
|
||||
Ok(axes)
|
||||
}
|
||||
|
||||
/// The three distinguishable ways `validate_and_register_axes` can fail: an
|
||||
/// unknown axis name is a usage error (exit 2, echoed before the archive is
|
||||
/// touched); a registry write failure is a runtime error (exit 1); running
|
||||
/// outside a project (no `Aura.toml` found up from cwd, #218's gate) is also
|
||||
/// a runtime error (exit 1) — the strategy document cannot resolve against a
|
||||
/// project store/vocabulary that doesn't exist.
|
||||
/// The four distinguishable ways `validate_and_register_axes` can fail: a
|
||||
/// shape-violating root name is a usage error (exit 2, echoed before the
|
||||
/// blueprint ever reaches the registry — #331 cycle-close: this fn
|
||||
/// `put_blueprint`s a fresh-FILE envelope and so must gate it exactly like
|
||||
/// `register`/`run`/the other authored-file intakes, see
|
||||
/// `gate_authored_root_name`'s doc comment); an unknown axis name is a usage
|
||||
/// error (exit 2, echoed before the archive is touched); a registry write
|
||||
/// failure is a runtime error (exit 1); running outside a project (no
|
||||
/// `Aura.toml` found up from cwd, #218's gate) is also a runtime error —
|
||||
/// exit 1, since the strategy document cannot resolve against a project
|
||||
/// store/vocabulary that doesn't exist.
|
||||
enum AxisRegisterError {
|
||||
BadName(String),
|
||||
UnknownAxis(String),
|
||||
Registry(String),
|
||||
NoProject(String),
|
||||
}
|
||||
|
||||
/// Validate every `--axis` name against the blueprint's WRAPPED probe namespace
|
||||
/// (`blueprint_axis_probe`/`--list-axes`) — an axis naming a BOUND param (#246:
|
||||
/// re-openable, same as every already-open knob) passes exactly like an open
|
||||
/// one; only a name matching NEITHER space is refused — then canonicalize +
|
||||
/// register the blueprint by topology hash and strip every axis name to the RAW
|
||||
/// campaign namespace (the sweep sequence, #210 c0110). A raw-form or
|
||||
/// fat-fingered axis name is refused here, echoing exactly what the user typed,
|
||||
/// before the archive is touched or the blueprint is registered — shared by
|
||||
/// Validate every `--axis` name against the RAW campaign-axis namespace only
|
||||
/// (#328: `--list-axes`'s own namespace, open-or-bound) — a WRAPPED name (the
|
||||
/// retired `<blueprint>.<node>.<param>` form) is refused with a translation
|
||||
/// pointer to its raw candidate; a name in neither namespace gets today's
|
||||
/// unmatched-axis prose, unchanged. [`aura_runner::axes::classify_axis_intake`]
|
||||
/// is the shared predicate (deliberately not `raw_matches_wrapped`, whose own
|
||||
/// equality branch would silently accept a wrapped name — see its doc
|
||||
/// comment). Then canonicalize + register the blueprint by topology hash — no
|
||||
/// further strip: the axes are already raw by the time this returns them (the
|
||||
/// old wrapped->raw strip is gone, since a splice path's own dots, e.g.
|
||||
/// `anchor.sess.period_minutes`, must not be mangled a second time). Shared by
|
||||
/// every campaign-path dispatcher (sweep/generalize/walkforward/mc all landed
|
||||
/// the identical block; #220 slice-1 deferred this dedup to "once wf/mc land
|
||||
/// the same block", rule-of-three now exceeded 4x). The override set itself is
|
||||
@@ -1682,17 +1777,28 @@ fn validate_and_register_axes(
|
||||
axes: &[(String, Vec<Scalar>)],
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<(String, Vec<(String, Vec<Scalar>)>), AxisRegisterError> {
|
||||
let space = blueprint_axis_probe(doc, env).param_space();
|
||||
let wrapped_open = blueprint_axis_probe(doc, env).param_space();
|
||||
let blueprint = blueprint_from_json(doc, &|t| env.resolve(t))
|
||||
.expect("doc parse-validated at the dispatch boundary");
|
||||
let bound = wrapped_bound_names(&blueprint);
|
||||
// #331 cycle-close: this fn `put_blueprint`s a fresh-FILE envelope below —
|
||||
// gate the root name before it lands in the registry (shared choke point
|
||||
// with every other authored-file intake).
|
||||
graph_construct::gate_authored_root_name(blueprint.name()).map_err(AxisRegisterError::BadName)?;
|
||||
let raw_bound: HashSet<String> =
|
||||
blueprint.bound_param_space().into_iter().map(|b| b.name).collect();
|
||||
for (n, _) in axes {
|
||||
if !space.iter().any(|p| &p.name == n) && !bound.contains(n) {
|
||||
return Err(AxisRegisterError::UnknownAxis(format!(
|
||||
"axis \"{n}\" is not one of this blueprint's \
|
||||
sweepable axes — run 'aura sweep <bp> --list-axes' \
|
||||
to see them"
|
||||
)));
|
||||
match aura_runner::axes::classify_axis_intake(n, &wrapped_open, &raw_bound) {
|
||||
aura_runner::axes::AxisIntake::Raw => {}
|
||||
aura_runner::axes::AxisIntake::WrappedRetired(raw) => {
|
||||
return Err(AxisRegisterError::UnknownAxis(wrapped_axis_refusal(n, &raw)));
|
||||
}
|
||||
aura_runner::axes::AxisIntake::Unknown => {
|
||||
return Err(AxisRegisterError::UnknownAxis(format!(
|
||||
"axis \"{n}\" is not one of this blueprint's \
|
||||
sweepable axes — run 'aura sweep <bp> --list-axes' \
|
||||
to see them"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if env.provenance().is_none() {
|
||||
@@ -1709,20 +1815,21 @@ fn validate_and_register_axes(
|
||||
let topo = topology_hash(&blueprint);
|
||||
reg.put_blueprint(&topo, &canonical)
|
||||
.map_err(|e| AxisRegisterError::Registry(e.to_string()))?;
|
||||
let raw_axes: Vec<(String, Vec<Scalar>)> = axes
|
||||
.iter()
|
||||
.map(|(n, v)| (aura_runner::axes::wrapped_to_raw_axis(n).to_string(), v.clone()))
|
||||
.collect();
|
||||
Ok((canonical, raw_axes))
|
||||
Ok((canonical, axes.to_vec()))
|
||||
}
|
||||
|
||||
/// Exit-map for a `validate_and_register_axes` failure — the stderr line and
|
||||
/// exit code every campaign dispatcher (sweep/generalize/walkforward/mc)
|
||||
/// preserves: an unknown axis is a usage error (exit 2, echoed before the
|
||||
/// archive is touched); a registry write failure or a missing project (no
|
||||
/// `Aura.toml` found up from cwd, #218's gate) is a runtime error (exit 1).
|
||||
/// preserves: a shape-violating root name (#331) or an unknown axis is a
|
||||
/// usage error (exit 2, echoed before the archive is touched); a registry
|
||||
/// write failure or a missing project (no `Aura.toml` found up from cwd,
|
||||
/// #218's gate) is a runtime error (exit 1).
|
||||
fn exit_axis_register_error(e: AxisRegisterError) -> ! {
|
||||
match e {
|
||||
AxisRegisterError::BadName(m) => {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(2)
|
||||
}
|
||||
AxisRegisterError::UnknownAxis(m) => {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(2)
|
||||
@@ -1828,6 +1935,19 @@ fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) {
|
||||
eprintln!("aura: {path}: {msg}");
|
||||
std::process::exit(2);
|
||||
});
|
||||
// #331 delta re-review: this is the third freshly-authored-FILE
|
||||
// intake (`register`, `introspect --content-id <FILE>`, and this
|
||||
// one) that must gate the root name before it ever reaches the
|
||||
// filesystem — both run arms below feed `signal.name()` straight
|
||||
// into `bind_tap_plan`'s `TraceStore::begin_run`, which joins it
|
||||
// unsanitized (`trace_store.rs`). Not routed through
|
||||
// `composite_from_authored_text` itself (see that fn's doc
|
||||
// comment on why); same `name_gate`/`name_gate_fault_prose`
|
||||
// primitives, this arm's own load-error exit/prose convention.
|
||||
if let Err(msg) = graph_construct::gate_authored_root_name(signal.name()) {
|
||||
eprintln!("aura: {path}: {msg}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
// Shape dispatch (C28 phase 3): a `bias` output → the strategy path
|
||||
// (wrap_r + R evaluation), byte-identical; else ≥1 declared tap → a
|
||||
// bare measurement run (no wrap_r); else an inert blueprint → refuse.
|
||||
@@ -1925,7 +2045,7 @@ fn dispatch_graph(a: GraphCmd, env: &aura_runner::project::Env) {
|
||||
eprintln!("aura: {path}: {e}");
|
||||
std::process::exit(2);
|
||||
});
|
||||
let bp = graph_construct::composite_from_any(&doc, env).unwrap_or_else(|msg| {
|
||||
let bp = graph_construct::composite_from_authored_text(&doc, env).unwrap_or_else(|msg| {
|
||||
eprintln!("aura: {path}: {msg}");
|
||||
std::process::exit(2);
|
||||
});
|
||||
@@ -2160,7 +2280,7 @@ fn dispatch_nodes_new(a: NodesNewCmd) {
|
||||
|
||||
fn dispatch_data(cmd: DataCmd, env: &aura_runner::project::Env) {
|
||||
match cmd.command {
|
||||
DataCommand::Coverage(a) => dispatch_data_coverage(a, env),
|
||||
DataCommand::Info(a) => dispatch_data_info(a, env),
|
||||
DataCommand::List => dispatch_data_list(env),
|
||||
}
|
||||
}
|
||||
@@ -2205,57 +2325,116 @@ fn dispatch_measure_ic(a: MeasureIcCmd, env: &aura_runner::project::Env) {
|
||||
println!("{}", report.to_json());
|
||||
}
|
||||
|
||||
/// `aura data list` (#264 cut 2): print the archive's known symbols, sorted
|
||||
/// ascending, one per line — the discovery step before `aura data coverage`
|
||||
/// or scoping a campaign's instrument matrix. Resolves the archive root
|
||||
/// through the normal project path ([`dispatch_data_coverage`]'s
|
||||
/// `Env::data_path`), then reuses `DataServer`'s own symbol index
|
||||
/// (`DataServer::symbols()`, already sorted) rather than re-deriving a
|
||||
/// directory scan. An empty or absent archive is informational absence, not a
|
||||
/// fault: a `no symbols` prose line, exit 0 (the verb corpus's established
|
||||
/// empty-result register — cf. `runs family`'s unknown-id empty exit 0).
|
||||
/// `aura data list` (#273: the namespace-wide NDJSON reshape): print the
|
||||
/// archive's known symbols as NDJSON, sorted ascending, one JSON string per
|
||||
/// line — the discovery step before `aura data info <symbol>` or scoping a
|
||||
/// campaign's instrument matrix. Resolves the archive root through the normal
|
||||
/// project path (`Env::data_path`), then reuses `DataServer`'s own symbol
|
||||
/// index (`DataServer::symbols()`, already sorted) rather than re-deriving a
|
||||
/// directory scan. An empty or absent archive prints zero stdout lines — the
|
||||
/// honest NDJSON of an empty set, since a placeholder prose line would break
|
||||
/// `| jq`-style consumption; the human affordance moves to a
|
||||
/// `crate::diag::note!` stderr notice instead (#273 design minutes,
|
||||
/// 2026-07-25).
|
||||
fn dispatch_data_list(env: &aura_runner::project::Env) {
|
||||
let data_path = std::path::PathBuf::from(env.data_path());
|
||||
let server = aura_ingest::DataServer::new(&data_path);
|
||||
for line in data_list_report(&server.symbols()) {
|
||||
let report = data_list_report(&server.symbols());
|
||||
if report.is_empty() {
|
||||
crate::diag::note!("no symbols in the archive");
|
||||
return;
|
||||
}
|
||||
for line in report {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure: render `symbols` (already sorted by `DataServer::symbols()`) as one
|
||||
/// line per symbol, or a single `no symbols` line when the archive is empty
|
||||
/// or absent — informational absence, never a fault (#264).
|
||||
/// JSON-string line per symbol (`serde_json::to_string`, so `SYMA` becomes the
|
||||
/// quoted `"SYMA"` NDJSON line) — an empty archive yields an empty `Vec`,
|
||||
/// never a placeholder line, so stdout stays pure NDJSON either way (#273).
|
||||
fn data_list_report(symbols: &[std::sync::Arc<str>]) -> Vec<String> {
|
||||
if symbols.is_empty() {
|
||||
return vec!["no symbols".to_string()];
|
||||
}
|
||||
symbols.iter().map(|s| s.to_string()).collect()
|
||||
symbols
|
||||
.iter()
|
||||
.map(|s| serde_json::to_string(s.as_ref()).expect("a symbol string always serializes"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `aura data coverage <SYMBOL>` (#264 cut 1): print the archive's month
|
||||
/// coverage for `SYMBOL` — the Copper failure mode (files present at both
|
||||
/// ends of a window while an interior month is missing, so a first/last-bounds
|
||||
/// check passes but a campaign run aborts mid-window) made visible up front.
|
||||
/// Resolves the archive root through the normal project path (`Env::data_path`
|
||||
/// — a project-local `[paths] data` override when set, the data-server default
|
||||
/// otherwise), so a project-scoped archive is inventoried, not the host one.
|
||||
fn dispatch_data_coverage(a: DataCoverageCmd, env: &aura_runner::project::Env) {
|
||||
/// `aura data info <SYMBOL>` (#273): print a symbol's recorded geometry as one
|
||||
/// flat JSON object — the machine-readable follow-up to `aura data list`'s
|
||||
/// discovery pass, surfacing `DataServer::symbol_meta`'s neutral,
|
||||
/// broker-agnostic geometry (digits / pip / tick / lot sizing, base / quote
|
||||
/// asset) without a human ever opening the `<SYMBOL>.meta.json` sidecar by
|
||||
/// hand. Resolves the archive root through the normal project path
|
||||
/// (`Env::data_path`, same as every other `data` verb). A symbol is KNOWN
|
||||
/// when the archive holds bar files for it (`DataServer::has_symbol`) OR it
|
||||
/// carries a usable geometry sidecar (`DataServer::symbol_meta`) — a
|
||||
/// sidecar-only symbol (bars not yet ingested) still reports its geometry. An
|
||||
/// unknown symbol (neither) refuses on stderr, exit 1. Nothing else about the
|
||||
/// archive (span, gaps, provider metadata) surfaces here — that was `aura
|
||||
/// data coverage`'s job; #272's per-cell fault isolation made it redundant,
|
||||
/// so this issue retires it rather than folding it in (#273 design minutes).
|
||||
fn dispatch_data_info(a: DataInfoCmd, env: &aura_runner::project::Env) {
|
||||
let data_path = std::path::PathBuf::from(env.data_path());
|
||||
let months = aura_ingest::list_m1_months(&data_path, &a.symbol);
|
||||
// The report body is single-sourced with a campaign cell's own coverage
|
||||
// annotation (#295 Task 7: `aura_runner::coverage::interior_gap_months`
|
||||
// is the one gap walk both consumers call).
|
||||
match aura_runner::coverage::data_coverage_report(&a.symbol, &months) {
|
||||
Ok(lines) => {
|
||||
for line in lines {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("aura: {e} at {}", env.data_path());
|
||||
std::process::exit(1);
|
||||
}
|
||||
let server = aura_ingest::DataServer::new(&data_path);
|
||||
let geo = server.symbol_meta(&a.symbol);
|
||||
if geo.is_none() && !server.has_symbol(&a.symbol) {
|
||||
eprintln!(
|
||||
"aura: no recorded data for symbol \"{}\" (no archive files, no geometry sidecar)",
|
||||
a.symbol
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("{}", data_info_report(&a.symbol, geo.as_ref()));
|
||||
}
|
||||
|
||||
/// One `aura data info` object's shape: `symbol` plus, when a geometry
|
||||
/// sidecar was found, its six neutral price-geometry fields — never a
|
||||
/// provider-specific field (`generator`, `schemaVersion`, `generatedAtUtc`,
|
||||
/// #273 acceptance), since `InstrumentGeometry` never carries one to begin
|
||||
/// with. `InstrumentGeometry` itself has no `Serialize` impl, so
|
||||
/// [`data_info_report`] copies its fields in here. `rename_all = "camelCase"`
|
||||
/// turns `pip_size`/`tick_size`/`lot_size`/`base_asset`/`quote_asset` into the
|
||||
/// sidecar's own `pipSize`/`tickSize`/`lotSize`/`baseAsset`/`quoteAsset`
|
||||
/// spelling; `#[serde(skip_serializing_if)]` drops the whole geometry tail
|
||||
/// when absent rather than emitting `null`s, so a sidecar-less known symbol
|
||||
/// reports as bare `{"symbol":"…"}` (#273 design minutes, 2026-07-25:
|
||||
/// `description` stays out of this report until the reader exposes it).
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DataInfoReport {
|
||||
symbol: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
digits: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pip_size: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tick_size: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
lot_size: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
base_asset: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
quote_asset: Option<String>,
|
||||
}
|
||||
|
||||
/// Pure: `aura data info <SYMBOL>`'s report body (#273) — one flat JSON
|
||||
/// object, field order `symbol`, `digits`, `pipSize`, `tickSize`, `lotSize`,
|
||||
/// `baseAsset`, `quoteAsset` (serde's field-declaration order is stable,
|
||||
/// unlike `serde_json::Map`'s alphabetical iteration). `geo` is `None` for a
|
||||
/// known-but-sidecar-less symbol, in which case every geometry field is
|
||||
/// omitted (see [`DataInfoReport`]).
|
||||
fn data_info_report(symbol: &str, geo: Option<&aura_ingest::InstrumentGeometry>) -> String {
|
||||
let report = DataInfoReport {
|
||||
symbol: symbol.to_string(),
|
||||
digits: geo.map(|g| g.digits),
|
||||
pip_size: geo.map(|g| g.pip_size),
|
||||
tick_size: geo.map(|g| g.tick_size),
|
||||
lot_size: geo.map(|g| g.lot_size),
|
||||
base_asset: geo.map(|g| g.base.clone()),
|
||||
quote_asset: geo.map(|g| g.quote.clone()),
|
||||
};
|
||||
serde_json::to_string(&report).expect("a finite DataInfoReport always serializes")
|
||||
}
|
||||
|
||||
/// `aura sweep`: loaded-blueprint by-name axis sweep (or `--list-axes` probe) when the
|
||||
@@ -2326,8 +2505,8 @@ fn dispatch_sweep(a: SweepCmd, env: &aura_runner::project::Env) {
|
||||
// strategy ref resolves against this one write; a second
|
||||
// put under `content_id_of(&canonical)` would target the
|
||||
// identical key and is not needed.
|
||||
// Axis validation + canonicalize/register + the wrapped->raw
|
||||
// strip are single-sourced in `validate_and_register_axes`
|
||||
// Axis validation (RAW-only, #328) + canonicalize/register are
|
||||
// single-sourced in `validate_and_register_axes`
|
||||
// (data-free, so this fires before the archive is touched).
|
||||
let (canonical, raw_axes) = validate_and_register_axes("sweep", &doc, &axes, env)
|
||||
.unwrap_or_else(|e| exit_axis_register_error(e));
|
||||
@@ -2361,6 +2540,10 @@ fn dispatch_sweep(a: SweepCmd, env: &aura_runner::project::Env) {
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
// #328: this route bypasses `validate_and_register_axes` (no
|
||||
// project/registry involved), so it needs its own WRAPPED-name
|
||||
// refusal ahead of the sweep.
|
||||
refuse_wrapped_synthetic_axes(&doc, env, &axes);
|
||||
run_blueprint_sweep(
|
||||
&doc, &axes, &name, persist,
|
||||
DataSource::from_choice(data, env), env,
|
||||
@@ -2418,8 +2601,8 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &aura_runner::project::Env) {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(2);
|
||||
});
|
||||
// Axis validation + canonicalize/register + the wrapped->raw
|
||||
// strip are single-sourced in `validate_and_register_axes`
|
||||
// Axis validation (RAW-only, #328) + canonicalize/register are
|
||||
// single-sourced in `validate_and_register_axes`
|
||||
// (data-free, so this fires before the archive is touched).
|
||||
let (canonical, raw_axes) = validate_and_register_axes("walkforward", &doc, &axes, env)
|
||||
.unwrap_or_else(|e| exit_axis_register_error(e));
|
||||
@@ -2463,10 +2646,11 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &aura_runner::project::Env) {
|
||||
exit_on_campaign_result(result);
|
||||
return;
|
||||
}
|
||||
// Synthetic in-process family path — unchanged (#220 non-goal); it
|
||||
// persists no per-window taps, so `--trace` stays refused here (#224
|
||||
// delivered only the `--real` campaign path above), mirroring
|
||||
// `dispatch_sweep`'s synthetic-arm refusal with its own named pointer.
|
||||
// Synthetic in-process family path (#220 non-goal, unchanged beyond
|
||||
// the #328 tidy below); it persists no per-window taps, so
|
||||
// `--trace` stays refused here (#224 delivered only the `--real`
|
||||
// campaign path above), mirroring `dispatch_sweep`'s synthetic-arm
|
||||
// refusal with its own named pointer.
|
||||
if a.trace.is_some() {
|
||||
eprintln!(
|
||||
"aura: --trace is not yet available on a synthetic walkforward (no --real); see #224"
|
||||
@@ -2485,6 +2669,10 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &aura_runner::project::Env) {
|
||||
None => Selection::Argmax,
|
||||
};
|
||||
let name = a.name.clone().unwrap_or_else(|| "walkforward".to_string());
|
||||
// #328: this route bypasses `validate_and_register_axes` (no
|
||||
// project/registry involved, same gap the plain synthetic sweep
|
||||
// arm had) — the same WRAPPED-name preflight closes it here too.
|
||||
refuse_wrapped_synthetic_axes(&doc, env, &axes);
|
||||
run_blueprint_walkforward(&doc, &axes, &name, DataSource::Synthetic, select, env);
|
||||
}
|
||||
None => {
|
||||
@@ -2532,8 +2720,8 @@ fn dispatch_mc(a: McCmd, env: &aura_runner::project::Env) {
|
||||
eprintln!("aura: {}", usage());
|
||||
std::process::exit(2);
|
||||
}
|
||||
// Axis validation + canonicalize/register + the wrapped->raw
|
||||
// strip are single-sourced in `validate_and_register_axes`
|
||||
// Axis validation (RAW-only, #328) + canonicalize/register are
|
||||
// single-sourced in `validate_and_register_axes`
|
||||
// (data-free, so this fires before the archive is touched).
|
||||
let (canonical, raw_axes) = validate_and_register_axes("mc", &doc, &axes, env)
|
||||
.unwrap_or_else(|e| exit_axis_register_error(e));
|
||||
@@ -2690,23 +2878,55 @@ mod tests {
|
||||
}
|
||||
|
||||
/// An empty (or absent) archive is informational absence, not a fault
|
||||
/// (#264): `data_list_report` returns the single `no symbols` prose line
|
||||
/// rather than an empty `Vec`, so the caller's exit-0 println always has
|
||||
/// something to say.
|
||||
/// (#264), but under the all-JSON namespace reshape (#273) stdout must
|
||||
/// stay pure NDJSON: `data_list_report` returns an empty `Vec` rather
|
||||
/// than a placeholder prose line, so the caller has nothing to print and
|
||||
/// moves the human affordance to stderr instead.
|
||||
#[test]
|
||||
fn data_list_report_of_an_empty_archive_is_a_no_symbols_line() {
|
||||
fn data_list_report_of_an_empty_archive_is_empty() {
|
||||
let symbols: Vec<std::sync::Arc<str>> = vec![];
|
||||
assert_eq!(data_list_report(&symbols), vec!["no symbols".to_string()]);
|
||||
assert_eq!(data_list_report(&symbols), Vec::<String>::new());
|
||||
}
|
||||
|
||||
/// A non-empty archive renders one line per symbol, in the order handed
|
||||
/// in (`DataServer::symbols()` already sorts) — no `no symbols` line
|
||||
/// mixed in (#264).
|
||||
/// A non-empty archive renders one NDJSON line per symbol, in the order
|
||||
/// handed in (`DataServer::symbols()` already sorts) — each symbol as a
|
||||
/// quoted JSON string, not a bare name (#273: the NDJSON reshape).
|
||||
#[test]
|
||||
fn data_list_report_of_a_populated_archive_is_one_line_per_symbol() {
|
||||
fn data_list_report_of_a_populated_archive_is_one_json_string_per_line() {
|
||||
let symbols: Vec<std::sync::Arc<str>> =
|
||||
vec![std::sync::Arc::from("SYMA"), std::sync::Arc::from("SYMB")];
|
||||
assert_eq!(data_list_report(&symbols), vec!["SYMA".to_string(), "SYMB".to_string()]);
|
||||
assert_eq!(
|
||||
data_list_report(&symbols),
|
||||
vec!["\"SYMA\"".to_string(), "\"SYMB\"".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
/// `aura data info`'s report body with a full geometry sidecar present:
|
||||
/// the exact output string, pinning both the field order (`symbol` then
|
||||
/// the six geometry fields, camelCased) and serde_json's own float
|
||||
/// rendering (#273).
|
||||
#[test]
|
||||
fn data_info_report_with_geometry_is_symbol_plus_six_camelcase_fields() {
|
||||
let geo = aura_ingest::InstrumentGeometry {
|
||||
digits: 5,
|
||||
pip_size: 0.0001,
|
||||
tick_size: 1e-05,
|
||||
lot_size: 100_000.0,
|
||||
base: "EUR".to_string(),
|
||||
quote: "USD".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
data_info_report("EURUSD", Some(&geo)),
|
||||
r#"{"symbol":"EURUSD","digits":5,"pipSize":0.0001,"tickSize":0.00001,"lotSize":100000.0,"baseAsset":"EUR","quoteAsset":"USD"}"#
|
||||
);
|
||||
}
|
||||
|
||||
/// A known symbol with no geometry sidecar reports bare identity — no
|
||||
/// `null` geometry fields, no provider metadata, just `symbol` (#273:
|
||||
/// "a sidecar-less known symbol yields just `{"symbol":"…"}`").
|
||||
#[test]
|
||||
fn data_info_report_without_geometry_is_bare_symbol() {
|
||||
assert_eq!(data_info_report("SYMX", None), r#"{"symbol":"SYMX"}"#);
|
||||
}
|
||||
|
||||
/// #234: `campaign_run::persist_campaign_traces`'s C1 drift alarm re-runs a
|
||||
@@ -3532,11 +3752,11 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #246: a fully-bound (closed) blueprint IS sweepable — an axis naming a
|
||||
/// bound param re-opens it (the bound value is the default), so the retired
|
||||
/// "fully bound; nothing to sweep" refusal must not resurface. Inverse
|
||||
/// guard: an axis naming NEITHER an open nor a bound param gets the one
|
||||
/// clear boundary message (not a terse `UnknownKnob` debug leak).
|
||||
/// #246/#328: a fully-bound (closed) blueprint IS sweepable — a RAW axis
|
||||
/// naming a bound param re-opens it (the bound value is the default), so
|
||||
/// the retired "fully bound; nothing to sweep" refusal must not resurface.
|
||||
/// Inverse guard: an axis naming NEITHER an open nor a bound param gets
|
||||
/// the one clear boundary message (not a terse `UnknownKnob` debug leak).
|
||||
#[test]
|
||||
fn blueprint_sweep_family_overrides_a_bound_param_and_names_unknown_axes() {
|
||||
let env = aura_runner::project::Env::std();
|
||||
@@ -3544,9 +3764,9 @@ mod tests {
|
||||
let closed = load_closed_r_sma();
|
||||
let doc = blueprint_to_json(&closed).expect("serializes");
|
||||
|
||||
// (a) override axis: two members, no refusal
|
||||
// (a) override axis (RAW name, #328): two members, no refusal
|
||||
let axes = vec![(
|
||||
"sma_signal.fast.length".to_string(),
|
||||
"fast.length".to_string(),
|
||||
vec![Scalar::i64(2), Scalar::i64(4)],
|
||||
)];
|
||||
let fam = blueprint_sweep_family(&doc, &axes, &DataSource::Synthetic, &env)
|
||||
@@ -3554,7 +3774,7 @@ mod tests {
|
||||
assert_eq!(fam.points.len(), 2);
|
||||
|
||||
// (b) unknown axis: the boundary message, no UnknownKnob leak
|
||||
let bad = vec![("sma_signal.nope".to_string(), vec![Scalar::i64(1)])];
|
||||
let bad = vec![("nope".to_string(), vec![Scalar::i64(1)])];
|
||||
let err = blueprint_sweep_family(&doc, &bad, &DataSource::Synthetic, &env)
|
||||
.expect_err("an axis matching neither space is refused");
|
||||
assert!(err.contains("names no param"), "boundary message, got: {err}");
|
||||
@@ -3562,35 +3782,36 @@ mod tests {
|
||||
assert!(!err.contains("UnknownKnob"), "must not leak the debug render: {err}");
|
||||
}
|
||||
|
||||
/// #249: an axis-reopened bound param flows through `params` ("what
|
||||
/// #249/#328: an axis-reopened bound param flows through `params` ("what
|
||||
/// varied") and must NOT also appear in `defaults` ("what was held") — the
|
||||
/// two are disjoint by construction. Sweeping `fast.length` on the fully
|
||||
/// bound r_sma example leaves `slow.length`/`bias.scale` untouched (they
|
||||
/// stay in `defaults`), while `fast.length` moves to `params` and drops
|
||||
/// out of `defaults` entirely.
|
||||
/// two are disjoint by construction. Sweeping `fast.length` (RAW, #328) on
|
||||
/// the fully bound r_sma example leaves `slow.length`/`bias.scale`
|
||||
/// untouched (they stay in `defaults`), while `fast.length` moves to
|
||||
/// `params` and drops out of `defaults` entirely. Both `manifest.params`
|
||||
/// and `manifest.defaults` keys are RAW (#328, batch 2 flips defaults too).
|
||||
#[test]
|
||||
fn sweep_override_excludes_the_reopened_default_from_the_manifest() {
|
||||
let env = aura_runner::project::Env::std();
|
||||
let closed = load_closed_r_sma();
|
||||
let doc = blueprint_to_json(&closed).expect("serializes");
|
||||
let axes = vec![("sma_signal.fast.length".to_string(), vec![Scalar::i64(2)])];
|
||||
let axes = vec![("fast.length".to_string(), vec![Scalar::i64(2)])];
|
||||
let fam = blueprint_sweep_family(&doc, &axes, &DataSource::Synthetic, &env)
|
||||
.expect("a bound param is a default — the axis overrides it");
|
||||
assert_eq!(fam.points.len(), 1);
|
||||
let manifest = &fam.points[0].report.manifest;
|
||||
|
||||
let param_names: Vec<&str> = manifest.params.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert!(param_names.contains(&"sma_signal.fast.length"), "swept axis is a param: {param_names:?}");
|
||||
assert!(param_names.contains(&"fast.length"), "swept axis is a raw param: {param_names:?}");
|
||||
|
||||
let default_names: Vec<&str> = manifest.defaults.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert!(
|
||||
!default_names.contains(&"sma_signal.fast.length"),
|
||||
!default_names.contains(&"fast.length"),
|
||||
"the reopened default must not also appear in defaults: {default_names:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
default_names,
|
||||
["sma_signal.slow.length", "sma_signal.bias.scale"],
|
||||
"the untouched bound params stay in defaults: {default_names:?}"
|
||||
["slow.length", "bias.scale"],
|
||||
"the untouched bound params stay in defaults, RAW: {default_names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3809,6 +4030,54 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #271 (cross-path identity, extending #171's bridge pattern to an
|
||||
/// arg-bearing type): the Rust-authored `Session::configured` builder and
|
||||
/// its `add`-op `args` twin differ in canonical bytes (debug names) —
|
||||
/// distinct content ids — but project to the same identity JSON, one
|
||||
/// identity id across authoring paths even though the construction
|
||||
/// channel (Rust call vs `args` object) differs.
|
||||
#[test]
|
||||
fn identity_id_bridges_the_rust_configured_session_and_args_script() {
|
||||
use aura_market::Session;
|
||||
|
||||
let rust_built = Composite::new(
|
||||
"sess",
|
||||
vec![Session::configured(9, 30, chrono_tz::Europe::Berlin, 15).into()],
|
||||
vec![],
|
||||
vec![aura_engine::Role {
|
||||
name: "trigger".into(),
|
||||
targets: vec![aura_engine::Target { node: 0, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![aura_engine::OutField { node: 0, field: 0, name: "bars".into() }],
|
||||
);
|
||||
|
||||
let doc = r#"[
|
||||
{"op":"source","role":"trigger","kind":"F64"},
|
||||
{"op":"add","type":"Session","name":"sess",
|
||||
"args":{"tz":"Europe/Berlin","open":"09:30"},
|
||||
"bind":{"period_minutes":{"I64":15}}},
|
||||
{"op":"feed","role":"trigger","into":["sess.trigger"]},
|
||||
{"op":"expose","from":"sess.bars_since_open","as":"bars"}
|
||||
]"#;
|
||||
let env = aura_runner::project::Env::std();
|
||||
let json = crate::graph_construct::build_from_str(doc, &env).expect("args op-script twin builds");
|
||||
assert_ne!(
|
||||
content_id(&json),
|
||||
content_id(&blueprint_to_json(&rust_built).expect("serializes")),
|
||||
"authoring paths keep distinct content ids (debug names differ)"
|
||||
);
|
||||
let loaded = blueprint_from_json(&json, &|t| std_vocabulary(t)).expect("twin reloads");
|
||||
let identity = |c: &Composite| {
|
||||
content_id(&aura_engine::blueprint_identity_json(c).expect("identity-serializes"))
|
||||
};
|
||||
assert_eq!(
|
||||
identity(&loaded),
|
||||
identity(&rust_built),
|
||||
"one topology -> one identity id, Rust-configured vs args op-script"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mc_r_bootstrap_json_carries_every_bootstrap_field_under_the_mc_r_bootstrap_key() {
|
||||
// Property: the `mc_r_bootstrap` output line is the full RBootstrap shape —
|
||||
|
||||
@@ -462,8 +462,14 @@ pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
|
||||
RefFault::StrategyUnloadable { id, error } => {
|
||||
format!("strategy {id} cannot be loaded: {error}")
|
||||
}
|
||||
RefFault::AxisNotInParamSpace { strategy, axis } => {
|
||||
format!("strategy {strategy}: axis \"{axis}\" is not in the param space")
|
||||
RefFault::AxisNotInParamSpace { strategy, axis, raw_candidate } => {
|
||||
let mut msg = format!("strategy {strategy}: axis \"{axis}\" is not in the param space");
|
||||
if let Some(candidate) = raw_candidate {
|
||||
msg.push_str(&format!(
|
||||
"; axis names are raw node.param paths — did you mean \"{candidate}\"?"
|
||||
));
|
||||
}
|
||||
msg
|
||||
}
|
||||
RefFault::AxisKindMismatch { strategy, axis } => {
|
||||
format!("strategy {strategy}: axis \"{axis}\" declares a kind that is not the param's kind")
|
||||
@@ -761,6 +767,7 @@ mod tests {
|
||||
ref_fault_prose(&RefFault::AxisNotInParamSpace {
|
||||
strategy: "9f3a".into(),
|
||||
axis: "nope".into(),
|
||||
raw_candidate: None,
|
||||
}),
|
||||
ref_fault_prose(&RefFault::AxisKindMismatch {
|
||||
strategy: "9f3a".into(),
|
||||
@@ -781,6 +788,39 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #328: `ref_fault_prose` appends the did-you-mean clause, contiguous
|
||||
/// and verbatim, exactly when `raw_candidate` is present — the document-
|
||||
/// side refusal seam's mirror of the sweep `--axis` intake translation
|
||||
/// (`aura-runner`'s `axes.rs` classify predicate), sharing the same
|
||||
/// `axis names are raw node.param paths` clause pinned there.
|
||||
#[test]
|
||||
fn ref_fault_prose_renders_the_did_you_mean_when_a_raw_candidate_is_present() {
|
||||
let msg = ref_fault_prose(&RefFault::AxisNotInParamSpace {
|
||||
strategy: "9f3a".into(),
|
||||
axis: "graph.fast.length".into(),
|
||||
raw_candidate: Some("fast.length".into()),
|
||||
});
|
||||
assert_eq!(
|
||||
msg,
|
||||
"strategy 9f3a: axis \"graph.fast.length\" is not in the param space; \
|
||||
axis names are raw node.param paths — did you mean \"fast.length\"?"
|
||||
);
|
||||
}
|
||||
|
||||
/// #328 (negative): a rejected axis with no `raw_candidate` (stripping one
|
||||
/// leading segment yields no param-space hit, or the axis has no leading
|
||||
/// segment to strip at all) renders today's prose unchanged — no
|
||||
/// speculative suggestion.
|
||||
#[test]
|
||||
fn ref_fault_prose_omits_the_did_you_mean_when_no_raw_candidate() {
|
||||
let msg = ref_fault_prose(&RefFault::AxisNotInParamSpace {
|
||||
strategy: "9f3a".into(),
|
||||
axis: "nope".into(),
|
||||
raw_candidate: None,
|
||||
});
|
||||
assert_eq!(msg, "strategy 9f3a: axis \"nope\" is not in the param space");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #194 (the prefix trap): a doc ref that carries the display `content:`
|
||||
/// prefix (a copy-paste from register/introspect output) is bare-only in
|
||||
|
||||
@@ -234,8 +234,9 @@ std vocabulary, anchored by `Aura.toml`. There is no crate and no build step.
|
||||
- Run: `aura run blueprints/signal.json` (the starter is closed — all
|
||||
params bound; bound values are defaults — any `--axis` may override them
|
||||
(#246))
|
||||
- Sweep: `aura sweep blueprints/signal.json --axis __NAME_SNAKE___signal.fast.length=2,4,8`
|
||||
(`aura sweep <bp> --list-axes` lists the open + bound-overridable axes)
|
||||
- Sweep: `aura sweep blueprints/signal.json --axis fast.length=2,4,8`
|
||||
(`aura sweep <bp> --list-axes` lists the open + bound-overridable axes, RAW
|
||||
`<node>.<param>` names — #328)
|
||||
- Native nodes: when the project needs its first project-specific node,
|
||||
`aura nodes new <name>` scaffolds a node crate beside this project and
|
||||
attaches it via `[nodes]` in `Aura.toml` (build it with `cargo build`).
|
||||
@@ -459,10 +460,11 @@ mod tests {
|
||||
let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab");
|
||||
assert!(claude.contains("demo-lab"));
|
||||
// The CLAUDE.md sweep quickstart targets the closed starter itself
|
||||
// with the rendered blueprint-name axis prefix (#246: a bound param
|
||||
// is a default an axis overrides).
|
||||
// with the RAW axis name (#246: a bound param is a default an axis
|
||||
// overrides; #328: the axis namespace is raw, no blueprint-name
|
||||
// prefix).
|
||||
assert!(claude.contains("blueprints/signal.json"));
|
||||
assert!(claude.contains("demo_lab_signal.fast.length"));
|
||||
assert!(claude.contains("--axis fast.length"));
|
||||
}
|
||||
|
||||
/// #315: the scaffolded project CLAUDE.md teaches the execution semantics
|
||||
|
||||
+573
-257
File diff suppressed because it is too large
Load Diff
@@ -88,6 +88,43 @@ const SIGNAL_DOC_NAME_RESTATED: &str = r#"[
|
||||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||||
]"#;
|
||||
|
||||
/// SIGNAL_DOC with a leading `{"op":"name",...}` (#331) — authors a render
|
||||
/// name distinct from the CLI's own default seed ("graph"), otherwise
|
||||
/// structurally identical to SIGNAL_DOC.
|
||||
const SIGNAL_DOC_NAMED: &str = r#"[
|
||||
{"op":"name","name":"ny_momentum"},
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
||||
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"add","type":"Bias"},
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"},
|
||||
{"op":"connect","from":"slow.value","to":"sub.rhs"},
|
||||
{"op":"connect","from":"sub.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||||
]"#;
|
||||
|
||||
/// SIGNAL_DOC_NAME_RESTATED with an authored name (#331 spec test 7) instead
|
||||
/// of the CLI's default seed: the `doc` op's text restates the *authored*
|
||||
/// name ("ny_momentum") rather than the literal "graph" — the same
|
||||
/// RestatesName arm of the C29 shape gate must fire against whichever name
|
||||
/// the composite actually carries, not a hardcoded "graph" comparison.
|
||||
const SIGNAL_DOC_NAMED_NAME_RESTATED: &str = r#"[
|
||||
{"op":"name","name":"ny_momentum"},
|
||||
{"op":"doc","text":"Ny Momentum"},
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
||||
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"add","type":"Bias"},
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"},
|
||||
{"op":"connect","from":"slow.value","to":"sub.rhs"},
|
||||
{"op":"connect","from":"sub.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||||
]"#;
|
||||
|
||||
/// Every op below applies cleanly (all eager checks pass), but `sub.rhs` is
|
||||
/// never wired — a 0-cover input slot only the holistic finalize gate can
|
||||
/// reject. The smallest document that is op-valid yet whole-document-invalid.
|
||||
@@ -170,8 +207,9 @@ fn graph_introspect_vocabulary_lists_exactly_the_closed_roster_count() {
|
||||
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
33,
|
||||
"the std-only (no project) vocabulary has exactly the roster's 33 entries: {stdout}"
|
||||
36,
|
||||
"the std-only (no project) vocabulary has exactly the roster's 36 entries (#271: \
|
||||
Session/LinComb/CostSum moved in): {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,6 +221,78 @@ fn graph_introspect_node_answers_ports_without_a_build() {
|
||||
assert!(stdout.contains("value"), "lists the output field: {stdout}");
|
||||
}
|
||||
|
||||
/// #271 acceptance criterion 1: an op-script reaching BOTH new arg-bearing
|
||||
/// roster entries (a `Session` for any IANA timezone/open, a `LinComb` of any
|
||||
/// arity — no Rust authored) builds via `aura graph build`; the emitted
|
||||
/// blueprint is `"version": 2` (the args-bearing tier) and carries the
|
||||
/// accepted timezone verbatim.
|
||||
///
|
||||
/// Deviation from the spec's literal worked example
|
||||
/// (`docs/specs/construction-args.md` §"The user-facing program"): that JSON
|
||||
/// wires `ny.bars_since_open` (`Session`'s I64 output) directly into
|
||||
/// `mix.term[0]` (`LinComb`'s F64 input) — a genuine kind mismatch the
|
||||
/// engine's eager `connect` gate refuses (I64 != F64), not a property of
|
||||
/// this feature. This fixture keeps every construction-args property the
|
||||
/// criterion asks for (Session + args, LinComb + args, version 2, tz
|
||||
/// carried) but feeds `LinComb` from two same-kind SMAs and exposes
|
||||
/// `ny.bars_since_open` at the boundary directly (`expose` has no kind
|
||||
/// constraint) instead of wiring it into the mismatched slot.
|
||||
#[test]
|
||||
fn graph_build_accepts_the_session_args_example() {
|
||||
let doc = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"Session","name":"ny",
|
||||
"args":{"tz":"America/New_York","open":"09:30"},
|
||||
"bind":{"period_minutes":{"I64":15}}},
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
||||
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
|
||||
{"op":"add","type":"LinComb","name":"mix","args":{"arity":"2"},
|
||||
"bind":{"weights[0]":{"F64":1.0},"weights[1]":{"F64":0.25}}},
|
||||
{"op":"add","type":"Bias"},
|
||||
{"op":"feed","role":"price","into":["ny.trigger","fast.series","slow.series"]},
|
||||
{"op":"connect","from":"fast.value","to":"mix.term[0]"},
|
||||
{"op":"connect","from":"slow.value","to":"mix.term[1]"},
|
||||
{"op":"connect","from":"mix.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"},
|
||||
{"op":"expose","from":"ny.bars_since_open","as":"session_bar"},
|
||||
{"op":"doc","text":"NY-session-gated SMA blend"}
|
||||
]"#;
|
||||
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
|
||||
assert!(ok, "the worked example builds: {stderr}");
|
||||
assert!(stdout.contains(r#""format_version":2"#), "an args-bearing document is version 2: {stdout}");
|
||||
assert!(stdout.contains("America/New_York"), "carries the accepted tz verbatim: {stdout}");
|
||||
}
|
||||
|
||||
/// #271: a malformed construction-arg value (a bad IANA tz string) refuses at
|
||||
/// the `add` op with exit 1, naming the offending arg (not a Debug leak).
|
||||
#[test]
|
||||
fn graph_build_bad_tz_is_exit_1_naming_the_arg() {
|
||||
let doc = r#"[
|
||||
{"op":"add","type":"Session","name":"ny",
|
||||
"args":{"tz":"not_a_zone","open":"09:30"}}
|
||||
]"#;
|
||||
let (stderr, code) = run_code(&["graph", "build"], doc);
|
||||
assert_eq!(code, Some(1), "a construction-arg fault is exit 1: {stderr}");
|
||||
assert!(stderr.contains("tz"), "names the offending arg: {stderr}");
|
||||
assert!(!stderr.contains("{"), "no Debug-struct leak: {stderr}");
|
||||
}
|
||||
|
||||
/// #271: `graph introspect --node Session` self-describes its declared
|
||||
/// construction args (name, kind, hint) plus the pending note — the
|
||||
/// discovery surface an author reads before writing the `args` object.
|
||||
#[test]
|
||||
fn graph_introspect_node_session_lists_arg_rows() {
|
||||
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--node", "Session"], "");
|
||||
assert!(ok, "exit success");
|
||||
assert!(stdout.contains("tz"), "lists the tz arg: {stdout}");
|
||||
assert!(stdout.contains("open"), "lists the open arg: {stdout}");
|
||||
assert!(stdout.contains("IANA timezone"), "shows the tz hint: {stdout}");
|
||||
assert!(
|
||||
stdout.contains("ports and params form at construction"),
|
||||
"shows the pending note: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_introspect_unwired_reports_open_slots() {
|
||||
let doc = r#"[
|
||||
@@ -587,16 +697,44 @@ fn register_refuses_undescribed_composites_end_to_end() {
|
||||
|
||||
/// Property (#196, the campaign-axis namespace): `--params <FILE>` prints the
|
||||
/// RAW composite param space — one `{name}:{kind:?}` line per open param, in
|
||||
/// lowering order, WITHOUT the harness-wrap prefix `aura sweep --list-axes`
|
||||
/// shows. The open fixture leaves exactly the two SMA lengths unbound
|
||||
/// (`bias.scale` is bound in the document).
|
||||
/// lowering order (#328: the one raw axis namespace — `aura sweep
|
||||
/// --list-axes` prints the identical lines for the same blueprint, see
|
||||
/// `graph_params_and_sweep_list_axes_are_line_identical` below). The open
|
||||
/// fixture leaves exactly the two SMA lengths unbound (`bias.scale` is bound
|
||||
/// in the document).
|
||||
#[test]
|
||||
fn graph_params_lists_raw_axis_namespace() {
|
||||
let dir = temp_cwd("params");
|
||||
let (stdout, stderr, code) =
|
||||
run_in(&dir, &["graph", "introspect", "--params", &fixture("r_sma_open.json")]);
|
||||
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
|
||||
assert_eq!(stdout, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order");
|
||||
assert_eq!(
|
||||
stdout, "fast.length:I64\nslow.length:I64\nbias.scale:F64 default=0.5\n",
|
||||
"the raw open params, then the raw bound param with its default"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#328, cycle-close tidy fix): `graph introspect --params` and
|
||||
/// `aura sweep --list-axes` are two independently-dispatched discovery
|
||||
/// surfaces over the SAME raw axis namespace — the open pass shares one
|
||||
/// wrap-strip convention, the bound pass shares one `bound_param_space()` +
|
||||
/// `render_value` lexicon (`list_blueprint_axes`, main.rs; `params_lines`,
|
||||
/// this crate). Pinned here as a same-fixture LINE EQUALITY (not just two
|
||||
/// independently-asserted shapes) so the duplicated formatting cannot
|
||||
/// silently drift apart across an edit to either surface.
|
||||
#[test]
|
||||
fn graph_params_and_sweep_list_axes_are_line_identical() {
|
||||
let dir = temp_cwd("params-vs-list-axes");
|
||||
let bp = fixture("r_sma_open.json");
|
||||
let (params_out, params_err, params_code) =
|
||||
run_in(&dir, &["graph", "introspect", "--params", &bp]);
|
||||
assert_eq!(params_code, Some(0), "graph introspect --params: {params_out} {params_err}");
|
||||
let (axes_out, axes_err, axes_code) = run_in(&dir, &["sweep", &bp, "--list-axes"]);
|
||||
assert_eq!(axes_code, Some(0), "sweep --list-axes: {axes_out} {axes_err}");
|
||||
assert_eq!(
|
||||
params_out, axes_out,
|
||||
"the two discovery surfaces must print byte-identical lines for the same blueprint"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#196, file-mode --content-id): a blueprint FILE's printed content
|
||||
@@ -646,7 +784,10 @@ fn graph_params_by_content_id_matches_params_by_file() {
|
||||
run_in(&dir, &["graph", "introspect", "--params", &bp]);
|
||||
assert_eq!(by_file_code, Some(0), "stdout: {by_file_out} stderr: {by_file_err}");
|
||||
assert_eq!(by_id_out, by_file_out, "by-id and by-file agree on the raw axis namespace");
|
||||
assert_eq!(by_id_out, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order");
|
||||
assert_eq!(
|
||||
by_id_out, "fast.length:I64\nslow.length:I64\nbias.scale:F64 default=0.5\n",
|
||||
"the raw open params, then the raw bound param with its default"
|
||||
);
|
||||
}
|
||||
|
||||
/// #194 (the prefix trap): a `--params <ID>` target may carry the display
|
||||
@@ -663,7 +804,10 @@ fn graph_params_tolerates_content_prefix_on_target() {
|
||||
let (pfx_out, pfx_err, pfx_code) =
|
||||
run_in(&dir, &["graph", "introspect", "--params", &format!("content:{id}")]);
|
||||
assert_eq!(pfx_code, Some(0), "content:-prefixed --params must resolve: stdout {pfx_out} stderr {pfx_err}");
|
||||
assert_eq!(pfx_out, "fast.length:I64\nslow.length:I64\n", "same raw axis namespace as the bare id");
|
||||
assert_eq!(
|
||||
pfx_out, "fast.length:I64\nslow.length:I64\nbias.scale:F64 default=0.5\n",
|
||||
"same raw axis namespace as the bare id"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#196, negative): `--params <ID>` with a well-shaped 64-hex id
|
||||
@@ -822,6 +966,120 @@ fn graph_register_refuses_an_op_script_doc_that_restates_the_composite_name() {
|
||||
assert!(err.contains("C29"), "stderr cites the contract: {err}");
|
||||
}
|
||||
|
||||
/// (#331 spec test 7) The C29 restates-name gate checks against the
|
||||
/// *authored* name, not a hardcoded default: a `doc` op restating the name
|
||||
/// set by a `name` op ("ny_momentum") refuses at register exactly like the
|
||||
/// default-name case above (`graph_register_refuses_an_op_script_doc_that_
|
||||
/// restates_the_composite_name`, which must stay green byte-identical).
|
||||
#[test]
|
||||
fn graph_register_refuses_a_doc_that_restates_an_authored_name() {
|
||||
let dir = temp_cwd("register-op-script-restated-authored-name-doc");
|
||||
let restated_script = dir.join("restated-authored-name-doc-script.json");
|
||||
std::fs::write(&restated_script, SIGNAL_DOC_NAMED_NAME_RESTATED)
|
||||
.expect("write op-script fixture");
|
||||
let (out, err, code) = run_in(&dir, &["graph", "register", restated_script.to_str().unwrap()]);
|
||||
assert_eq!(code, Some(1), "authored-name-restating doc refuses: {out} {err}");
|
||||
assert!(err.contains("merely restates"), "stderr names the rule: {err}");
|
||||
assert!(err.contains("C29"), "stderr cites the contract: {err}");
|
||||
}
|
||||
|
||||
/// (#331 spec test 6) `graph build` on an ops doc with a `name` op emits
|
||||
/// blueprint JSON carrying the authored name, not the CLI's own seeded
|
||||
/// default (`"graph"`, `replay("graph", ...)` in `composite_from_str`).
|
||||
#[test]
|
||||
fn graph_build_emits_the_authored_name_from_a_name_op() {
|
||||
let (stdout, stderr, ok) = run(&["graph", "build"], SIGNAL_DOC_NAMED);
|
||||
assert!(ok, "build succeeds: {stderr}");
|
||||
assert!(stdout.contains("\"name\":\"ny_momentum\""), "authored name carries into the blueprint: {stdout}");
|
||||
assert!(!stdout.contains("\"name\":\"graph\""), "the default seed name must not leak in: {stdout}");
|
||||
}
|
||||
|
||||
/// (#331 spec test 8) Refusal prose for a duplicate `name` op and a
|
||||
/// shape-violating name is op-indexed (`op N (name): ...`) — the same
|
||||
/// by-identifier labeling every other op fault gets.
|
||||
#[test]
|
||||
fn graph_build_name_op_refusals_are_op_indexed() {
|
||||
let twice_named = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
||||
{"op":"name","name":"a"},
|
||||
{"op":"name","name":"b"},
|
||||
{"op":"feed","role":"price","into":["fast.series"]},
|
||||
{"op":"expose","from":"fast.value","as":"out"}
|
||||
]"#;
|
||||
let (_out, stderr, ok) = run(&["graph", "build"], twice_named);
|
||||
assert!(!ok, "a second name op refuses");
|
||||
assert!(stderr.contains("op 3 (name):"), "op-indexed at the second name op: {stderr}");
|
||||
|
||||
let slash_named = r#"[{"op":"name","name":"a/b"}]"#;
|
||||
let (_out2, stderr2, ok2) = run(&["graph", "build"], slash_named);
|
||||
assert!(!ok2, "a shape-violating name refuses");
|
||||
assert!(stderr2.contains("op 0 (name):"), "op-indexed at the offending op: {stderr2}");
|
||||
}
|
||||
|
||||
/// (#331 spec test 9, at the binary seam — mirrors
|
||||
/// `graph_introspect_identity_id_bridges_renamed_op_scripts`'s pattern for
|
||||
/// the debug-name field): a named and an unnamed build of the SAME
|
||||
/// structural script share the identity id (the name is stripped as a
|
||||
/// debug symbol before identity hashing, C23) and differ in the content id
|
||||
/// (the name is part of the serialized bytes).
|
||||
#[test]
|
||||
fn graph_named_and_unnamed_twins_share_identity_id_and_differ_content_id() {
|
||||
let (ia, _e, ok) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC);
|
||||
assert!(ok, "exit success");
|
||||
let (ib, _e2, ok2) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC_NAMED);
|
||||
assert!(ok2, "exit success");
|
||||
assert_eq!(ia.trim(), ib.trim(), "the name is stripped before identity hashing (C23)");
|
||||
|
||||
let (ca, _e3, ok3) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
|
||||
let (cb, _e4, ok4) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC_NAMED);
|
||||
assert!(ok3 && ok4, "exit success");
|
||||
assert_ne!(ca.trim(), cb.trim(), "the name is part of the serialized bytes -> different content id");
|
||||
}
|
||||
|
||||
/// (#331 spec test 10, the skeptic's bypass route): the op intake
|
||||
/// (`GraphSession::set_name`) is not the name's only data-borne birth
|
||||
/// route — a hand-edited blueprint ENVELOPE (not an op-script) can carry a
|
||||
/// shape-violating root name directly. `aura graph register` on such a file
|
||||
/// is refused naming the offending name (exit 1, the refusal convention
|
||||
/// every sibling test in this module asserts), not silently stored under a
|
||||
/// name that would write `traces/../x/` at run time. Shipped envelope
|
||||
/// fixtures with plain root names (`doc_blueprint.json`'s "sig",
|
||||
/// `nested_doc_blueprint.json`'s "root", every op-script's default "graph")
|
||||
/// stay accepted — the surrounding suite is that regression gate.
|
||||
///
|
||||
/// Spec test 10 also names `build` as a gated envelope-intake route
|
||||
/// alongside `register`. The `graph build` SUBCOMMAND takes only a stdin
|
||||
/// op-script (`Vec<OpDoc>`, never a JSON object) — it cannot ingest this
|
||||
/// envelope fixture at all (a shape mismatch fires before any name is ever
|
||||
/// read). The file-consuming envelope-intake sibling `build` route is
|
||||
/// `graph introspect --content-id <FILE>` (its own doc comment: "one
|
||||
/// build, then each requested id…") — the other call site this iteration
|
||||
/// moved onto the same `composite_from_authored_text` choke point as
|
||||
/// `register`, so it is the leg exercised here, sharing the identical
|
||||
/// `name_gate_fault_prose` text.
|
||||
#[test]
|
||||
fn graph_build_and_register_refuse_a_hand_crafted_envelope_with_a_bad_root_name() {
|
||||
let dir = temp_cwd("register-bad-root-name");
|
||||
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED);
|
||||
assert!(built, "graph build produces the envelope");
|
||||
let bad = envelope_bytes.replacen("\"name\":\"graph\"", "\"name\":\"../x\"", 1);
|
||||
assert!(bad.contains("\"name\":\"../x\""), "the root-name replace landed: {bad}");
|
||||
let file = dir.join("bad-root-name.json");
|
||||
std::fs::write(&file, &bad).expect("write envelope fixture");
|
||||
|
||||
let (out, err, code) = run_in(&dir, &["graph", "register", file.to_str().unwrap()]);
|
||||
assert_eq!(code, Some(1), "a shape-violating root name refuses (not a panic): {out} {err}");
|
||||
assert!(err.contains("../x"), "names the offending root name: {err}");
|
||||
|
||||
// The other file-consuming envelope intake (same fixture, same choke
|
||||
// point) refuses identically.
|
||||
let (out2, err2, code2) =
|
||||
run_in(&dir, &["graph", "introspect", "--content-id", file.to_str().unwrap()]);
|
||||
assert_eq!(code2, Some(1), "the sibling envelope intake refuses too: {out2} {err2}");
|
||||
assert_eq!(err2, err, "both file-consuming envelope intakes share the identical fault prose");
|
||||
}
|
||||
|
||||
/// Property (#202, the same on-ramp seam at the sibling verb): `aura graph
|
||||
/// introspect --params` accepts an op-script exactly as it accepts a blueprint
|
||||
/// envelope — the raw axis namespace it prints for the op-script equals the one it
|
||||
@@ -847,6 +1105,35 @@ fn graph_params_accepts_an_op_script_matching_its_envelope() {
|
||||
assert_eq!(op_out, env_out, "op-script and envelope agree on the raw axis namespace");
|
||||
}
|
||||
|
||||
/// (#331 review finding, the C29 exemption's ratifying pin): the root-name
|
||||
/// shape gate lives at the CLI's file-consuming envelope intakes (`register`,
|
||||
/// `introspect --content-id <FILE>`) — never at STORE read-back. A blueprint
|
||||
/// with a shape-violating root name registered DIRECTLY through the
|
||||
/// `aura-registry` store API (bypassing the CLI's gate entirely, the way a
|
||||
/// pre-#331 artifact would already sit in a project's store) must still be
|
||||
/// introspectable: `introspect --params <ID>` reads the store, not a file, so
|
||||
/// it must never re-punish an artifact the store already accepted — C29,
|
||||
/// "registered artifacts are never retroactively invalidated".
|
||||
#[test]
|
||||
fn graph_introspect_params_accepts_a_pre_331_store_artifact_with_a_bad_root_name() {
|
||||
let dir = temp_cwd("introspect-store-readback-bad-root-name");
|
||||
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED);
|
||||
assert!(built, "graph build produces the envelope");
|
||||
let bad = envelope_bytes.replacen("\"name\":\"graph\"", "\"name\":\"../x\"", 1);
|
||||
assert!(bad.contains("\"name\":\"../x\""), "the root-name replace landed: {bad}");
|
||||
|
||||
// Bypass the CLI's gate entirely: the registry's own store API is the
|
||||
// simulated pre-#331 write path.
|
||||
let id = aura_research::content_id_of(&bad);
|
||||
let registry = aura_registry::Registry::open(dir.join("runs/runs.jsonl"));
|
||||
registry
|
||||
.put_blueprint(&id, &bad)
|
||||
.expect("the registry's own store API has no root-name gate to bypass");
|
||||
|
||||
let (out, err, code) = run_in(&dir, &["graph", "introspect", "--params", &id]);
|
||||
assert_eq!(code, Some(0), "store read-back stays ungated (C29): {out} {err}");
|
||||
}
|
||||
|
||||
/// Property (#226, the render positional closes the #202 on-ramp family):
|
||||
/// `aura graph <op-script.json>` (the render positional) accepts an op-script
|
||||
/// document (a JSON array) exactly as it accepts a #155 blueprint envelope (a
|
||||
@@ -892,7 +1179,10 @@ fn shipped_r_sma_example_is_genuinely_closed() {
|
||||
let (stdout, stderr, code) =
|
||||
run_in(&dir, &["graph", "introspect", "--params", &example("r_sma.json")]);
|
||||
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
|
||||
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
|
||||
assert_eq!(
|
||||
stdout, "fast.length:I64 default=2\nslow.length:I64 default=4\nbias.scale:F64 default=0.5\n",
|
||||
"zero OPEN params — every knob is now printed as a raw bound default instead"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#159): the open fixture (`tests/fixtures/r_sma_open.json`) is a
|
||||
@@ -910,8 +1200,8 @@ fn open_r_sma_fixture_lists_its_axis_namespace() {
|
||||
run_in(&dir, &["graph", "introspect", "--params", &fixture("r_sma_open.json")]);
|
||||
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
|
||||
assert_eq!(
|
||||
stdout, "fast.length:I64\nslow.length:I64\n",
|
||||
"the raw open params, in order, from the public gallery copy"
|
||||
stdout, "fast.length:I64\nslow.length:I64\nbias.scale:F64 default=0.5\n",
|
||||
"the raw open params, then the raw bound param, from the public gallery copy"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -948,12 +1238,17 @@ fn shipped_r_breakout_example_is_genuinely_closed() {
|
||||
let (stdout, stderr, code) =
|
||||
run_in(&dir, &["graph", "introspect", "--params", &example("r_breakout.json")]);
|
||||
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
|
||||
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
|
||||
assert_eq!(
|
||||
stdout,
|
||||
"delay.lag:I64 default=1\nchannel_hi.length:I64 default=3\nchannel_lo.length:I64 default=3\n",
|
||||
"zero OPEN params — every knob is now printed as a raw bound default instead"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#159 cut 2): the open fixture (`tests/fixtures/r_breakout_open.json`)
|
||||
/// lists its ONE ganged channel axis (#61: the two rolling windows are structurally
|
||||
/// one knob).
|
||||
/// one knob) followed by the still-bound `delay.lag` default (#328: bound params
|
||||
/// are listed too, line-identical to `--list-axes`).
|
||||
#[test]
|
||||
fn open_r_breakout_fixture_lists_its_axis_namespace() {
|
||||
let dir = temp_cwd("r-breakout-example-open-params");
|
||||
@@ -961,8 +1256,9 @@ fn open_r_breakout_fixture_lists_its_axis_namespace() {
|
||||
run_in(&dir, &["graph", "introspect", "--params", &fixture("r_breakout_open.json")]);
|
||||
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
|
||||
assert_eq!(
|
||||
stdout, "channel_length:I64\n",
|
||||
"the ganged channel knob — one public axis for the two rolling windows (#61)"
|
||||
stdout, "channel_length:I64\ndelay.lag:I64 default=1\n",
|
||||
"the ganged channel knob — one public axis for the two rolling windows (#61) — \
|
||||
then the still-bound delay.lag default"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -991,7 +1287,7 @@ fn open_r_breakout_fixture_gang_axis_matches_the_closed_example() {
|
||||
let sweep_dir = temp_cwd("r-breakout-gang-axis-sweep");
|
||||
let (sweep_stdout, sweep_stderr, sweep_code) = run_in(
|
||||
&sweep_dir,
|
||||
&["sweep", &fixture("r_breakout_open.json"), "--axis", "r_breakout_signal.channel_length=3"],
|
||||
&["sweep", &fixture("r_breakout_open.json"), "--axis", "channel_length=3"],
|
||||
);
|
||||
assert_eq!(sweep_code, Some(0), "sweep stderr: {sweep_stderr}");
|
||||
let lines: Vec<&str> = sweep_stdout.lines().collect();
|
||||
@@ -1013,7 +1309,11 @@ fn shipped_r_meanrev_example_is_genuinely_closed() {
|
||||
let (stdout, stderr, code) =
|
||||
run_in(&dir, &["graph", "introspect", "--params", &example("r_meanrev.json")]);
|
||||
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
|
||||
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
|
||||
assert_eq!(
|
||||
stdout,
|
||||
"mean_window.length:I64 default=3\nvar_window.length:I64 default=3\nband.factor:F64 default=2\n",
|
||||
"zero OPEN params — every knob is now printed as a raw bound default instead"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#159 cut 3): the open fixture (`tests/fixtures/r_meanrev_open.json`)
|
||||
@@ -1051,8 +1351,8 @@ fn open_r_meanrev_fixture_gang_plus_plain_axis_matches_the_closed_example() {
|
||||
&sweep_dir,
|
||||
&[
|
||||
"sweep", &fixture("r_meanrev_open.json"),
|
||||
"--axis", "r_meanrev_signal.window=3",
|
||||
"--axis", "r_meanrev_signal.band.factor=2.0",
|
||||
"--axis", "window=3",
|
||||
"--axis", "band.factor=2.0",
|
||||
],
|
||||
);
|
||||
assert_eq!(sweep_code, Some(0), "sweep stderr: {sweep_stderr}");
|
||||
@@ -1817,8 +2117,8 @@ fn graph_build_use_end_to_end_axes() {
|
||||
run_in(&dir, &["sweep", consumer_bp.to_str().unwrap(), "--list-axes"]);
|
||||
assert_eq!(axes_code, Some(0), "list-axes: {axes_out} {axes_err}");
|
||||
assert_eq!(
|
||||
axes_out, "graph.trend.sma.length:I64\n",
|
||||
"the spliced instance's open param surfaces path-qualified, bare (unbound) form"
|
||||
axes_out, "trend.sma.length:I64\n",
|
||||
"the spliced instance's open param surfaces path-qualified, bare (unbound) RAW form (#328)"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,8 @@ fn sugar_verbs_name_their_document_shape() {
|
||||
|
||||
/// #323: `graph build --help` carries the op-list reference — the op kinds
|
||||
/// and fields are learnable from the binary, not only from serde refusals.
|
||||
/// #317: the `use` op joins the roster (nine -> ten).
|
||||
/// #317: the `use` op joins the roster (nine -> ten). #331: the `name` op
|
||||
/// joins the roster (ten -> eleven).
|
||||
#[test]
|
||||
fn graph_build_help_carries_the_op_reference() {
|
||||
let (out, err, ok) = run(&["graph", "build", "--help"]);
|
||||
@@ -74,6 +75,7 @@ fn graph_build_help_carries_the_op_reference() {
|
||||
r#"{"op":"gang""#,
|
||||
r#"{"op":"doc""#,
|
||||
r#"{"op":"use""#,
|
||||
r#"{"op":"name""#,
|
||||
] {
|
||||
assert!(out.contains(op), "op reference carries {op}: {out}");
|
||||
}
|
||||
|
||||
@@ -117,9 +117,9 @@ fn project_registry_anchors_at_discovered_root_not_invocation_cwd() {
|
||||
"sweep",
|
||||
&closed_bp,
|
||||
"--axis",
|
||||
"sma_signal.fast.length=2,4",
|
||||
"fast.length=2,4",
|
||||
"--axis",
|
||||
"sma_signal.slow.length=8,16",
|
||||
"slow.length=8,16",
|
||||
"--name",
|
||||
"proj-anchor",
|
||||
])
|
||||
|
||||
@@ -135,7 +135,7 @@ fn data_only_project_sweeps_without_any_build() {
|
||||
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
|
||||
let proj = base.join("scratch");
|
||||
let out = aura(
|
||||
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4"],
|
||||
&["sweep", "blueprints/signal.json", "--axis", "fast.length=2,4"],
|
||||
&proj,
|
||||
);
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
@@ -157,7 +157,7 @@ fn data_only_project_sweep_over_the_starter_opens_one_member_per_axis_value() {
|
||||
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
|
||||
let proj = base.join("scratch");
|
||||
let out = aura(
|
||||
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4,8"],
|
||||
&["sweep", "blueprints/signal.json", "--axis", "fast.length=2,4,8"],
|
||||
&proj,
|
||||
);
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
@@ -160,8 +160,8 @@ fn project_node_open_param_sweeps_and_reproduces() {
|
||||
// the fixture's three BOUND params as `default=`-lines (#246: every bound
|
||||
// param is an equally re-openable `--axis`, listed regardless of how many
|
||||
// knobs happen to be open alongside it). NOT gated: enumerating axes needs
|
||||
// no data, so the discoverability half runs on every host. The wrapped
|
||||
// name is `<blueprint>.<node>.<param>`.
|
||||
// no data, so the discoverability half runs on every host. The name is RAW
|
||||
// `<node>.<param>` (#328: the blueprint name stays out of axis paths).
|
||||
let axes = aura_in(dir, &["sweep", "blueprints/scaled_open.json", "--list-axes"]);
|
||||
assert!(
|
||||
axes.status.success(),
|
||||
@@ -170,10 +170,10 @@ fn project_node_open_param_sweeps_and_reproduces() {
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&axes.stdout),
|
||||
"scaled_signal.gain.factor:F64\n\
|
||||
scaled_signal.fast.length:I64 default=2\n\
|
||||
scaled_signal.slow.length:I64 default=4\n\
|
||||
scaled_signal.bias.scale:F64 default=0.5\n",
|
||||
"gain.factor:F64\n\
|
||||
fast.length:I64 default=2\n\
|
||||
slow.length:I64 default=4\n\
|
||||
bias.scale:F64 default=0.5\n",
|
||||
"the project node's own OPEN param is the one open axis, alongside the \
|
||||
fixture's bound defaults; stderr: {}",
|
||||
String::from_utf8_lossy(&axes.stderr)
|
||||
@@ -198,7 +198,7 @@ fn project_node_open_param_sweeps_and_reproduces() {
|
||||
"--to",
|
||||
GER40_TO_MS,
|
||||
"--axis",
|
||||
"scaled_signal.gain.factor=0.5,1.0",
|
||||
"gain.factor=0.5,1.0",
|
||||
"--name",
|
||||
"knob",
|
||||
],
|
||||
@@ -212,9 +212,11 @@ fn project_node_open_param_sweeps_and_reproduces() {
|
||||
let lines: Vec<&str> = stdout.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "one member line per factor point: {stdout}");
|
||||
|
||||
// Each member's manifest carries the swept PROJECT-node param binding under
|
||||
// its wrapped name — the load-bearing proof that it is MY node's knob that
|
||||
// varied across the family, not some incidental std axis.
|
||||
// Each member's manifest carries the swept PROJECT-node param binding
|
||||
// under its RAW name (this cycle's tidy fix, #328 batch 2: the
|
||||
// real/campaign route's own `manifest.params` mints raw too now) — the
|
||||
// load-bearing proof that it is MY node's knob that varied across the
|
||||
// family, not some incidental std axis.
|
||||
for (line, factor) in lines.iter().zip([0.5_f64, 1.0]) {
|
||||
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
|
||||
assert_eq!(
|
||||
@@ -227,7 +229,7 @@ fn project_node_open_param_sweeps_and_reproduces() {
|
||||
.expect("manifest.params is an array");
|
||||
let bound = params
|
||||
.iter()
|
||||
.find(|p| p[0].as_str() == Some("scaled_signal.gain.factor"))
|
||||
.find(|p| p[0].as_str() == Some("gain.factor"))
|
||||
.and_then(|p| p[1]["F64"].as_f64());
|
||||
assert_eq!(
|
||||
bound,
|
||||
|
||||
@@ -329,9 +329,9 @@ fn campaign_validate_in_project_reports_referential_tier_end_to_end() {
|
||||
"sweep",
|
||||
&closed_bp,
|
||||
"--axis",
|
||||
"sma_signal.fast.length=2,4",
|
||||
"fast.length=2,4",
|
||||
"--axis",
|
||||
"sma_signal.slow.length=8,16",
|
||||
"slow.length=8,16",
|
||||
"--name",
|
||||
"campaign-ref-seed",
|
||||
],
|
||||
@@ -377,10 +377,11 @@ fn campaign_validate_in_project_reports_referential_tier_end_to_end() {
|
||||
.trim_start_matches("content:")
|
||||
.to_string();
|
||||
|
||||
// "fast.length": the axis name is the RAW composite's `param_space` name.
|
||||
// "fast.length": the axis name is the RAW composite's `param_space` name —
|
||||
// the SAME namespace `aura sweep --axis` itself binds against now (#328).
|
||||
// `validate_campaign_refs` loads the stored blueprint bare, unlike the
|
||||
// sweep's `wrap_r`-wrapped axis probe, so it does NOT carry the
|
||||
// "sma_signal." prefix `aura sweep --axis` binds against.
|
||||
// sweep's `wrap_r`-wrapped axis probe, so it never carries a wrap prefix
|
||||
// to begin with.
|
||||
let campaign = format!(
|
||||
r#"{{
|
||||
"format_version": 1,
|
||||
@@ -448,9 +449,9 @@ fn campaign_validate_resolves_identity_ref_via_index_first_lookup_then_index_hit
|
||||
"sweep",
|
||||
&closed_bp,
|
||||
"--axis",
|
||||
"sma_signal.fast.length=2,4",
|
||||
"fast.length=2,4",
|
||||
"--axis",
|
||||
"sma_signal.slow.length=8,16",
|
||||
"slow.length=8,16",
|
||||
"--name",
|
||||
"campaign-identity-seed",
|
||||
],
|
||||
@@ -1288,9 +1289,9 @@ fn seed_blueprint(dir: &Path, name: &str) -> String {
|
||||
"sweep",
|
||||
&closed_bp,
|
||||
"--axis",
|
||||
"sma_signal.fast.length=2,4",
|
||||
"fast.length=2,4",
|
||||
"--axis",
|
||||
"sma_signal.slow.length=8,16",
|
||||
"slow.length=8,16",
|
||||
"--name",
|
||||
name,
|
||||
],
|
||||
|
||||
@@ -81,3 +81,60 @@ fn measurement_run_honours_the_tap_selector() {
|
||||
let expected = (1.0097_f64 + 1.0092) / 2.0;
|
||||
assert!((got - expected).abs() < 1e-9, "last row: got {got}, expected {expected}");
|
||||
}
|
||||
|
||||
/// #331 delta re-review, the third authored-file intake route: `dispatch_run`
|
||||
/// loads a FRESH FILE via `blueprint_from_json` and, on this measurement arm,
|
||||
/// feeds `signal.name()` straight into `bind_tap_plan` -> `TraceStore::begin_run`
|
||||
/// (`trace_store.rs` joins the name unsanitized) — a hand-crafted envelope with
|
||||
/// `"name":"../x"` used to reach `--tap fast_tap=record` and escape
|
||||
/// `runs/traces/<name>/` for `runs/x/` (one level up, since `traces/../x`
|
||||
/// normalizes there) instead of refusing at the same root-name choke point
|
||||
/// `register`/`introspect --content-id <FILE>` already gate through. This pins
|
||||
/// the closed route: exit nonzero (dispatch_run's own bad-input-file
|
||||
/// convention, exit 2), the shared `name_gate_fault_prose` wording, and — the
|
||||
/// property that actually matters — the escaped directory is never created.
|
||||
#[test]
|
||||
fn run_refuses_a_hand_crafted_envelope_with_a_bad_root_name_before_any_trace_write() {
|
||||
let cwd = temp_cwd("bad-root-name");
|
||||
let mut v: serde_json::Value =
|
||||
serde_json::from_str(&measurement_blueprint_json()).expect("parse measurement blueprint");
|
||||
v["blueprint"]["name"] = serde_json::json!("../x");
|
||||
let bad = serde_json::to_string(&v).expect("re-serialize with a shape-violating root name");
|
||||
let bp_path = cwd.join("bad-root-name.json");
|
||||
std::fs::write(&bp_path, &bad).expect("write bad-root-name blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=record"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(2),
|
||||
"a shape-violating root name refuses at dispatch_run's own bad-file exit code: {:?} stderr={}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains(r#"blueprint name "../x" is invalid"#),
|
||||
"the shared name_gate_fault_prose wording names the offending root name: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
out.stdout.is_empty(),
|
||||
"a refused run must not print a report: {:?}",
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
);
|
||||
|
||||
// The property that actually matters: the escaped write target (one level
|
||||
// up from `traces/`, since `trace_store.rs` joins the name unsanitized)
|
||||
// was never created — the gate fires before `begin_run` ever touches disk.
|
||||
assert!(
|
||||
!cwd.join("runs/x").exists(),
|
||||
"the escaped trace directory must never be created"
|
||||
);
|
||||
assert!(
|
||||
!cwd.join("runs/traces").exists(),
|
||||
"no trace directory of any shape is written on a refused run"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,8 +52,8 @@ fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite {
|
||||
let sqrt = g.add(Sqrt::builder()); // → σ (price units)
|
||||
// k·σ: weights[0] bound (Some) or open and named `stop_k` (None).
|
||||
let scale = g.add(match knobs {
|
||||
Some((_, k)) => LinComb::builder(1).bind("weights[0]", Scalar::f64(k)),
|
||||
None => LinComb::builder(1).named("stop_k"),
|
||||
Some((_, k)) => LinComb::configured(1).bind("weights[0]", Scalar::f64(k)),
|
||||
None => LinComb::configured(1).named("stop_k"),
|
||||
});
|
||||
g.feed(price, [delay.input("series"), sub.input("lhs")]);
|
||||
g.connect(delay.output("value"), sub.input("rhs"));
|
||||
@@ -174,7 +174,7 @@ pub fn cost_graph(cost_nodes: Vec<PrimitiveBuilder>) -> Composite {
|
||||
let entry = g.input_role("entry_price");
|
||||
let stop = g.input_role("stop_price");
|
||||
|
||||
let agg = g.add(CostSum::builder(n));
|
||||
let agg = g.add(CostSum::configured(n));
|
||||
|
||||
// Per-role geometry fan targets, collected across all nodes, fed once per role.
|
||||
let mut closed_t = Vec::with_capacity(n);
|
||||
|
||||
@@ -7,6 +7,10 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
# ArgValue::Tz carries chrono_tz::Tz (the closed IANA table, exact names only) —
|
||||
# already a workspace dependency of four other crates (see docs/specs/
|
||||
# construction-args.md § Dependency note).
|
||||
chrono-tz = { version = "0.10", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -44,8 +44,9 @@ pub use column::{Column, Window};
|
||||
pub use ctx::Ctx;
|
||||
pub use error::KindMismatch;
|
||||
pub use node::{
|
||||
doc_gate, zip_params, BindOpError, BoundParam, DocGateFault, FieldSpec, Firing, Node,
|
||||
NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
||||
doc_gate, zip_params, ArgKind, ArgOpError, ArgSpec, ArgValue, BindOpError, BoundParam,
|
||||
ConstructionArg, DocGateFault, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
|
||||
PrimitiveBuilder,
|
||||
};
|
||||
pub use scalar::{Scalar, ScalarKind, Timestamp};
|
||||
pub use series_fold::SeriesFold;
|
||||
|
||||
@@ -112,6 +112,7 @@ pub struct PrimitiveBuilder {
|
||||
instance_name: Option<String>,
|
||||
schema: NodeSchema,
|
||||
bound: Vec<BoundParam>,
|
||||
args: ArgsState, // NEW: `Plain` for every existing (non-arg-bearing) node
|
||||
// The build closure's type is exactly the recipe contract (a param slice in, a
|
||||
// boxed node out); a type alias would not clarify it.
|
||||
#[allow(clippy::type_complexity)]
|
||||
@@ -127,7 +128,108 @@ impl PrimitiveBuilder {
|
||||
schema: NodeSchema,
|
||||
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
|
||||
) -> Self {
|
||||
Self { name, instance_name: None, schema, bound: Vec::new(), build: Box::new(build) }
|
||||
Self { name, instance_name: None, schema, bound: Vec::new(), args: ArgsState::Plain, build: Box::new(build) }
|
||||
}
|
||||
|
||||
/// An arg-bearing recipe: introspectable (doc + declared `ArgSpec`s) but
|
||||
/// not yet constructible. `schema` carries only the doc line (empty
|
||||
/// inputs/output/params — the real signature forms inside `make`, once
|
||||
/// `try_args` has parsed values to hand it); `build` on a pending builder
|
||||
/// panics (the `bind` panic-contract twin) — the data path always goes
|
||||
/// through `try_args` first, so no pending builder ever reaches `build`.
|
||||
pub fn pending(
|
||||
name: &'static str,
|
||||
doc: &'static str,
|
||||
specs: &'static [ArgSpec],
|
||||
make: fn(&[(String, ArgValue)]) -> PrimitiveBuilder,
|
||||
) -> Self {
|
||||
Self {
|
||||
name,
|
||||
instance_name: None,
|
||||
schema: NodeSchema { doc, ..Default::default() },
|
||||
bound: Vec::new(),
|
||||
args: ArgsState::Pending { specs, make },
|
||||
build: Box::new(move |_| {
|
||||
panic!(
|
||||
"PrimitiveBuilder::build: `{name}` is an unconfigured arg-bearing type — \
|
||||
call try_args first"
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate raw `(name, value)` pairs against the declared `ArgSpec`s
|
||||
/// (strict form — see `ArgKind::parse`), then run `make`, returning the
|
||||
/// configured builder (`args: Configured{..}`) with this builder's
|
||||
/// `instance_name` carried over. `Plain` + empty raw is `Ok(self)`
|
||||
/// (uniform op application, #157's try_bind precedent); `Plain` +
|
||||
/// non-empty raw is `NotArgBearing`. Validation order (deterministic):
|
||||
/// first unknown arg name, then a duplicate, then the first spec-order
|
||||
/// missing arg, then per-kind parse failures (`BadValue`).
|
||||
pub fn try_args(self, raw: &[(String, String)]) -> Result<Self, ArgOpError> {
|
||||
let (specs, make) = match &self.args {
|
||||
ArgsState::Plain | ArgsState::Configured { .. } => {
|
||||
return if raw.is_empty() { Ok(self) } else { Err(ArgOpError::NotArgBearing) };
|
||||
}
|
||||
ArgsState::Pending { specs, make } => (*specs, *make),
|
||||
};
|
||||
|
||||
for (name, _) in raw {
|
||||
if !specs.iter().any(|s| s.name == name.as_str()) {
|
||||
return Err(ArgOpError::UnknownArg(name.clone()));
|
||||
}
|
||||
}
|
||||
for i in 0..raw.len() {
|
||||
if raw[i + 1..].iter().any(|(n, _)| n == &raw[i].0) {
|
||||
return Err(ArgOpError::DuplicateArg(raw[i].0.clone()));
|
||||
}
|
||||
}
|
||||
for spec in specs {
|
||||
if !raw.iter().any(|(n, _)| n.as_str() == spec.name) {
|
||||
return Err(ArgOpError::MissingArg(spec.name.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut values = Vec::with_capacity(specs.len());
|
||||
let mut accepted = Vec::with_capacity(specs.len());
|
||||
for spec in specs {
|
||||
let raw_val = &raw.iter().find(|(n, _)| n.as_str() == spec.name).expect("presence checked above").1;
|
||||
let value = spec.kind.parse(raw_val).map_err(|()| ArgOpError::BadValue {
|
||||
arg: spec.name.to_string(),
|
||||
kind: spec.kind,
|
||||
got: raw_val.clone(),
|
||||
})?;
|
||||
values.push((spec.name.to_string(), value));
|
||||
accepted.push(ConstructionArg { name: spec.name.to_string(), value: raw_val.clone() });
|
||||
}
|
||||
|
||||
let mut built = make(&values);
|
||||
built.instance_name = self.instance_name;
|
||||
built.args = ArgsState::Configured { values: accepted };
|
||||
Ok(built)
|
||||
}
|
||||
|
||||
/// The declared construction-arg specs (a view into the pending recipe);
|
||||
/// empty unless this builder `is_pending()`.
|
||||
pub fn arg_specs(&self) -> &[ArgSpec] {
|
||||
match &self.args {
|
||||
ArgsState::Pending { specs, .. } => specs,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The accepted, verbatim construction-arg pairs (the serialize surface);
|
||||
/// empty unless this builder was configured via `try_args`.
|
||||
pub fn construction_args(&self) -> &[ConstructionArg] {
|
||||
match &self.args {
|
||||
ArgsState::Configured { values } => values,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// True for an arg-bearing recipe not yet configured via `try_args`.
|
||||
pub fn is_pending(&self) -> bool {
|
||||
matches!(self.args, ArgsState::Pending { .. })
|
||||
}
|
||||
/// Set this node instance's explicit name. Must be non-empty: the name forms
|
||||
/// a knob-address segment (`<composite>.<name>.<param>`, via `node_name()` in
|
||||
@@ -344,6 +446,135 @@ pub enum BindOpError {
|
||||
AlreadyGanged { param: String, gang: String },
|
||||
}
|
||||
|
||||
/// Closed construction-arg kinds (spec §Closedness) — deliberately NOT
|
||||
/// `ScalarKind`: the four scalar kinds are the streamed set (invariant 4); args
|
||||
/// are bootstrap metadata, never streamed, so the two closedness axes stay
|
||||
/// separate types.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ArgKind {
|
||||
/// An IANA timezone name (`chrono_tz::Tz`'s own parser — exact, case-sensitive).
|
||||
Tz,
|
||||
/// A local wall-clock time, strict zero-padded `HH:MM` (00-23 / 00-59).
|
||||
TimeOfDay,
|
||||
/// A plain positive decimal count (>= 1, no leading zeros).
|
||||
Count,
|
||||
}
|
||||
|
||||
impl ArgKind {
|
||||
/// Parse `raw` in this kind's strict canonical form. The accepted form IS
|
||||
/// the canonical form (spec §Closedness) — there is no normalization
|
||||
/// layer, so a near-miss string refuses rather than being silently
|
||||
/// rewritten (content ids stay input-variance-free by refusal).
|
||||
// The spec's pinned shape (`Result<ArgValue, ()>`, mirroring `ArgOpError`'s
|
||||
// caller-side `BadValue` wrapping the plain refusal): the unit error carries
|
||||
// no information of its own — `try_args` is the only caller and always maps
|
||||
// it to `ArgOpError::BadValue`, which is where the real detail lives.
|
||||
#[allow(clippy::result_unit_err)]
|
||||
pub fn parse(&self, raw: &str) -> Result<ArgValue, ()> {
|
||||
match self {
|
||||
ArgKind::Tz => raw.parse::<chrono_tz::Tz>().map(ArgValue::Tz).map_err(|_| ()),
|
||||
ArgKind::TimeOfDay => {
|
||||
let bytes = raw.as_bytes();
|
||||
if bytes.len() != 5 || bytes[2] != b':' {
|
||||
return Err(());
|
||||
}
|
||||
let (h, m) = (&raw[0..2], &raw[3..5]);
|
||||
if !h.bytes().all(|b| b.is_ascii_digit()) || !m.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(());
|
||||
}
|
||||
let hour: u32 = h.parse().map_err(|_| ())?;
|
||||
let minute: u32 = m.parse().map_err(|_| ())?;
|
||||
if hour > 23 || minute > 59 {
|
||||
return Err(());
|
||||
}
|
||||
Ok(ArgValue::TimeOfDay { hour, minute })
|
||||
}
|
||||
ArgKind::Count => {
|
||||
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(());
|
||||
}
|
||||
if raw.len() > 1 && raw.starts_with('0') {
|
||||
return Err(()); // leading zero: not the canonical form
|
||||
}
|
||||
let n: usize = raw.parse().map_err(|_| ())?;
|
||||
if n == 0 {
|
||||
return Err(()); // Count is >= 1
|
||||
}
|
||||
Ok(ArgValue::Count(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The static, per-kind hint line (introspection surface, C29) — a closed
|
||||
/// table keyed only by `ArgKind`, never per-entry freetext.
|
||||
pub fn hint(&self) -> &'static str {
|
||||
match self {
|
||||
ArgKind::Tz => "IANA timezone name, e.g. Europe/Berlin",
|
||||
ArgKind::TimeOfDay => "local wall-clock HH:MM",
|
||||
ArgKind::Count => "positive integer count",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One declared construction arg of an arg-bearing `PrimitiveBuilder` (the
|
||||
/// pending recipe's twin of `ParamSpec`): its render name and closed `ArgKind`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ArgSpec {
|
||||
pub name: &'static str,
|
||||
pub kind: ArgKind,
|
||||
}
|
||||
|
||||
/// A parsed, validated construction-arg value (in-memory only; documents carry
|
||||
/// the canonical string form in [`ConstructionArg`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ArgValue {
|
||||
Tz(chrono_tz::Tz),
|
||||
TimeOfDay { hour: u32, minute: u32 },
|
||||
Count(usize),
|
||||
}
|
||||
|
||||
/// A consumed construction arg — the id-bearing, serialized twin of
|
||||
/// `BoundParam`. `value` is the accepted strict-form string, stored verbatim
|
||||
/// (never re-normalized — see `ArgKind::parse`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ConstructionArg {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// A fallible-`try_args` fault — the construction-arg twin of `BindOpError`,
|
||||
/// for the data-level construction surface.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ArgOpError {
|
||||
/// Args were given, but this type declares none (a `Plain` builder).
|
||||
NotArgBearing,
|
||||
/// A given arg name is not among the declared `ArgSpec`s.
|
||||
UnknownArg(String),
|
||||
/// The same arg name was given more than once.
|
||||
DuplicateArg(String),
|
||||
/// A declared arg has no value in the given set.
|
||||
MissingArg(String),
|
||||
/// A given value failed its declared kind's strict-form parse.
|
||||
BadValue { arg: String, kind: ArgKind, got: String },
|
||||
}
|
||||
|
||||
/// The args-channel state of a [`PrimitiveBuilder`] (private: callers only
|
||||
/// ever observe it through `arg_specs`/`construction_args`/`is_pending`).
|
||||
enum ArgsState {
|
||||
/// No construction args declared (every existing node, unaffected).
|
||||
Plain,
|
||||
/// An arg-bearing recipe: declared `ArgSpec`s and the `make` that turns a
|
||||
/// parsed value set into the real, configured builder.
|
||||
Pending {
|
||||
specs: &'static [ArgSpec],
|
||||
#[allow(clippy::type_complexity)]
|
||||
make: fn(&[(String, ArgValue)]) -> PrimitiveBuilder,
|
||||
},
|
||||
/// A configured arg-bearing builder: the accepted, verbatim (name, value)
|
||||
/// pairs — the render/serialize surface (`construction_args`).
|
||||
Configured { values: Vec<ConstructionArg> },
|
||||
}
|
||||
|
||||
/// A node's declared interface: its inputs (in order) and its output record — an
|
||||
/// ordered list of named base columns; length 1 is a scalar (the degenerate
|
||||
/// case), and an **empty** `output` (`vec![]`) declares a **pure consumer**
|
||||
@@ -844,6 +1075,132 @@ mod tests {
|
||||
fn doc_gate_accepts_a_meaning_line() {
|
||||
assert_eq!(doc_gate("EMA", "exponential moving average over the input series"), Ok(()));
|
||||
}
|
||||
|
||||
// --- construction args (ArgKind/ArgSpec/ArgValue/ConstructionArg/ArgOpError) ---
|
||||
|
||||
const DEMO_ARG_SPECS: &[ArgSpec] =
|
||||
&[ArgSpec { name: "tz", kind: ArgKind::Tz }, ArgSpec { name: "open", kind: ArgKind::TimeOfDay }];
|
||||
|
||||
/// A minimal `make`: the fixture does not need `values` to shape a real
|
||||
/// schema (that's `Session::make`'s job, Task 2) — it only proves
|
||||
/// `try_args` reaches `make` with parsed values and carries the result
|
||||
/// forward as `Configured`.
|
||||
fn demo_make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
|
||||
let _ = values;
|
||||
PrimitiveBuilder::new(
|
||||
"Demo",
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only arg-bearing schema" },
|
||||
|_| Box::new(Bare),
|
||||
)
|
||||
}
|
||||
|
||||
fn demo_pending() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::pending("Demo", "test-only arg-bearing schema", DEMO_ARG_SPECS, demo_make)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_not_arg_bearing_on_a_plain_builder_with_args() {
|
||||
let plain = PrimitiveBuilder::new("Bare", NodeSchema::default(), |_| Box::new(Bare));
|
||||
assert_eq!(
|
||||
plain.try_args(&[("x".into(), "1".into())]).err(),
|
||||
Some(ArgOpError::NotArgBearing),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_plain_with_empty_raw_is_ok_unchanged() {
|
||||
let plain = PrimitiveBuilder::new(
|
||||
"Bare",
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "plain doc" },
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
let after = plain.try_args(&[]).expect("empty raw against a Plain builder is Ok(self)");
|
||||
assert_eq!(after.schema().doc, "plain doc");
|
||||
assert!(after.construction_args().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_unknown_arg_names_the_first_unknown() {
|
||||
assert_eq!(
|
||||
demo_pending()
|
||||
.try_args(&[("nope".into(), "x".into()), ("tz".into(), "Europe/Berlin".into())])
|
||||
.err(),
|
||||
Some(ArgOpError::UnknownArg("nope".into())),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_duplicate_arg_names_the_repeated_name() {
|
||||
assert_eq!(
|
||||
demo_pending()
|
||||
.try_args(&[
|
||||
("tz".into(), "Europe/Berlin".into()),
|
||||
("tz".into(), "Europe/Berlin".into()),
|
||||
("open".into(), "09:30".into()),
|
||||
])
|
||||
.err(),
|
||||
Some(ArgOpError::DuplicateArg("tz".into())),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_missing_arg_names_the_first_missing_in_spec_order() {
|
||||
assert_eq!(
|
||||
demo_pending().try_args(&[("tz".into(), "Europe/Berlin".into())]).err(),
|
||||
Some(ArgOpError::MissingArg("open".into())),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_bad_value_names_arg_kind_and_the_rejected_string() {
|
||||
assert_eq!(
|
||||
demo_pending()
|
||||
.try_args(&[("tz".into(), "berlin".into()), ("open".into(), "09:30".into())])
|
||||
.err(),
|
||||
Some(ArgOpError::BadValue { arg: "tz".into(), kind: ArgKind::Tz, got: "berlin".into() }),
|
||||
);
|
||||
}
|
||||
|
||||
/// Strict form IS the canonical form (spec §Closedness): no normalization
|
||||
/// layer, so a near-miss string refuses rather than being rewritten.
|
||||
#[test]
|
||||
fn arg_kind_parse_strict_form_refusals_and_accepts() {
|
||||
assert!(ArgKind::TimeOfDay.parse("9:30").is_err(), "not zero-padded");
|
||||
assert!(ArgKind::Tz.parse("berlin").is_err(), "not the exact IANA name");
|
||||
assert!(ArgKind::Count.parse("02").is_err(), "leading zero");
|
||||
assert!(ArgKind::TimeOfDay.parse("09:30").is_ok());
|
||||
assert!(ArgKind::Tz.parse("Europe/Berlin").is_ok());
|
||||
assert!(ArgKind::Count.parse("2").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_accepts_a_valid_set_and_echoes_verbatim_pairs() {
|
||||
let configured = demo_pending()
|
||||
.try_args(&[("tz".into(), "America/New_York".into()), ("open".into(), "09:30".into())])
|
||||
.expect("a full valid set configures");
|
||||
assert!(!configured.is_pending());
|
||||
assert_eq!(
|
||||
configured.construction_args(),
|
||||
&[
|
||||
ConstructionArg { name: "tz".into(), value: "America/New_York".into() },
|
||||
ConstructionArg { name: "open".into(), value: "09:30".into() },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_arg_specs_are_visible_before_configuration() {
|
||||
let pending = demo_pending();
|
||||
assert!(pending.is_pending());
|
||||
assert_eq!(pending.arg_specs().iter().map(|s| s.name).collect::<Vec<_>>(), ["tz", "open"]);
|
||||
assert!(pending.construction_args().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "unconfigured")]
|
||||
fn pending_build_panics() {
|
||||
let _ = demo_pending().build(&[]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -195,6 +195,38 @@ pub struct GangMember {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Deterministic shape gate for authored composite render names (#331) —
|
||||
/// shape only, never content judgement (C29 discipline): non-empty, no path
|
||||
/// separator (`/` or `\`), and not the special segments `.` or `..`. The rule
|
||||
/// exists because the name keys a trace directory on disk unsanitized
|
||||
/// (`traces/<name>/`, `crates/aura-registry/src/trace_store.rs`). Applied at
|
||||
/// both data-borne birth routes for a name (the skeptic's two-seam finding):
|
||||
/// the `Op::Name` op intake (`construction.rs`) and the CLI's
|
||||
/// blueprint-envelope root-name intake — never at store read-back (C29:
|
||||
/// registered artifacts are never retroactively invalidated), and never on
|
||||
/// the Rust builder API (`GraphBuilder::new`, role-2 native authoring).
|
||||
pub fn name_gate(name: &str) -> Result<(), NameGateFault> {
|
||||
if name.is_empty() {
|
||||
return Err(NameGateFault::Empty);
|
||||
}
|
||||
if name.contains('/') || name.contains('\\') {
|
||||
return Err(NameGateFault::ContainsSeparator);
|
||||
}
|
||||
if name == "." || name == ".." {
|
||||
return Err(NameGateFault::DotSegment);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The [`name_gate`] shape violation — WHY the name was refused; the caller
|
||||
/// already holds the offending name (WHAT) to build a message from.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum NameGateFault {
|
||||
Empty,
|
||||
ContainsSeparator,
|
||||
DotSegment,
|
||||
}
|
||||
|
||||
/// A reusable sub-graph fragment compiled away by inlining (C9/C23). It is **not**
|
||||
/// a [`Node`]: it is never `eval`'d. It holds interior items (local indices),
|
||||
/// interior edges (local indices), input roles (role `r` fans into the interior
|
||||
@@ -1519,6 +1551,19 @@ mod tests {
|
||||
use aura_strategy::Bias;
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// `name_gate` (#331) unit table: an ordinary name passes; an empty
|
||||
/// name, one containing either path separator, and the two dot segments
|
||||
/// each fault with their own fault variant.
|
||||
#[test]
|
||||
fn name_gate_accepts_ordinary_names_and_refuses_shape_violations() {
|
||||
assert_eq!(name_gate("ny_momentum"), Ok(()));
|
||||
assert_eq!(name_gate(""), Err(NameGateFault::Empty));
|
||||
assert_eq!(name_gate("a/b"), Err(NameGateFault::ContainsSeparator));
|
||||
assert_eq!(name_gate("a\\b"), Err(NameGateFault::ContainsSeparator));
|
||||
assert_eq!(name_gate("."), Err(NameGateFault::DotSegment));
|
||||
assert_eq!(name_gate(".."), Err(NameGateFault::DotSegment));
|
||||
}
|
||||
|
||||
/// One knob fanning into two sibling open params passes the gate; the
|
||||
/// value carries the gang table.
|
||||
#[test]
|
||||
@@ -1821,7 +1866,7 @@ mod tests {
|
||||
Composite::new(
|
||||
"sig3",
|
||||
vec![
|
||||
LinComb::builder(2).into(),
|
||||
LinComb::configured(2).into(),
|
||||
Bias::builder().into(),
|
||||
Sma::builder().named("c").into(),
|
||||
],
|
||||
@@ -1860,7 +1905,7 @@ mod tests {
|
||||
Composite::new(
|
||||
"sig3",
|
||||
vec![
|
||||
LinComb::builder(2).into(),
|
||||
LinComb::configured(2).into(),
|
||||
Bias::builder().into(),
|
||||
Sma::builder().named("c").into(),
|
||||
],
|
||||
@@ -3233,7 +3278,7 @@ mod tests {
|
||||
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
||||
let strategy = Composite::new(
|
||||
"strategy",
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
|
||||
vec![],
|
||||
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
|
||||
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
||||
@@ -3441,7 +3486,7 @@ mod tests {
|
||||
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
||||
let strategy = Composite::new(
|
||||
"strategy",
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
|
||||
// fan fast_slow's output into both LinComb terms so every interior slot
|
||||
// is wired (the totality check, cycle 0040); param order is unaffected.
|
||||
vec![
|
||||
@@ -3501,7 +3546,7 @@ mod tests {
|
||||
use aura_std::{LinComb, Sma};
|
||||
let bp = Composite::new(
|
||||
"root",
|
||||
vec![Sma::builder().into(), LinComb::builder(2).into()],
|
||||
vec![Sma::builder().into(), LinComb::configured(2).into()],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![], // output
|
||||
|
||||
@@ -7,18 +7,29 @@
|
||||
//! closure and the declared schema are re-derived on load from the injected
|
||||
//! resolver. This is the inverse of the lossy render half (`model_to_json`); it
|
||||
//! carries no node logic (C17) and references a closed vocabulary (C24).
|
||||
//!
|
||||
//! Construction args (#271) are structural, id-bearing data — like `bound`
|
||||
//! values, unlike debug-symbol names — so `strip_debug_symbols` leaves
|
||||
//! `PrimitiveData.args` untouched.
|
||||
|
||||
use crate::blueprint::{BlueprintNode, Composite, Gang, OutField, Role};
|
||||
use crate::harness::Edge;
|
||||
use aura_core::{BoundParam, PrimitiveBuilder};
|
||||
use aura_core::{BoundParam, ConstructionArg, PrimitiveBuilder};
|
||||
|
||||
/// The format version the loader understands. Bumped only by a load-bearing
|
||||
/// (Tier-2) change; additive optional fields do not bump it (#156). Pre-ship
|
||||
/// dormancy (#61, 2026-07-10): while the project is unshipped every document
|
||||
/// lives in-repo and reader/writer change atomically, so the Tier-2 bump
|
||||
/// discipline activates at the first external ship — which consciously
|
||||
/// freezes v1 (gangs included).
|
||||
pub const BLUEPRINT_FORMAT_VERSION: u32 = 1;
|
||||
/// The CEILING format version this build's loader understands (and the
|
||||
/// writer may ever emit) — bumped only by a load-bearing (Tier-2) change;
|
||||
/// additive optional fields do not bump it (#156). Pre-ship dormancy (#61,
|
||||
/// 2026-07-10): while the project is unshipped every document lives in-repo
|
||||
/// and reader/writer change atomically, so the Tier-2 bump discipline
|
||||
/// activates at the first external ship.
|
||||
///
|
||||
/// #271 (data-driven version): the writer no longer emits a single fixed
|
||||
/// version. A document emits `1` when args-free (byte-identical to every
|
||||
/// pre-#271 document — content ids stable, C18) and `2` the moment any
|
||||
/// primitive, at any nesting depth, carries construction `args` — the
|
||||
/// must-understand signal a pre-#271 loader needs. The loader accepts the
|
||||
/// closed range `1..=BLUEPRINT_FORMAT_VERSION`.
|
||||
pub const BLUEPRINT_FORMAT_VERSION: u32 = 2;
|
||||
|
||||
/// Top-level envelope: the version is read before the payload is interpreted.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
@@ -61,17 +72,33 @@ pub enum NodeData {
|
||||
}
|
||||
|
||||
/// A primitive node as data: its compiled-in type identity, optional instance
|
||||
/// name, and bound params. The schema + build closure are re-derived on load.
|
||||
/// name, its accepted construction args (#271), and bound params. The schema
|
||||
/// + build closure are re-derived on load.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct PrimitiveData {
|
||||
#[serde(rename = "type")]
|
||||
pub type_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// The consumed `(name, value)` construction-arg pairs (#271) — the
|
||||
/// id-bearing, serialized twin of `bound`. Additive-optional: absent
|
||||
/// (empty) for every args-free primitive, so a document with no args
|
||||
/// anywhere stays byte-identical to its pre-#271 form.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub args: Vec<ArgData>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub bound: Vec<BoundParam>,
|
||||
}
|
||||
|
||||
/// One serialized construction-arg pair (#271) — the wire twin of
|
||||
/// [`aura_core::ConstructionArg`]: `value` is the accepted strict-form
|
||||
/// string, stored verbatim (never re-normalized).
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ArgData {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Serializer failure (typed, named).
|
||||
#[derive(Debug)]
|
||||
pub enum SerializeError {
|
||||
@@ -111,9 +138,19 @@ fn project_node(n: &BlueprintNode) -> NodeData {
|
||||
// construction once a multi-param node enters the vocabulary.
|
||||
let mut bound = b.bound_params().to_vec();
|
||||
bound.sort_by_key(|bp| bp.pos);
|
||||
// Construction args (#271) are declared in a fixed `ArgSpec` order
|
||||
// (not player-chosen like binds), so no re-canonicalization is
|
||||
// needed — `construction_args()` already returns them in that
|
||||
// deterministic order.
|
||||
let args: Vec<ArgData> = b
|
||||
.construction_args()
|
||||
.iter()
|
||||
.map(|ConstructionArg { name, value }| ArgData { name: name.clone(), value: value.clone() })
|
||||
.collect();
|
||||
NodeData::Primitive(PrimitiveData {
|
||||
type_id: b.label(),
|
||||
name: b.instance_name().map(str::to_string),
|
||||
args,
|
||||
bound,
|
||||
})
|
||||
}
|
||||
@@ -121,8 +158,32 @@ fn project_node(n: &BlueprintNode) -> NodeData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Any primitive, at any nesting depth, carries construction args (#271) —
|
||||
/// the must-understand signal for the data-driven version (spec §Data-driven
|
||||
/// format version).
|
||||
fn has_args(b: &CompositeData) -> bool {
|
||||
b.nodes.iter().any(|n| match n {
|
||||
NodeData::Primitive(p) => !p.args.is_empty(),
|
||||
NodeData::Composite(c) => has_args(c),
|
||||
})
|
||||
}
|
||||
|
||||
/// The version THIS document must declare (#271): `1` for an args-free
|
||||
/// document (byte-identical to every pre-#271 document, C18) or `2` the
|
||||
/// moment any primitive anywhere carries args — never the fixed
|
||||
/// `BLUEPRINT_FORMAT_VERSION` ceiling.
|
||||
fn document_version(b: &CompositeData) -> u32 {
|
||||
if has_args(b) {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn build_doc(c: &Composite) -> BlueprintDoc {
|
||||
BlueprintDoc { format_version: BLUEPRINT_FORMAT_VERSION, blueprint: project(c) }
|
||||
let blueprint = project(c);
|
||||
let format_version = document_version(&blueprint);
|
||||
BlueprintDoc { format_version, blueprint }
|
||||
}
|
||||
|
||||
fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
|
||||
@@ -200,11 +261,16 @@ pub enum LoadError {
|
||||
/// addition needs finer granularity than a version bump.
|
||||
UnsupportedVersion { found: u32, supported: u32 },
|
||||
/// `resolve` returned `None` for a serialized `type_id` (outside the injected
|
||||
/// vocabulary — unknown node, or a construction-arg/sink node not in #155's set).
|
||||
/// vocabulary — unknown node, or a sink node not in #155's set).
|
||||
UnknownNodeType(String),
|
||||
/// The document's gangs section fails structural validation against its
|
||||
/// own nodes (a hand-edited or corrupted document).
|
||||
Gang(crate::blueprint::CompileError),
|
||||
/// A primitive's `args` (#271) failed `try_args`: unknown/duplicate/
|
||||
/// missing arg, a malformed value, OR (the pending-with-no-args refusal)
|
||||
/// an arg-bearing type serialized/hand-written with an empty `args` —
|
||||
/// a document naming an arg-bearing type without args must not load.
|
||||
BadArg(aura_core::ArgOpError),
|
||||
}
|
||||
|
||||
fn reconstruct(
|
||||
@@ -220,6 +286,15 @@ fn reconstruct(
|
||||
if let Some(n) = &p.name {
|
||||
b = b.named(n);
|
||||
}
|
||||
// Apply construction args (#271) BEFORE bound params — the
|
||||
// `try_args` seam. An arg-bearing type with no `args` in the
|
||||
// document refuses HERE as `MissingArg` (no pending builder
|
||||
// ever reaches `bind`/enters the built graph); a `Plain` type
|
||||
// has empty `p.args`, so this is `Ok(self)` unchanged for
|
||||
// every pre-#271 document.
|
||||
let args: Vec<(String, String)> =
|
||||
p.args.iter().map(|a| (a.name.clone(), a.value.clone())).collect();
|
||||
b = b.try_args(&args).map_err(LoadError::BadArg)?;
|
||||
// Re-apply bound params BY NAME. The effective param vector is
|
||||
// order-independent (each `bind` computes its slot against the
|
||||
// shrunk schema); ascending original `pos` is the canonical order.
|
||||
@@ -251,7 +326,9 @@ pub fn blueprint_from_json(
|
||||
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
|
||||
) -> Result<Composite, LoadError> {
|
||||
let doc: BlueprintDoc = serde_json::from_str(data).map_err(LoadError::Json)?;
|
||||
if doc.format_version != BLUEPRINT_FORMAT_VERSION {
|
||||
// #271: the loader accepts the closed range 1..=BLUEPRINT_FORMAT_VERSION
|
||||
// (today 1..=2) — a data-driven ceiling, not a single fixed version.
|
||||
if !(1..=BLUEPRINT_FORMAT_VERSION).contains(&doc.format_version) {
|
||||
return Err(LoadError::UnsupportedVersion {
|
||||
found: doc.format_version,
|
||||
supported: BLUEPRINT_FORMAT_VERSION,
|
||||
@@ -266,7 +343,8 @@ mod tests {
|
||||
use crate::blueprint::{Composite, GangMember, OutField, Role};
|
||||
use crate::harness::{Edge, Target};
|
||||
use crate::VecSource; // crate-root re-export (blueprint.rs tests import it the same way, e.g. :923)
|
||||
use aura_core::{Scalar, ScalarKind, Timestamp};
|
||||
use aura_core::{ArgOpError, Scalar, ScalarKind, Timestamp};
|
||||
use aura_market::Session;
|
||||
use aura_std::{Recorder, Sma, Sub};
|
||||
use aura_strategy::Bias;
|
||||
use std::sync::mpsc;
|
||||
@@ -436,11 +514,14 @@ mod tests {
|
||||
assert!(matches!(err, LoadError::UnknownNodeType(t) if t == "NoSuchNode"));
|
||||
}
|
||||
|
||||
/// #271 flipped pin (named contract change, not a regression): `format_version: 2`
|
||||
/// used to be refused (v1 was the only understood version); now that v2 loads
|
||||
/// (the args-bearing tier), only a version PAST the new ceiling is unsupported.
|
||||
#[test]
|
||||
fn unsupported_version_fails_named() {
|
||||
let json = r#"{"format_version":2,"blueprint":{"name":"x","nodes":[]}}"#;
|
||||
let json = r#"{"format_version":3,"blueprint":{"name":"x","nodes":[]}}"#;
|
||||
let err = blueprint_from_json(json, &|t| aura_vocabulary::std_vocabulary(t)).err().unwrap();
|
||||
assert!(matches!(err, LoadError::UnsupportedVersion { found: 2, supported: 1 }));
|
||||
assert!(matches!(err, LoadError::UnsupportedVersion { found: 3, supported: 2 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -813,4 +894,127 @@ mod tests {
|
||||
"the gang itself is identity-bearing"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- construction args (#271) --------------------------------------
|
||||
|
||||
// A single-node composite around an arg-configured `Session` (arity-free —
|
||||
// a `trigger` input role feeds it, its `bars_since_open` output re-exports).
|
||||
fn session_arg_fixture(tz: chrono_tz::Tz) -> Composite {
|
||||
Composite::new(
|
||||
"sess",
|
||||
vec![Session::configured(9, 30, tz, 15).into()],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "trigger".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![OutField { node: 0, field: 0, name: "bars".into() }],
|
||||
)
|
||||
}
|
||||
|
||||
/// #271 acceptance (engine layer): an args-bearing composite serializes its
|
||||
/// construction-arg pairs, declares `format_version: 2` (the data-driven
|
||||
/// must-understand tier, spec §Data-driven format version), and round-trips
|
||||
/// byte-stably through load -> re-serialize.
|
||||
#[test]
|
||||
fn args_round_trip_preserves_pairs_and_version_2() {
|
||||
let c = session_arg_fixture(chrono_tz::Europe::Berlin);
|
||||
let json = blueprint_to_json(&c).expect("serializes");
|
||||
assert!(json.contains(r#""format_version":2"#), "args-bearing document is version 2: {json}");
|
||||
assert!(
|
||||
json.contains(r#""args":[{"name":"tz","value":"Europe/Berlin"},{"name":"open","value":"09:30"}]"#),
|
||||
"carries the verbatim accepted arg pairs: {json}"
|
||||
);
|
||||
let loaded = blueprint_from_json(&json, &fixture_resolver()).expect("loads");
|
||||
assert_eq!(
|
||||
blueprint_to_json(&loaded).expect("re-serializes"),
|
||||
json,
|
||||
"args round-trip byte-stably"
|
||||
);
|
||||
}
|
||||
|
||||
/// #271 acceptance (C18 stability): a document with NO args anywhere stays
|
||||
/// version 1 and byte-identical to its pre-#271 canonical form — the exact
|
||||
/// golden `signal_serializes_to_canonical_golden` already pins, re-asserted
|
||||
/// here under the #271-specific test name the plan names.
|
||||
#[test]
|
||||
fn args_free_document_serializes_byte_identical_and_version_1() {
|
||||
let signal = crate::test_fixtures::sink_free_sma_cross_signal();
|
||||
let json = blueprint_to_json(&signal).expect("serializes");
|
||||
assert!(json.starts_with(r#"{"format_version":1,"#), "args-free document stays version 1: {json}");
|
||||
assert!(!json.contains("\"args\""), "an args-free document emits no args key: {json}");
|
||||
let golden = r#"{"format_version":1,"blueprint":{"name":"sma_cross","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias"}}],"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}]}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||||
assert_eq!(json, golden, "byte-identical to the pre-#271 canonical form (content ids stable, C18)");
|
||||
}
|
||||
|
||||
/// #271 (nested propagation): an args-bearing primitive nested INSIDE an
|
||||
/// outer composite still trips the outer document's version to 2 — the
|
||||
/// version walk recurses through `NodeData::Composite`, not just the
|
||||
/// top-level node list.
|
||||
#[test]
|
||||
fn nested_spliced_args_composite_is_version_2() {
|
||||
let inner = session_arg_fixture(chrono_tz::Europe::Berlin);
|
||||
let outer = Composite::new(
|
||||
"outer",
|
||||
vec![crate::blueprint::BlueprintNode::Composite(inner)],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "trigger".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![OutField { node: 0, field: 0, name: "bars".into() }],
|
||||
);
|
||||
let json = blueprint_to_json(&outer).expect("serializes");
|
||||
assert!(json.contains(r#""format_version":2"#), "a nested arg-bearing primitive still trips version 2: {json}");
|
||||
assert!(json.contains("\"args\""), "the nested primitive's args survive: {json}");
|
||||
}
|
||||
|
||||
/// #271 (identity is arg-bearing): construction args are structural,
|
||||
/// id-bearing data — like a bound value, unlike a debug-symbol name — so
|
||||
/// two builds differing ONLY in one accepted arg value never share an
|
||||
/// identity JSON, while renaming the composite (a pure debug symbol)
|
||||
/// still does not affect it.
|
||||
#[test]
|
||||
fn identity_json_retains_args() {
|
||||
let berlin = session_arg_fixture(chrono_tz::Europe::Berlin);
|
||||
let paris = session_arg_fixture(chrono_tz::Europe::Paris);
|
||||
assert_ne!(
|
||||
blueprint_identity_json(&berlin).expect("identity-serializes"),
|
||||
blueprint_identity_json(&paris).expect("identity-serializes"),
|
||||
"a differing construction-arg value survives the identity projection"
|
||||
);
|
||||
// renaming the composite (a debug symbol) does not affect identity.
|
||||
let renamed = Composite::new(
|
||||
"sess_renamed",
|
||||
vec![Session::configured(9, 30, chrono_tz::Europe::Berlin, 15).into()],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "trigger".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![OutField { node: 0, field: 0, name: "bars".into() }],
|
||||
);
|
||||
assert_eq!(
|
||||
blueprint_identity_json(&berlin).expect("identity-serializes"),
|
||||
blueprint_identity_json(&renamed).expect("identity-serializes"),
|
||||
"the composite's own debug name does not affect identity"
|
||||
);
|
||||
}
|
||||
|
||||
/// #271 (refuse, don't guess): a hand-written document naming an
|
||||
/// arg-bearing type (`Session`) with NO `args` key refuses on load as
|
||||
/// `LoadError::BadArg(ArgOpError::MissingArg(..))` — a pending builder
|
||||
/// must never silently enter the built graph.
|
||||
#[test]
|
||||
fn loading_arg_bearing_type_without_args_refuses() {
|
||||
let json = r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"Session"}}]}}"#;
|
||||
let err = blueprint_from_json(json, &fixture_resolver()).err().unwrap();
|
||||
assert!(
|
||||
matches!(&err, LoadError::BadArg(ArgOpError::MissingArg(a)) if a == "tz"),
|
||||
"an arg-bearing type with no args refuses as MissingArg, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use aura_core::{BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind};
|
||||
use aura_core::{ArgOpError, BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind};
|
||||
|
||||
use crate::blueprint::{
|
||||
check_param_namespace_injective, edge_kind_check, validate_wiring, BlueprintNode, Composite,
|
||||
Gang, GangMember, OutField, Role, Tap, TapWire,
|
||||
check_param_namespace_injective, edge_kind_check, name_gate, validate_wiring, BlueprintNode,
|
||||
Composite, Gang, GangMember, NameGateFault, OutField, Role, Tap, TapWire,
|
||||
};
|
||||
use crate::builder::{resolve_input_slot, resolve_output_field, PortResolveError};
|
||||
use crate::harness::{Edge, Target};
|
||||
@@ -32,8 +32,14 @@ pub enum Op {
|
||||
Source { role: String, kind: ScalarKind },
|
||||
/// Reserve an open input role (wired by an enclosing graph).
|
||||
Input { role: String },
|
||||
/// Add a node of `type_id`, optionally named `as_name`, with `bind` params.
|
||||
Add { type_id: String, as_name: Option<String>, bind: Vec<(String, Scalar)> },
|
||||
/// Add a node of `type_id`, optionally named `as_name`, with construction
|
||||
/// `args` (the typed, closed channel — #271) applied BEFORE `bind` params.
|
||||
Add {
|
||||
type_id: String,
|
||||
as_name: Option<String>,
|
||||
args: Vec<(String, String)>,
|
||||
bind: Vec<(String, Scalar)>,
|
||||
},
|
||||
/// Fan a role into one or more `"node.slot"` consumers.
|
||||
Feed { role: String, into: Vec<String> },
|
||||
/// Wire `"node.field"` -> `"node.slot"`.
|
||||
@@ -58,6 +64,8 @@ pub enum Op {
|
||||
/// `bind` is applied path-qualified (e.g. `"sma.length"`), after the
|
||||
/// splice.
|
||||
Use { ref_id: String, name: Option<String>, bind: Vec<(String, Scalar)> },
|
||||
/// Set the composite's render name (#331). At most once per script.
|
||||
Name { name: String },
|
||||
}
|
||||
|
||||
/// A per-op construction fault, by-identifier so the cause names the op.
|
||||
@@ -91,6 +99,11 @@ pub enum OpError {
|
||||
/// blueprint non-acyclic (domain invariant 5 / ledger C9).
|
||||
WouldCycle { from: String, to: String },
|
||||
BadParam { node: String, err: BindOpError },
|
||||
/// A construction-arg fault (#271): an arg-bearing type's `args` failed
|
||||
/// `try_args` (unknown/duplicate/missing arg, or a malformed value) — or a
|
||||
/// `Plain` type was given args it does not declare. By-identifier, naming
|
||||
/// the offending node.
|
||||
BadArg { node: String, err: ArgOpError },
|
||||
/// Holistic totality fault, by-identifier: an interior input slot covered by
|
||||
/// no edge or role target (the finalize 0-cover arm).
|
||||
UnconnectedPort { node: String, slot: String },
|
||||
@@ -113,6 +126,13 @@ pub enum OpError {
|
||||
/// resolves and fetches before replay, refusing there on a miss), but
|
||||
/// total: a bare `replay`/`GraphSession` caller can still hit it.
|
||||
UnknownSubgraph { ref_id: String },
|
||||
/// A second `name` op — the render name is declared at most once (mirrors
|
||||
/// `DuplicateDoc`).
|
||||
DuplicateName,
|
||||
/// A `name` op's `name` (or, at the CLI's blueprint-envelope intake, an
|
||||
/// envelope's root name) failed the deterministic `name_gate` shape check
|
||||
/// (#331). Carries the offending name and the shape fault detail.
|
||||
BadName { name: String, fault: NameGateFault },
|
||||
}
|
||||
|
||||
/// A per-op-fallible blueprint accumulator. Holds the same interior data a
|
||||
@@ -142,6 +162,11 @@ pub struct GraphSession<'v> {
|
||||
coverage: HashMap<(usize, usize), usize>,
|
||||
gangs: Vec<Gang>,
|
||||
doc: Option<String>,
|
||||
/// At-most-once guard for `Op::Name` (#331), mirroring `doc`'s own
|
||||
/// uniqueness posture but tracked separately (`name` itself is always
|
||||
/// populated, seeded from `::new`'s `name` argument, so it cannot double
|
||||
/// as its own "was it authored" flag).
|
||||
name_authored: bool,
|
||||
}
|
||||
|
||||
impl<'v> GraphSession<'v> {
|
||||
@@ -171,6 +196,7 @@ impl<'v> GraphSession<'v> {
|
||||
coverage: HashMap::new(),
|
||||
gangs: Vec::new(),
|
||||
doc: None,
|
||||
name_authored: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +206,7 @@ impl<'v> GraphSession<'v> {
|
||||
match op {
|
||||
Op::Source { role, kind } => self.add_role(role, Some(kind)),
|
||||
Op::Input { role } => self.add_role(role, None),
|
||||
Op::Add { type_id, as_name, bind } => self.add_node(type_id, as_name, bind),
|
||||
Op::Add { type_id, as_name, args, bind } => self.add_node(type_id, as_name, args, bind),
|
||||
Op::Feed { role, into } => self.feed(role, into),
|
||||
Op::Connect { from, to } => self.connect(from, to),
|
||||
Op::Expose { from, as_name } => self.expose(from, as_name),
|
||||
@@ -194,9 +220,29 @@ impl<'v> GraphSession<'v> {
|
||||
Ok(())
|
||||
}
|
||||
Op::Use { ref_id, name, bind } => self.use_subgraph(ref_id, name, bind),
|
||||
Op::Name { name } => self.set_name(name),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Op::Name` (#331): set the composite's render name — the op-script's
|
||||
/// data-borne twin of `replay`'s seeded default name. Guarded by (a)
|
||||
/// at-most-once (mirrors the `doc` op's uniqueness guard just above) and
|
||||
/// (b) the shared deterministic shape gate (`name_gate`) — the SAME gate
|
||||
/// the CLI's blueprint-envelope intake applies to a hand-edited
|
||||
/// envelope's root name (the other data-borne birth route, skeptic
|
||||
/// finding minuted on #331).
|
||||
fn set_name(&mut self, name: String) -> Result<(), OpError> {
|
||||
if self.name_authored {
|
||||
return Err(OpError::DuplicateName);
|
||||
}
|
||||
if let Err(fault) = name_gate(&name) {
|
||||
return Err(OpError::BadName { name, fault });
|
||||
}
|
||||
self.name = name;
|
||||
self.name_authored = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `Op::Use` (#317): fetch a registered blueprint through the injected
|
||||
/// `subgraph` resolver, rename it to the instance identifier, apply its
|
||||
/// path-qualified `bind` entries, and push it as an ordinary
|
||||
@@ -248,11 +294,17 @@ impl<'v> GraphSession<'v> {
|
||||
&mut self,
|
||||
type_id: String,
|
||||
as_name: Option<String>,
|
||||
args: Vec<(String, String)>,
|
||||
bind: Vec<(String, Scalar)>,
|
||||
) -> Result<(), OpError> {
|
||||
let mut builder =
|
||||
(self.vocab)(&type_id).ok_or_else(|| OpError::UnknownNodeType(type_id.clone()))?;
|
||||
let id = as_name.unwrap_or_else(|| builder.node_name());
|
||||
// Construction args apply BEFORE the bind loop (spec §aura-engine): a
|
||||
// pending arg-bearing builder with no args therefore refuses here as
|
||||
// `MissingArg` — no pending builder ever reaches `try_bind`/enters the
|
||||
// graph.
|
||||
builder = builder.try_args(&args).map_err(|err| OpError::BadArg { node: id.clone(), err })?;
|
||||
for (slot, value) in bind {
|
||||
builder = builder
|
||||
.try_bind(&slot, value)
|
||||
@@ -564,7 +616,7 @@ pub fn replay(
|
||||
mod tests {
|
||||
use super::{GraphSession, Op, OpError};
|
||||
use crate::blueprint::{BlueprintNode, Composite, Role};
|
||||
use aura_core::{BindOpError, Scalar, ScalarKind};
|
||||
use aura_core::{ArgKind, ArgOpError, BindOpError, Scalar, ScalarKind};
|
||||
use aura_vocabulary::std_vocabulary;
|
||||
|
||||
fn session(name: &str) -> GraphSession<'static> {
|
||||
@@ -575,7 +627,7 @@ mod tests {
|
||||
fn add_unknown_type_rejected_at_op() {
|
||||
let mut s = session("g");
|
||||
assert_eq!(
|
||||
s.apply(Op::Add { type_id: "Nope".into(), as_name: None, bind: vec![] }),
|
||||
s.apply(Op::Add { type_id: "Nope".into(), as_name: None, args: vec![], bind: vec![] }),
|
||||
Err(OpError::UnknownNodeType("Nope".into()))
|
||||
);
|
||||
}
|
||||
@@ -583,9 +635,9 @@ mod tests {
|
||||
#[test]
|
||||
fn add_duplicate_identifier_rejected() {
|
||||
let mut s = session("g");
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("x".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("x".into()), args: vec![], bind: vec![] }).unwrap();
|
||||
assert_eq!(
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("x".into()), bind: vec![] }),
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("x".into()), args: vec![], bind: vec![] }),
|
||||
Err(OpError::DuplicateIdentifier("x".into()))
|
||||
);
|
||||
}
|
||||
@@ -597,7 +649,7 @@ mod tests {
|
||||
s.apply(Op::Add {
|
||||
type_id: "SMA".into(),
|
||||
as_name: Some("fast".into()),
|
||||
bind: vec![("length".into(), Scalar::f64(2.0))], // wrong kind (i64 param)
|
||||
args: vec![], bind: vec![("length".into(), Scalar::f64(2.0))], // wrong kind (i64 param)
|
||||
}),
|
||||
Err(OpError::BadParam {
|
||||
node: "fast".into(),
|
||||
@@ -624,9 +676,9 @@ mod tests {
|
||||
fn scaffold() -> GraphSession<'static> {
|
||||
let mut s = session("g");
|
||||
s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }).unwrap();
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] }).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
@@ -653,8 +705,8 @@ mod tests {
|
||||
fn connect_kind_mismatch_rejected_at_op() {
|
||||
// Gt produces bool; Bias.signal consumes f64 -> eager kind mismatch
|
||||
let mut s = session("g");
|
||||
s.apply(Op::Add { type_id: "Gt".into(), as_name: None, bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "Gt".into(), as_name: None, args: vec![], bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "Bias".into(), as_name: None, args: vec![], bind: vec![] }).unwrap();
|
||||
let r = s.apply(Op::Connect { from: "gt.value".into(), to: "bias.signal".into() });
|
||||
assert!(matches!(
|
||||
r,
|
||||
@@ -820,9 +872,9 @@ mod tests {
|
||||
use crate::harness::FlatTap;
|
||||
let ops = vec![
|
||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
|
||||
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] },
|
||||
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() },
|
||||
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
|
||||
@@ -842,10 +894,10 @@ mod tests {
|
||||
fn gang_op_projects_the_param_space() {
|
||||
let ops = vec![
|
||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), args: vec![], bind: vec![] },
|
||||
Op::Gang { as_name: "length".into(), into: vec!["a.length".into(), "b.length".into()] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
|
||||
Op::Feed { role: "price".into(), into: vec!["a.series".into(), "b.series".into()] },
|
||||
Op::Connect { from: "a.value".into(), to: "sub.lhs".into() },
|
||||
Op::Connect { from: "b.value".into(), to: "sub.rhs".into() },
|
||||
@@ -863,7 +915,7 @@ mod tests {
|
||||
/// i64` session-index output, wires its `trigger` input through the ordinary
|
||||
/// edge convention, and builds to a finished `Composite`. Reachability is the
|
||||
/// whole ask — the node's tz-aware/DST session SEMANTICS are already pinned
|
||||
/// in `aura-std/src/session.rs` and are not re-pinned here. Today the preset
|
||||
/// in `aura-market/src/session.rs` and are not re-pinned here. Today the preset
|
||||
/// id is absent from the roster (the base `Session` builder takes structural
|
||||
/// construction args, so the zero-arg macro cannot host it), so the lookup
|
||||
/// returns `None` and the op-script's `Add` would fail `UnknownNodeType`.
|
||||
@@ -892,7 +944,7 @@ mod tests {
|
||||
Op::Add {
|
||||
type_id: "SessionFrankfurt".into(),
|
||||
as_name: Some("session".into()),
|
||||
bind: vec![("period_minutes".into(), Scalar::i64(15))],
|
||||
args: vec![], bind: vec![("period_minutes".into(), Scalar::i64(15))],
|
||||
},
|
||||
Op::Feed { role: "trigger".into(), into: vec!["session.trigger".into()] },
|
||||
Op::Expose { from: "session.bars_since_open".into(), as_name: "bars".into() },
|
||||
@@ -909,11 +961,11 @@ mod tests {
|
||||
// bound member: `b.length` was bound at Add, so it left the open param
|
||||
// list and is unknown to the gang gate, exactly as `try_bind` would see it.
|
||||
let mut s = session("g");
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add {
|
||||
type_id: "SMA".into(),
|
||||
as_name: Some("b".into()),
|
||||
bind: vec![("length".into(), Scalar::i64(5))],
|
||||
args: vec![], bind: vec![("length".into(), Scalar::i64(5))],
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -923,8 +975,8 @@ mod tests {
|
||||
|
||||
// kind mismatch: SMA.length is i64, Bias.scale is f64.
|
||||
let mut s = session("g");
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "Bias".into(), as_name: Some("b".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "Bias".into(), as_name: Some("b".into()), args: vec![], bind: vec![] }).unwrap();
|
||||
let r = s.apply(Op::Gang { as_name: "g".into(), into: vec!["a.length".into(), "b.scale".into()] });
|
||||
assert!(
|
||||
matches!(r, Err(OpError::GangKindMismatch { expected: ScalarKind::I64, got: ScalarKind::F64, .. })),
|
||||
@@ -933,9 +985,9 @@ mod tests {
|
||||
|
||||
// doubly-ganged member: a.length is already claimed by an earlier gang.
|
||||
let mut s = session("g");
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("c".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), args: vec![], bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("c".into()), args: vec![], bind: vec![] }).unwrap();
|
||||
s.apply(Op::Gang { as_name: "lo".into(), into: vec!["a.length".into(), "b.length".into()] }).unwrap();
|
||||
assert_eq!(
|
||||
s.apply(Op::Gang { as_name: "hi".into(), into: vec!["a.length".into(), "c.length".into()] }),
|
||||
@@ -944,7 +996,7 @@ mod tests {
|
||||
|
||||
// single member: a gang needs at least two.
|
||||
let mut s = session("g");
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] }).unwrap();
|
||||
assert_eq!(
|
||||
s.apply(Op::Gang { as_name: "solo".into(), into: vec!["a.length".into()] }),
|
||||
Err(OpError::GangArity { gang: "solo".into() })
|
||||
@@ -952,7 +1004,7 @@ mod tests {
|
||||
|
||||
// unknown node: a member names a node that was never added.
|
||||
let mut s = session("g");
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] }).unwrap();
|
||||
assert_eq!(
|
||||
s.apply(Op::Gang { as_name: "g".into(), into: vec!["a.length".into(), "ghost.length".into()] }),
|
||||
Err(OpError::UnknownIdentifier("ghost".into()))
|
||||
@@ -985,6 +1037,72 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Op::Name (#331) -----------------------------------------------
|
||||
|
||||
/// A `name` op sets the finished composite's render name — the property
|
||||
/// the whole op exists for.
|
||||
#[test]
|
||||
fn name_op_sets_the_finished_composite_name() {
|
||||
let mut s = scaffold();
|
||||
s.apply(Op::Name { name: "x".into() }).unwrap();
|
||||
s.apply(Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] })
|
||||
.unwrap();
|
||||
s.apply(Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }).unwrap();
|
||||
s.apply(Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() }).unwrap();
|
||||
s.apply(Op::Expose { from: "sub.value".into(), as_name: "out".into() }).unwrap();
|
||||
let c = s.finish().expect("finishes");
|
||||
assert_eq!(c.name(), "x");
|
||||
}
|
||||
|
||||
/// Byte-compat (#331 AC1): a script that never authors a `name` op keeps
|
||||
/// the seed name `replay`/`GraphSession::new` was given — the CLI's own
|
||||
/// seed stays `"graph"`, unchanged by this op's addition.
|
||||
#[test]
|
||||
fn no_name_op_keeps_the_seeded_replay_name() {
|
||||
let ops = vec![
|
||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] },
|
||||
Op::Feed { role: "price".into(), into: vec!["a.series".into()] },
|
||||
Op::Expose { from: "a.value".into(), as_name: "out".into() },
|
||||
];
|
||||
let c = super::replay("graph", ops, &std_vocabulary, &|_: &str| None).expect("replays");
|
||||
assert_eq!(c.name(), "graph");
|
||||
}
|
||||
|
||||
/// A second `name` op is a fault, not a silent last-wins overwrite —
|
||||
/// mirrors the `doc` op's own at-most-once guard.
|
||||
#[test]
|
||||
fn duplicate_name_op_is_refused() {
|
||||
let mut s = session("g");
|
||||
s.apply(Op::Name { name: "x".into() }).unwrap();
|
||||
assert_eq!(
|
||||
s.apply(Op::Name { name: "y".into() }),
|
||||
Err(OpError::DuplicateName)
|
||||
);
|
||||
}
|
||||
|
||||
/// Shape violations (#331): empty, containing a path separator, and the
|
||||
/// dot segments all fault at the op via the shared `name_gate`.
|
||||
#[test]
|
||||
fn name_op_shape_violations_are_refused() {
|
||||
use crate::blueprint::NameGateFault;
|
||||
let mut s = session("g");
|
||||
assert_eq!(
|
||||
s.apply(Op::Name { name: "".into() }),
|
||||
Err(OpError::BadName { name: "".into(), fault: NameGateFault::Empty })
|
||||
);
|
||||
let mut s = session("g");
|
||||
assert_eq!(
|
||||
s.apply(Op::Name { name: "a/b".into() }),
|
||||
Err(OpError::BadName { name: "a/b".into(), fault: NameGateFault::ContainsSeparator })
|
||||
);
|
||||
let mut s = session("g");
|
||||
assert_eq!(
|
||||
s.apply(Op::Name { name: "..".into() }),
|
||||
Err(OpError::BadName { name: "..".into(), fault: NameGateFault::DotSegment })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_reports_incomplete_on_unwired_slot() {
|
||||
// a fully-named scaffold missing the sub.rhs connection -> holistic 0-arm
|
||||
@@ -1010,7 +1128,7 @@ mod tests {
|
||||
fn finish_accepts_an_open_input_role_and_compile_refuses_it() {
|
||||
let mut s = session("g");
|
||||
s.apply(Op::Input { role: "ext".into() }).unwrap();
|
||||
s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), args: vec![], bind: vec![] }).unwrap();
|
||||
s.apply(Op::Feed { role: "ext".into(), into: vec!["sub.lhs".into(), "sub.rhs".into()] }).unwrap();
|
||||
s.apply(Op::Expose { from: "sub.value".into(), as_name: "out".into() }).unwrap();
|
||||
let c = s.finish().expect("finish() no longer gates root-role boundness");
|
||||
@@ -1030,8 +1148,8 @@ mod tests {
|
||||
fn finish_reports_role_kind_mismatch_by_identifier() {
|
||||
let mut s = session("g");
|
||||
s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }).unwrap();
|
||||
s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "And".into(), as_name: Some("conj".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), args: vec![], bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "And".into(), as_name: Some("conj".into()), args: vec![], bind: vec![] }).unwrap();
|
||||
// price fans into sub.lhs (f64) AND conj.a (bool): differing slot kinds.
|
||||
s.apply(Op::Feed { role: "price".into(), into: vec!["sub.lhs".into(), "conj.a".into()] }).unwrap();
|
||||
let err = s.finish().err().unwrap();
|
||||
@@ -1065,8 +1183,8 @@ mod tests {
|
||||
// — only an acyclicity check can catch it.
|
||||
let ops = vec![
|
||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
||||
Op::Add { type_id: "Sub".into(), as_name: Some("a".into()), bind: vec![] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: Some("b".into()), bind: vec![] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: Some("a".into()), args: vec![], bind: vec![] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: Some("b".into()), args: vec![], bind: vec![] },
|
||||
Op::Feed { role: "price".into(), into: vec!["a.rhs".into(), "b.rhs".into()] },
|
||||
Op::Connect { from: "a.value".into(), to: "b.lhs".into() }, // op 4: a -> b
|
||||
Op::Connect { from: "b.value".into(), to: "a.lhs".into() }, // op 5: closes b -> a
|
||||
@@ -1097,7 +1215,7 @@ mod tests {
|
||||
// loop. Both slots covered, role bound — only an acyclicity check catches it.
|
||||
let ops = vec![
|
||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
||||
Op::Add { type_id: "Sub".into(), as_name: Some("s".into()), bind: vec![] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: Some("s".into()), args: vec![], bind: vec![] },
|
||||
Op::Feed { role: "price".into(), into: vec!["s.rhs".into()] },
|
||||
Op::Connect { from: "s.value".into(), to: "s.lhs".into() }, // self-edge
|
||||
Op::Expose { from: "s.value".into(), as_name: "out".into() },
|
||||
@@ -1111,9 +1229,9 @@ mod tests {
|
||||
#[test]
|
||||
fn replay_stops_at_first_failing_op_naming_index() {
|
||||
let ops = vec![
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] },
|
||||
Op::Add { type_id: "Nope".into(), as_name: None, bind: vec![] }, // op 1 fails
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] },
|
||||
Op::Add { type_id: "Nope".into(), as_name: None, args: vec![], bind: vec![] }, // op 1 fails
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
|
||||
];
|
||||
// `.err().unwrap()` rather than `.unwrap_err()`: the Ok arm `Composite` is
|
||||
// not `Debug` (it holds build closures), which `unwrap_err` would require.
|
||||
@@ -1141,10 +1259,10 @@ mod tests {
|
||||
// (a) op-script replay
|
||||
let ops = vec![
|
||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
|
||||
Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
|
||||
Op::Add { type_id: "Bias".into(), as_name: None, args: vec![], bind: vec![] },
|
||||
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] },
|
||||
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() },
|
||||
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
|
||||
@@ -1189,10 +1307,10 @@ mod tests {
|
||||
// (a) op-script: the Task-4 fixture (two SMA + gang + Sub + expose).
|
||||
let ops = vec![
|
||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), args: vec![], bind: vec![] },
|
||||
Op::Gang { as_name: "length".into(), into: vec!["a.length".into(), "b.length".into()] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
|
||||
Op::Feed { role: "price".into(), into: vec!["a.series".into(), "b.series".into()] },
|
||||
Op::Connect { from: "a.value".into(), to: "sub.lhs".into() },
|
||||
Op::Connect { from: "b.value".into(), to: "sub.rhs".into() },
|
||||
@@ -1241,9 +1359,9 @@ mod tests {
|
||||
// (a) op-script: an SMA crossover with a tap on the raw spread (sub.value).
|
||||
let ops = vec![
|
||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), args: vec![], bind: vec![] },
|
||||
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] },
|
||||
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() },
|
||||
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
|
||||
@@ -1277,6 +1395,71 @@ mod tests {
|
||||
assert_eq!(replay_flat.edges, built_flat.edges);
|
||||
}
|
||||
|
||||
// ---- construction args (#271) -------------------------------------
|
||||
|
||||
/// `add_node` applies `try_args` AFTER resolve and BEFORE the bind loop
|
||||
/// (spec §aura-engine): an arg-bearing type's `add` op, given a valid
|
||||
/// `args` set, configures the real signature and the subsequent `bind`
|
||||
/// sees the now-real `period_minutes` param — a data-only op-script
|
||||
/// reaches a `Session` for any IANA timezone/open, not just the
|
||||
/// `SessionFrankfurt` preset.
|
||||
#[test]
|
||||
fn add_op_with_args_builds_a_session() {
|
||||
let mut s = session("g");
|
||||
s.apply(Op::Source { role: "trigger".into(), kind: ScalarKind::F64 }).unwrap();
|
||||
assert_eq!(
|
||||
s.apply(Op::Add {
|
||||
type_id: "Session".into(),
|
||||
as_name: Some("ny".into()),
|
||||
args: vec![("tz".into(), "America/New_York".into()), ("open".into(), "09:30".into())],
|
||||
bind: vec![("period_minutes".into(), Scalar::i64(15))],
|
||||
}),
|
||||
Ok(()),
|
||||
);
|
||||
s.apply(Op::Feed { role: "trigger".into(), into: vec!["ny.trigger".into()] }).unwrap();
|
||||
s.apply(Op::Expose { from: "ny.bars_since_open".into(), as_name: "bars".into() }).unwrap();
|
||||
s.finish().expect("an args-configured Session finishes and builds");
|
||||
}
|
||||
|
||||
/// `Session` is arg-bearing (declares `tz`/`open`): an `add` op naming it
|
||||
/// with NO `args` refuses at the op as `BadArg(MissingArg(..))` — the
|
||||
/// pending builder never reaches the bind loop or the graph (spec
|
||||
/// §aura-engine: "a pending resolve with no args therefore fails as
|
||||
/// MissingArg").
|
||||
#[test]
|
||||
fn add_op_arg_bearing_type_without_args_refuses() {
|
||||
let mut s = session("g");
|
||||
assert_eq!(
|
||||
s.apply(Op::Add {
|
||||
type_id: "Session".into(),
|
||||
as_name: Some("ny".into()),
|
||||
args: vec![],
|
||||
bind: vec![],
|
||||
}),
|
||||
Err(OpError::BadArg { node: "ny".into(), err: ArgOpError::MissingArg("tz".into()) }),
|
||||
);
|
||||
}
|
||||
|
||||
/// A malformed arg value (strict-form refusal, spec §Closedness) surfaces
|
||||
/// as `BadArg(BadValue{..})`, naming the arg, its declared `ArgKind`, and
|
||||
/// the rejected string.
|
||||
#[test]
|
||||
fn add_op_bad_tz_refuses() {
|
||||
let mut s = session("g");
|
||||
assert_eq!(
|
||||
s.apply(Op::Add {
|
||||
type_id: "Session".into(),
|
||||
as_name: Some("ny".into()),
|
||||
args: vec![("tz".into(), "not_a_zone".into()), ("open".into(), "09:30".into())],
|
||||
bind: vec![],
|
||||
}),
|
||||
Err(OpError::BadArg {
|
||||
node: "ny".into(),
|
||||
err: ArgOpError::BadValue { arg: "tz".into(), kind: ArgKind::Tz, got: "not_a_zone".into() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Op::Use (#317) -----------------------------------------------
|
||||
|
||||
/// A small two-node fixture composite for the `Op::Use` tests (#317): an
|
||||
@@ -1412,13 +1595,37 @@ mod tests {
|
||||
fn use_op_duplicate_instance_identifier_rejected() {
|
||||
let subgraph = |_: &str| Some(use_fixture());
|
||||
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("gate".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("gate".into()), args: vec![], bind: vec![] }).unwrap();
|
||||
assert_eq!(
|
||||
s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] }),
|
||||
Err(OpError::DuplicateIdentifier("gate".into()))
|
||||
);
|
||||
}
|
||||
|
||||
/// #331 ratifying pin (spec test 5): `use_subgraph`'s instance-identifier
|
||||
/// default (`name: None` -> the fetched composite's own `name()`,
|
||||
/// `construction.rs:221-230` — code-inspected, previously untested since
|
||||
/// every other test in this section passes an explicit instance name)
|
||||
/// actually reaches the session's identifier namespace: `use_fixture()`'s
|
||||
/// own name is "fixture", so with no instance name the spliced ports
|
||||
/// resolve under "fixture.*", not some other placeholder.
|
||||
#[test]
|
||||
fn use_op_with_no_name_defaults_instance_identifier_to_the_fetched_composites_name() {
|
||||
let subgraph = |_: &str| Some(use_fixture());
|
||||
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
|
||||
s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }).unwrap();
|
||||
assert_eq!(
|
||||
s.apply(Op::Use { ref_id: "fixture-id".into(), name: None, bind: vec![] }),
|
||||
Ok(())
|
||||
);
|
||||
s.apply(Op::Feed { role: "price".into(), into: vec!["fixture.price".into()] }).unwrap();
|
||||
assert_eq!(
|
||||
s.apply(Op::Expose { from: "fixture.out".into(), as_name: "bias".into() }),
|
||||
Ok(()),
|
||||
"the spliced instance resolves under \"fixture\" — the fetched composite's own name"
|
||||
);
|
||||
}
|
||||
|
||||
/// A post-splice `bind` path with no matching open param anywhere in the
|
||||
/// instance faults through the existing bind-fault shape (`BadParam`),
|
||||
/// naming the instance and the qualified within-instance path (#317).
|
||||
@@ -1448,13 +1655,13 @@ mod tests {
|
||||
fn use_gang_fixture() -> Composite {
|
||||
let ops = vec![
|
||||
Op::Input { role: "price".into() },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("channel_hi".into()), bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("channel_lo".into()), bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("channel_hi".into()), args: vec![], bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("channel_lo".into()), args: vec![], bind: vec![] },
|
||||
Op::Gang {
|
||||
as_name: "channel_length".into(),
|
||||
into: vec!["channel_hi.length".into(), "channel_lo.length".into()],
|
||||
},
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
|
||||
Op::Feed {
|
||||
role: "price".into(),
|
||||
into: vec!["channel_hi.series".into(), "channel_lo.series".into()],
|
||||
@@ -1505,7 +1712,7 @@ mod tests {
|
||||
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
|
||||
s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] })
|
||||
.unwrap();
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("solo".into()), bind: vec![] })
|
||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("solo".into()), args: vec![], bind: vec![] })
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.apply(Op::Gang {
|
||||
@@ -1527,7 +1734,7 @@ mod tests {
|
||||
fn use_op_connect_closing_a_cycle_through_an_instance_is_rejected_eagerly() {
|
||||
let subgraph = |_: &str| Some(use_fixture());
|
||||
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
|
||||
s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("s".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("s".into()), args: vec![], bind: vec![] }).unwrap();
|
||||
s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] }).unwrap();
|
||||
s.apply(Op::Connect { from: "s.value".into(), to: "gate.price".into() }).unwrap();
|
||||
assert_eq!(
|
||||
@@ -1595,7 +1802,7 @@ mod tests {
|
||||
Op::Add {
|
||||
type_id: "SMA".into(),
|
||||
as_name: Some("sma".into()),
|
||||
bind: vec![("length".into(), Scalar::i64(3))],
|
||||
args: vec![], bind: vec![("length".into(), Scalar::i64(3))],
|
||||
},
|
||||
Op::Feed { role: "x".into(), into: vec!["sma.series".into()] },
|
||||
Op::Expose { from: "sma.value".into(), as_name: "out".into() },
|
||||
|
||||
@@ -52,12 +52,13 @@ mod sweep;
|
||||
mod walkforward;
|
||||
|
||||
pub use blueprint::{
|
||||
BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang, GangFault,
|
||||
GangMember, OutField, RandomBinder, ReopenError, Role, SweepBinder, Tap, TapWire,
|
||||
name_gate, BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang,
|
||||
GangFault, GangMember, NameGateFault, OutField, RandomBinder, ReopenError, Role, SweepBinder,
|
||||
Tap, TapWire,
|
||||
};
|
||||
pub use blueprint_serde::{
|
||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintDoc, CompositeData,
|
||||
LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
|
||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, ArgData, BlueprintDoc,
|
||||
CompositeData, LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
|
||||
};
|
||||
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
|
||||
pub use construction::{replay, GraphSession, Op, OpError};
|
||||
@@ -88,7 +89,8 @@ pub use walkforward::{
|
||||
// (SourceSpec.kind is a ScalarKind; sources/Recorder columns are Scalar /
|
||||
// Firing / Timestamp) so a graph builder has one import surface, not two.
|
||||
pub use aura_core::{
|
||||
BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind, Timestamp,
|
||||
ArgKind, ArgOpError, BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind,
|
||||
Timestamp,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -34,10 +34,10 @@ fn params() -> [Scalar; 3] {
|
||||
fn signal_ops() -> Vec<Op> {
|
||||
vec![
|
||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
|
||||
Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
|
||||
Op::Add { type_id: "Bias".into(), as_name: None, args: vec![], bind: vec![] },
|
||||
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] },
|
||||
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() },
|
||||
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
|
||||
@@ -96,10 +96,10 @@ fn replayed_construction_compiles_identically_to_graphbuilder() {
|
||||
#[test]
|
||||
fn replay_attributes_eager_fault_to_its_op_index() {
|
||||
let ops = vec![
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, // 0 ok
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, // 1 ok
|
||||
Op::Add { type_id: "Nope".into(), as_name: None, bind: vec![] }, // 2 fails
|
||||
Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] }, // 3 never runs
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] }, // 0 ok
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] }, // 1 ok
|
||||
Op::Add { type_id: "Nope".into(), as_name: None, args: vec![], bind: vec![] }, // 2 fails
|
||||
Op::Add { type_id: "Bias".into(), as_name: None, args: vec![], bind: vec![] }, // 3 never runs
|
||||
];
|
||||
// `.err().unwrap()` (not `unwrap_err`): the Ok arm `Composite` holds build
|
||||
// closures and is not `Debug`, which `unwrap_err`'s bound would require.
|
||||
@@ -120,9 +120,9 @@ fn replay_attributes_eager_fault_to_its_op_index() {
|
||||
fn replay_attributes_holistic_fault_to_finalize_step() {
|
||||
let ops = vec![
|
||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 }, // 0
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, // 1
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }, // 2
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, // 3
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] }, // 1
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] }, // 2
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] }, // 3
|
||||
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }, // 4
|
||||
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }, // 5
|
||||
// sub.rhs deliberately left unwired -> only finalize can decide totality.
|
||||
|
||||
@@ -110,7 +110,7 @@ fn build_harness(
|
||||
Resample::builder().schema().clone(), // 0
|
||||
Delay::builder().schema().clone(), // 1
|
||||
Gt::builder().schema().clone(), // 2
|
||||
Session::builder(9, 0, Berlin, 15).schema().clone(), // 3
|
||||
Session::configured(9, 0, Berlin, 15).schema().clone(), // 3
|
||||
EqConst::builder().schema().clone(), // 4
|
||||
EqConst::builder().schema().clone(), // 5
|
||||
And::builder().schema().clone(), // 6
|
||||
|
||||
@@ -21,7 +21,7 @@ fn run_meanrev_bias(closes: &[f64], n: i64, k: f64) -> Vec<f64> {
|
||||
let sq = g.add(Mul::builder()); // dev * dev
|
||||
let var = g.add(Ema::builder().bind("length", Scalar::i64(n))); // EWMA variance
|
||||
let sigma = g.add(Sqrt::builder());
|
||||
let band = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(k))); // k*sigma
|
||||
let band = g.add(LinComb::configured(1).bind("weights[0]", Scalar::f64(k))); // k*sigma
|
||||
let upper = g.add(Add::builder()); // mean + k*sigma
|
||||
let lower = g.add(Sub::builder()); // mean - k*sigma
|
||||
let gt_hi = g.add(Gt::builder()); // price > upper
|
||||
|
||||
@@ -112,7 +112,7 @@ pub fn build_harness() -> (Harness, Taps) {
|
||||
Resample::builder().schema().clone(), // 0
|
||||
Delay::builder().schema().clone(), // 1
|
||||
Gt::builder().schema().clone(), // 2
|
||||
Session::builder(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES)
|
||||
Session::configured(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES)
|
||||
.schema()
|
||||
.clone(), // 3
|
||||
EqConst::builder().schema().clone(), // 4
|
||||
@@ -248,7 +248,7 @@ pub fn ger40_breakout_blueprint(
|
||||
);
|
||||
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1)));
|
||||
let gt = g.add(Gt::builder());
|
||||
let session = g.add(Session::builder(open_hour, open_minute, tz, bar_period_minutes));
|
||||
let session = g.add(Session::configured(open_hour, open_minute, tz, bar_period_minutes));
|
||||
// The ONLY two params: the EqConst targets, named so the space reads
|
||||
// `entry_bar.target` / `exit_bar.target`. Their `target` is left UNBOUND.
|
||||
let entry_bar = g.add(EqConst::builder().named("entry_bar"));
|
||||
|
||||
@@ -33,10 +33,17 @@
|
||||
//! **Cold guard.** If the trigger column is empty (not yet fired) → `None`.
|
||||
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, ScalarKind,
|
||||
ArgKind, ArgSpec, ArgValue, Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
|
||||
PrimitiveBuilder, Scalar, ScalarKind,
|
||||
};
|
||||
use chrono::{TimeZone, Timelike};
|
||||
|
||||
/// The declared construction args of a `Session` recipe (spec §Concrete code
|
||||
/// shapes): the timezone and local open time — structural, non-scalar
|
||||
/// configuration, never a swept param.
|
||||
const SESSION_ARGS: &[ArgSpec] =
|
||||
&[ArgSpec { name: "tz", kind: ArgKind::Tz }, ArgSpec { name: "open", kind: ArgKind::TimeOfDay }];
|
||||
|
||||
/// Frankfurt-session bar indexer. Holds the open offset (minutes past local
|
||||
/// midnight), the IANA timezone, and the bar period — baked at construction,
|
||||
/// never streamed and never a swept param (a timezone is not a scalar).
|
||||
@@ -60,23 +67,57 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The param-generic recipe for a blueprint primitive. The open time,
|
||||
/// timezone, and period are **structural** config baked into the closure
|
||||
/// (like `SimBroker::builder` bakes `pip_size`), NOT scalar params — the
|
||||
/// declared param list is empty and the `build_fn` ignores its slice (like
|
||||
/// `Gt` / `Latch`).
|
||||
pub fn builder(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> PrimitiveBuilder {
|
||||
/// Roster factory: zero-arg, arg-bearing (spec §Concrete code shapes) — the
|
||||
/// timezone and open time come through the `args` channel (`try_args`),
|
||||
/// declared by [`SESSION_ARGS`]. `period_minutes` becomes an ordinary
|
||||
/// `ParamSpec` once `make` runs (a real scalar knob, unlike tz/open).
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::pending(
|
||||
"Session",
|
||||
"bars elapsed since the session open, from the configured open time and timezone",
|
||||
SESSION_ARGS,
|
||||
Self::make,
|
||||
)
|
||||
}
|
||||
|
||||
/// Turn a validated `(tz, open)` value pair into the real signature: one
|
||||
/// `trigger` input, one `bars_since_open` output, and `period_minutes` as
|
||||
/// the sole (now scalar) `ParamSpec` — read from the param slice at build.
|
||||
fn make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
|
||||
let tz = values
|
||||
.iter()
|
||||
.find_map(|(n, v)| match (n.as_str(), v) {
|
||||
("tz", ArgValue::Tz(tz)) => Some(*tz),
|
||||
_ => None,
|
||||
})
|
||||
.expect("try_args validated `tz` as ArgKind::Tz before calling make");
|
||||
let (open_hour, open_minute) = values
|
||||
.iter()
|
||||
.find_map(|(n, v)| match (n.as_str(), v) {
|
||||
("open", ArgValue::TimeOfDay { hour, minute }) => Some((*hour, *minute)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("try_args validated `open` as ArgKind::TimeOfDay before calling make");
|
||||
PrimitiveBuilder::new(
|
||||
"Session",
|
||||
NodeSchema {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }],
|
||||
output: vec![FieldSpec { name: "bars_since_open".into(), kind: ScalarKind::I64 }],
|
||||
params: vec![],
|
||||
params: vec![ParamSpec { name: "period_minutes".into(), kind: ScalarKind::I64 }],
|
||||
doc: "bars elapsed since the session open, from the configured open time and timezone",
|
||||
},
|
||||
move |_| Box::new(Session::new(open_hour, open_minute, tz, period_minutes)),
|
||||
move |p| Box::new(Session::new(open_hour, open_minute, tz, p[0].i64())),
|
||||
)
|
||||
}
|
||||
|
||||
/// Rust-path convenience — the same recipe as the data path (twin
|
||||
/// identity): `builder().try_args([tz, open]) + .bind("period_minutes", …)`.
|
||||
pub fn configured(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> PrimitiveBuilder {
|
||||
Self::builder()
|
||||
.try_args(&[("tz".to_string(), tz.name().to_string()), ("open".to_string(), format!("{open_hour:02}:{open_minute:02}"))])
|
||||
.expect("configured: a valid chrono_tz::Tz and in-range hour/minute always parse")
|
||||
.bind("period_minutes", Scalar::i64(period_minutes))
|
||||
}
|
||||
}
|
||||
|
||||
/// The zero-arg `SessionFrankfurt` roster preset (#261): the Frankfurt open
|
||||
@@ -244,4 +285,36 @@ mod tests {
|
||||
let inputs = trigger_input();
|
||||
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None);
|
||||
}
|
||||
|
||||
/// Twin identity (spec §aura-market): `configured` is exactly
|
||||
/// `builder().try_args([tz, open]).bind("period_minutes", …)` — same
|
||||
/// accepted args, same declared signature, whichever path authored it.
|
||||
#[test]
|
||||
fn session_configured_twin_equals_builder_try_args() {
|
||||
let via_configured = Session::configured(9, 30, Berlin, 15);
|
||||
let via_try_args = Session::builder()
|
||||
.try_args(&[("tz".into(), "Europe/Berlin".into()), ("open".into(), "09:30".into())])
|
||||
.expect("valid tz/open configure")
|
||||
.bind("period_minutes", Scalar::i64(15));
|
||||
assert_eq!(via_configured.construction_args(), via_try_args.construction_args());
|
||||
assert_eq!(via_configured.schema(), via_try_args.schema());
|
||||
}
|
||||
|
||||
/// Behaviour re-anchor (#271 Task 2): the DST-aware headline property
|
||||
/// (local 09:45 reads bar 3 in both CEST summer and CET winter) holds
|
||||
/// identically when the node is built through `configured` instead of
|
||||
/// `Session::new` directly.
|
||||
#[test]
|
||||
fn session_configured_behaviour_unchanged() {
|
||||
let mut node = Session::configured(9, 0, Berlin, 15).build(&[]);
|
||||
let mut inputs = trigger_input();
|
||||
|
||||
let summer = Berlin.with_ymd_and_hms(2024, 7, 1, 9, 45, 0).unwrap().timestamp_nanos_opt().unwrap();
|
||||
inputs[0].push(Scalar::f64(0.0)).unwrap();
|
||||
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(summer))).map(|r| r[0].i64()), Some(3));
|
||||
|
||||
let winter = Berlin.with_ymd_and_hms(2024, 1, 2, 9, 45, 0).unwrap().timestamp_nanos_opt().unwrap();
|
||||
inputs[0].push(Scalar::f64(0.0)).unwrap();
|
||||
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(winter))).map(|r| r[0].i64()), Some(3));
|
||||
}
|
||||
}
|
||||
|
||||
+125
-12
@@ -421,7 +421,18 @@ pub enum RefFault {
|
||||
StrategyNotFound(String),
|
||||
IdentityUnmatched(String),
|
||||
StrategyUnloadable { id: String, error: String },
|
||||
AxisNotInParamSpace { strategy: String, axis: String },
|
||||
AxisNotInParamSpace {
|
||||
strategy: String,
|
||||
axis: String,
|
||||
/// #328 translation aid: stripping ONE leading segment off `axis`
|
||||
/// (the wrapped `<blueprint>.<node>.<param>` shape a stale transcript
|
||||
/// might quote) and finding that remainder in the strategy's
|
||||
/// `param_space()` or `bound_param_space()` — `Some` iff the strip-
|
||||
/// then-hit lands, computed at fault-construction time so the prose
|
||||
/// layer never re-derives it. `None` when no leading segment strips
|
||||
/// to a hit (today's prose stays unchanged, no speculation).
|
||||
raw_candidate: Option<String>,
|
||||
},
|
||||
AxisKindMismatch { strategy: String, axis: String },
|
||||
/// An open param of the resolved strategy is bound by no campaign axis —
|
||||
/// the executor's every-open-knob-required rule, mirrored at validate
|
||||
@@ -523,10 +534,22 @@ impl Registry {
|
||||
});
|
||||
}
|
||||
}
|
||||
None => faults.push(RefFault::AxisNotInParamSpace {
|
||||
strategy: label.clone(),
|
||||
axis: axis.clone(),
|
||||
}),
|
||||
None => {
|
||||
// #328 did-you-mean: strip one leading segment off the
|
||||
// rejected axis (the wrapped-name shape a stale
|
||||
// transcript might quote) and check whether the
|
||||
// remainder is a legal raw axis here — never
|
||||
// speculating when it isn't.
|
||||
let raw_candidate = axis.split_once('.').map(|(_, rest)| rest).filter(|stripped| {
|
||||
space.iter().any(|p| p.name == *stripped)
|
||||
|| bound.iter().any(|b| b.name == *stripped)
|
||||
}).map(str::to_string);
|
||||
faults.push(RefFault::AxisNotInParamSpace {
|
||||
strategy: label.clone(),
|
||||
axis: axis.clone(),
|
||||
raw_candidate,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// The reverse direction: every open param must be bound by some
|
||||
@@ -2154,14 +2177,12 @@ mod tests {
|
||||
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
|
||||
|
||||
// A minimal fixture composite with one OPEN param, built from a
|
||||
// zero-arg `aura-std` node (`Bias`) so `std_vocabulary` can actually
|
||||
// plain `aura-std` node (`Bias`) so `std_vocabulary` can actually
|
||||
// resolve it on load. `aura-composites`' shipped composites (vol_stop,
|
||||
// risk_executor*) all route through `LinComb`, whose builder needs a
|
||||
// structural arity argument and is therefore deliberately absent from
|
||||
// the zero-arg `std_vocabulary` roster (see aura-std/src/vocabulary.rs)
|
||||
// — they cannot round-trip through `blueprint_from_json` with this
|
||||
// resolver, so this fixture stands in as the "real, open-param,
|
||||
// vocabulary-loadable composite" the test needs.
|
||||
// risk_executor*) route through `LinComb`, an arg-bearing type (#271,
|
||||
// `aura-vocabulary/src/lib.rs`) — using `Bias` here keeps this fixture
|
||||
// independent of that construction channel, so it stands in as the
|
||||
// "real, open-param, vocabulary-loadable composite" the test needs.
|
||||
let composite = Composite::new(
|
||||
"fixture",
|
||||
vec![aura_strategy::Bias::builder().named("b").into()],
|
||||
@@ -2253,6 +2274,98 @@ mod tests {
|
||||
assert_eq!(reg.validate_campaign_refs(&by_identity, &resolve).expect("io ok"), Vec::new());
|
||||
}
|
||||
|
||||
/// #328: a rejected axis shaped like a stale wrapped transcript (one
|
||||
/// bogus leading segment glued in front of a real raw param name) carries
|
||||
/// that real name as `AxisNotInParamSpace::raw_candidate` — computed at
|
||||
/// fault-construction time in `validate_campaign_refs`, never re-derived
|
||||
/// by the prose layer.
|
||||
#[test]
|
||||
fn axis_not_in_param_space_carries_a_raw_candidate_when_strippable() {
|
||||
use aura_engine::blueprint_to_json;
|
||||
|
||||
let reg = Registry::open(temp_family_dir("axis_raw_candidate_hit"));
|
||||
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
|
||||
let composite = bias_fixture();
|
||||
let real = composite.param_space().first().expect("fixture has an open param").clone();
|
||||
let real_name = real.name.clone();
|
||||
|
||||
let blueprint_json = blueprint_to_json(&composite).expect("serializes");
|
||||
let bp_id = aura_research::content_id_of(&blueprint_json);
|
||||
reg.put_blueprint(&bp_id, &blueprint_json).expect("seed blueprint");
|
||||
let process = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#;
|
||||
let proc_id = reg.put_process(process).expect("seed process");
|
||||
|
||||
let wrapped_axis = format!("graph.{real_name}");
|
||||
let campaign_text = format!(
|
||||
concat!(
|
||||
r#"{{"format_version":1,"kind":"campaign","name":"c","#,
|
||||
r#""data":{{"instruments":["GER40"],"windows":[{{"from_ms":1,"to_ms":2}}]}},"#,
|
||||
r#""strategies":[{{"ref":{{"content_id":"{bp}"}},"#,
|
||||
r#""axes":{{"{axis}":{{"kind":"F64","values":[0.5]}}}}}}],"#,
|
||||
r#""process":{{"ref":{{"content_id":"{proc}"}}}},"#,
|
||||
r#""seed":1,"presentation":{{"persist_taps":[],"emit":["family_table"]}}}}"#
|
||||
),
|
||||
bp = bp_id,
|
||||
axis = wrapped_axis,
|
||||
proc = proc_id,
|
||||
);
|
||||
let doc = aura_research::parse_campaign(&campaign_text).expect("campaign parses");
|
||||
let faults = reg.validate_campaign_refs(&doc, &resolve).expect("io ok");
|
||||
let hit = faults
|
||||
.iter()
|
||||
.find(|f| matches!(f, RefFault::AxisNotInParamSpace { axis, .. } if axis == &wrapped_axis));
|
||||
match hit {
|
||||
Some(RefFault::AxisNotInParamSpace { raw_candidate, .. }) => {
|
||||
assert_eq!(raw_candidate.as_deref(), Some(real_name.as_str()));
|
||||
}
|
||||
other => panic!("expected AxisNotInParamSpace for {wrapped_axis:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// #328 (negative): a rejected axis whose stripped remainder ALSO misses
|
||||
/// the param space carries no `raw_candidate` — the fault never
|
||||
/// speculates when stripping one leading segment lands nowhere.
|
||||
#[test]
|
||||
fn axis_not_in_param_space_carries_no_candidate_when_stripped_remainder_also_misses() {
|
||||
use aura_engine::blueprint_to_json;
|
||||
|
||||
let reg = Registry::open(temp_family_dir("axis_raw_candidate_miss"));
|
||||
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
|
||||
let composite = bias_fixture();
|
||||
|
||||
let blueprint_json = blueprint_to_json(&composite).expect("serializes");
|
||||
let bp_id = aura_research::content_id_of(&blueprint_json);
|
||||
reg.put_blueprint(&bp_id, &blueprint_json).expect("seed blueprint");
|
||||
let process = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#;
|
||||
let proc_id = reg.put_process(process).expect("seed process");
|
||||
|
||||
let bogus_axis = "bogus.alsobogus";
|
||||
let campaign_text = format!(
|
||||
concat!(
|
||||
r#"{{"format_version":1,"kind":"campaign","name":"c","#,
|
||||
r#""data":{{"instruments":["GER40"],"windows":[{{"from_ms":1,"to_ms":2}}]}},"#,
|
||||
r#""strategies":[{{"ref":{{"content_id":"{bp}"}},"#,
|
||||
r#""axes":{{"{axis}":{{"kind":"F64","values":[0.5]}}}}}}],"#,
|
||||
r#""process":{{"ref":{{"content_id":"{proc}"}}}},"#,
|
||||
r#""seed":1,"presentation":{{"persist_taps":[],"emit":["family_table"]}}}}"#
|
||||
),
|
||||
bp = bp_id,
|
||||
axis = bogus_axis,
|
||||
proc = proc_id,
|
||||
);
|
||||
let doc = aura_research::parse_campaign(&campaign_text).expect("campaign parses");
|
||||
let faults = reg.validate_campaign_refs(&doc, &resolve).expect("io ok");
|
||||
let hit = faults
|
||||
.iter()
|
||||
.find(|f| matches!(f, RefFault::AxisNotInParamSpace { axis, .. } if axis == bogus_axis));
|
||||
match hit {
|
||||
Some(RefFault::AxisNotInParamSpace { raw_candidate, .. }) => {
|
||||
assert_eq!(raw_candidate, &None);
|
||||
}
|
||||
other => panic!("expected AxisNotInParamSpace for {bogus_axis:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Property (#191): the identity-index sidecar is a last-line-wins cache
|
||||
/// over identity id → content id — a missing file reads as empty (no
|
||||
/// error, since the scan is the truth path), a later append for the same
|
||||
|
||||
@@ -45,6 +45,75 @@ pub fn raw_matches_wrapped(raw: &str, wrapped: &str) -> bool {
|
||||
wrapped == raw || wrapped_to_raw_axis(wrapped) == raw
|
||||
}
|
||||
|
||||
/// (a) #328: the explicit `--axis` acceptance predicate, shared by BOTH
|
||||
/// intake routes (`validate_and_register_axes`'s real route and the synthetic
|
||||
/// sweep-verb intake ahead of `run_blueprint_sweep`). RAW is checked FIRST and
|
||||
/// independently — a legal RAW name (an open param's [`wrapped_to_raw_axis`]
|
||||
/// suffix, or a bound param's own already-raw name, #203) is accepted
|
||||
/// outright, so a pathological name that is both raw-legal and wrapped-exact
|
||||
/// (e.g. an unwrapped param) resolves as raw, never as a translation refusal.
|
||||
/// Only once that fails is a WRAPPED hit considered (an exact `param_space()`
|
||||
/// name, or one leading segment stripped off `name` landing on a bound
|
||||
/// param): that is a translation refusal naming the raw candidate, never a
|
||||
/// silent alias. Anything matching neither namespace is [`AxisIntake::Unknown`]
|
||||
/// — the caller's own unmatched-axis prose applies, unchanged.
|
||||
///
|
||||
/// Deliberately NOT built on [`raw_matches_wrapped`]: that predicate's own
|
||||
/// equality branch also matches an EXACT wrapped name (see its doc comment),
|
||||
/// so using it as the acceptance gate would silently accept the retired form
|
||||
/// instead of refusing it.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum AxisIntake {
|
||||
/// A legal RAW name (`--list-axes`'s own namespace, #328) — accept as-is.
|
||||
Raw,
|
||||
/// A retired WRAPPED name; the carried string is its translated RAW
|
||||
/// candidate — never applied silently, only ever surfaced in a refusal.
|
||||
WrappedRetired(String),
|
||||
/// Neither namespace: the caller's own unknown-axis prose applies.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Classify one `--axis` name against `wrapped_open` (the WRAPPED open-param
|
||||
/// probe, `blueprint_axis_probe(..).param_space()`) and `raw_bound` (the
|
||||
/// strategy's own `bound_param_space()` names — already RAW, #203). See
|
||||
/// [`AxisIntake`] for the precedence.
|
||||
pub fn classify_axis_intake(
|
||||
name: &str,
|
||||
wrapped_open: &[ParamSpec],
|
||||
raw_bound: &HashSet<String>,
|
||||
) -> AxisIntake {
|
||||
let is_raw = wrapped_open.iter().any(|p| wrapped_to_raw_axis(&p.name) == name)
|
||||
|| raw_bound.contains(name);
|
||||
if is_raw {
|
||||
return AxisIntake::Raw;
|
||||
}
|
||||
let is_wrapped = wrapped_open.iter().any(|p| p.name == name)
|
||||
|| raw_bound.iter().any(|b| name.split_once('.').map(|(_, rest)| rest) == Some(b.as_str()));
|
||||
if is_wrapped {
|
||||
AxisIntake::WrappedRetired(wrapped_to_raw_axis(name).to_string())
|
||||
} else {
|
||||
AxisIntake::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate one RAW axis name onto the ONE wrapped `space` slot it
|
||||
/// suffix-matches ([`raw_matches_wrapped`]), falling back to `raw` unchanged
|
||||
/// when no wrapped slot matches — the caller's own downstream name
|
||||
/// resolution (e.g. the sweep terminal's `UnknownKnob`) surfaces that case,
|
||||
/// never panicked here. #328: the shared translation `blueprint_sweep_family`
|
||||
/// established for its `SweepBinder::axis` call, extracted so
|
||||
/// `blueprint_sweep_over`/`validate_axis_grid` (the walkforward synthetic
|
||||
/// route's own binder calls, which key by the same exact wrapped
|
||||
/// `param_space()` name) can translate a RAW `--axis` name too instead of
|
||||
/// requiring the retired wrapped form.
|
||||
pub fn wrapped_name_of(raw: &str, space: &[ParamSpec]) -> String {
|
||||
space
|
||||
.iter()
|
||||
.find(|p| raw_matches_wrapped(raw, &p.name))
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| raw.to_string())
|
||||
}
|
||||
|
||||
/// Suffix-join each raw campaign axis onto exactly one wrapped param
|
||||
/// ([`raw_matches_wrapped`] — wrapped == raw, or stripping the wrapper's one
|
||||
/// node segment yields raw), then require every wrapped slot to be covered.
|
||||
@@ -139,6 +208,53 @@ mod tests {
|
||||
ParamSpec { name: name.to_string(), kind: ScalarKind::I64 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #328: a legal RAW name — either an open param's raw suffix or a bound
|
||||
/// param's own (already-raw) name — classifies `Raw`, accepted as-is.
|
||||
fn classify_axis_intake_accepts_a_raw_open_or_bound_name() {
|
||||
let wrapped_open = vec![spec("sma_signal.fast.length")];
|
||||
let raw_bound: HashSet<String> = ["bias.scale".to_string()].into_iter().collect();
|
||||
assert_eq!(classify_axis_intake("fast.length", &wrapped_open, &raw_bound), AxisIntake::Raw);
|
||||
assert_eq!(classify_axis_intake("bias.scale", &wrapped_open, &raw_bound), AxisIntake::Raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #328: a WRAPPED name — the exact `param_space()` string, or a bound
|
||||
/// param prefixed by one wrap segment — classifies `WrappedRetired`,
|
||||
/// carrying its raw candidate, never accepted silently.
|
||||
fn classify_axis_intake_flags_a_wrapped_name_with_its_raw_candidate() {
|
||||
let wrapped_open = vec![spec("sma_signal.fast.length")];
|
||||
let raw_bound: HashSet<String> = ["bias.scale".to_string()].into_iter().collect();
|
||||
assert_eq!(
|
||||
classify_axis_intake("sma_signal.fast.length", &wrapped_open, &raw_bound),
|
||||
AxisIntake::WrappedRetired("fast.length".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
classify_axis_intake("sma_signal.bias.scale", &wrapped_open, &raw_bound),
|
||||
AxisIntake::WrappedRetired("bias.scale".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #328: a name matching neither namespace is `Unknown` — the caller's own
|
||||
/// unmatched-axis prose applies, unchanged by this predicate.
|
||||
fn classify_axis_intake_reports_unknown_for_neither_space() {
|
||||
let wrapped_open = vec![spec("sma_signal.fast.length")];
|
||||
let raw_bound: HashSet<String> = ["bias.scale".to_string()].into_iter().collect();
|
||||
assert_eq!(classify_axis_intake("nope", &wrapped_open, &raw_bound), AxisIntake::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #328: raw is checked FIRST — an unwrapped (no-dot) param whose raw name
|
||||
/// happens to equal its own wrapped `param_space()` string (a root-level
|
||||
/// knob with no node-path prefix) resolves as `Raw`, never misclassified
|
||||
/// as a wrapped-exact hit needing translation.
|
||||
fn classify_axis_intake_prefers_raw_when_a_name_is_both() {
|
||||
let wrapped_open = vec![spec("length")]; // no dot: raw == wrapped
|
||||
let raw_bound: HashSet<String> = HashSet::new();
|
||||
assert_eq!(classify_axis_intake("length", &wrapped_open, &raw_bound), AxisIntake::Raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The only shape treated as a direct store address is a bare 64-char
|
||||
/// lowercase-hex token; anything else (wrong length, uppercase, non-hex)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Coverage reporting: the single interior-gap-month walk, shared by a
|
||||
//! campaign cell's coverage annotation
|
||||
//! (`DefaultMemberRunner::window_coverage`) and `aura data coverage`'s
|
||||
//! archive-wide report — two independent walk implementations before this
|
||||
//! module existed (#295 dedup).
|
||||
//! The interior-gap-month walk behind a campaign cell's coverage annotation
|
||||
//! (`DefaultMemberRunner::window_coverage`) — originally shared with `aura
|
||||
//! data coverage`'s archive-wide report (#295 dedup: two independent walk
|
||||
//! implementations before this module existed) before that verb retired in
|
||||
//! favor of per-cell fault isolation (#272, #273).
|
||||
|
||||
/// `"YYYY-MM"` rendering of a `(year, month)` pair.
|
||||
pub fn fmt_year_month((y, m): (u16, u8)) -> String {
|
||||
@@ -18,11 +18,11 @@ pub fn next_year_month((y, m): (u16, u8)) -> (u16, u8) {
|
||||
/// fall inside `window_ms` (Unix-ms) — one `"YYYY-MM"` entry per missing
|
||||
/// month, never a collapsed range: the registry's `CellCoverage::gap_months`
|
||||
/// is a flat list an aggregate counts directly. `months` is assumed sorted
|
||||
/// ([`aura_ingest::list_m1_months`]'s own contract). The single gap-walk
|
||||
/// implementation (#295): both `DefaultMemberRunner::window_coverage`
|
||||
/// (a swept cell's own window) and [`data_coverage_report`] (the full
|
||||
/// archive span, via [`FULL_ARCHIVE_WINDOW_MS`]) call this one walk instead
|
||||
/// of maintaining independent copies.
|
||||
/// ([`aura_ingest::list_m1_months`]'s own contract). `DefaultMemberRunner::
|
||||
/// window_coverage` (a swept cell's own window) is the sole caller since
|
||||
/// `aura data coverage`'s archive-wide report retired (#273); the walk stays
|
||||
/// its own function because it was single-sourced with that verb before then
|
||||
/// (#295).
|
||||
pub fn interior_gap_months(months: &[(u16, u8)], window_ms: (i64, i64)) -> Vec<String> {
|
||||
let from_ym = data_server::records::unix_ms_to_year_month(window_ms.0);
|
||||
let to_ym = data_server::records::unix_ms_to_year_month(window_ms.1);
|
||||
@@ -40,68 +40,6 @@ pub fn interior_gap_months(months: &[(u16, u8)], window_ms: (i64, i64)) -> Vec<S
|
||||
out
|
||||
}
|
||||
|
||||
/// A Unix-ms window that decodes (via `unix_ms_to_year_month`) to year/month
|
||||
/// bounds — 0001-01 and 9999-01, exact epoch-ms for those UTC instants, not
|
||||
/// an approximation — comfortably outside any realistic archive month, so an
|
||||
/// unbounded, archive-wide [`interior_gap_months`] walk never ms-filters out
|
||||
/// a real gap. Safely inside `chrono`'s valid calendar range, so the
|
||||
/// conversion inside `interior_gap_months` never panics.
|
||||
pub const FULL_ARCHIVE_WINDOW_MS: (i64, i64) = (-62_135_596_800_000, 253_370_764_800_000);
|
||||
|
||||
/// `aura data coverage <SYMBOL>`'s pure report body (#264): render `symbol`'s
|
||||
/// coverage report from its sorted `(year, month)` file list — one `span:`
|
||||
/// line framing the first/last present month, then either a `no gaps` line
|
||||
/// or one `missing: YYYY-MM..YYYY-MM` line per interior contiguous gap (a
|
||||
/// ten-month hole is one line, not ten). `Err` exactly when `months` is
|
||||
/// empty — no archive file exists for `symbol` at all (the caller's "unknown
|
||||
/// symbol" refusal, stderr + exit 1). The gap detection itself is
|
||||
/// [`interior_gap_months`] over the full archive span
|
||||
/// ([`FULL_ARCHIVE_WINDOW_MS`]); collapsing its flat per-month list into
|
||||
/// contiguous ranges is presentation-only regrouping, done here since it
|
||||
/// stays byte-identical to the pre-dedup report.
|
||||
pub fn data_coverage_report(symbol: &str, months: &[(u16, u8)]) -> Result<Vec<String>, String> {
|
||||
let Some(&first) = months.first() else {
|
||||
return Err(format!("no archive files found for symbol \"{symbol}\""));
|
||||
};
|
||||
let last = *months.last().expect("non-empty checked above");
|
||||
let mut lines =
|
||||
vec![format!("{symbol} span: {}..{}", fmt_year_month(first), fmt_year_month(last))];
|
||||
let gap_months = interior_gap_months(months, FULL_ARCHIVE_WINDOW_MS);
|
||||
if gap_months.is_empty() {
|
||||
lines.push(format!("{symbol} no gaps"));
|
||||
} else {
|
||||
for (from, to) in collapse_contiguous_year_months(&gap_months) {
|
||||
lines.push(format!("{symbol} missing: {from}..{to}"));
|
||||
}
|
||||
}
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
/// Collapse [`interior_gap_months`]' flat, ascending `"YYYY-MM"` list into
|
||||
/// contiguous inclusive `(from, to)` ranges — a ten-month hole collapses to
|
||||
/// one pair, not ten. Presentation-only regrouping: the gap WALK itself
|
||||
/// already ran in `interior_gap_months`.
|
||||
fn collapse_contiguous_year_months(months: &[String]) -> Vec<(String, String)> {
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
for m in months {
|
||||
let ym = parse_year_month(m);
|
||||
match out.last_mut() {
|
||||
Some((_, to)) if next_year_month(parse_year_month(to)) == ym => {
|
||||
*to = m.clone();
|
||||
}
|
||||
_ => out.push((m.clone(), m.clone())),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Inverse of [`fmt_year_month`] — parses back the `"YYYY-MM"` shape
|
||||
/// [`interior_gap_months`] always emits.
|
||||
fn parse_year_month(s: &str) -> (u16, u8) {
|
||||
let (y, m) = s.split_once('-').expect("interior_gap_months emits YYYY-MM");
|
||||
(y.parse().expect("YYYY digits"), m.parse().expect("MM digits"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -125,37 +63,4 @@ mod tests {
|
||||
"both interior gap months must be named, in order"
|
||||
);
|
||||
}
|
||||
|
||||
/// An unknown symbol (no archive files at all — an empty month list)
|
||||
/// refuses rather than reporting a bogus empty-span coverage (#264): the
|
||||
/// caller eprintln's the message and exits 1.
|
||||
#[test]
|
||||
fn data_coverage_report_refuses_an_unknown_symbol() {
|
||||
let err = data_coverage_report("GHOST", &[]).unwrap_err();
|
||||
assert!(err.contains("GHOST"), "names the unknown symbol: {err}");
|
||||
}
|
||||
|
||||
/// A symbol with a fully contiguous file index reports its span plus an
|
||||
/// explicit `no gaps` line — never a bare span with no gap-status line at
|
||||
/// all, which would leave "no gaps" indistinguishable from "gaps not yet
|
||||
/// checked" (#264).
|
||||
#[test]
|
||||
fn data_coverage_report_of_a_gapless_symbol_is_span_plus_no_gaps() {
|
||||
let months = [(2024, 1), (2024, 2), (2024, 3)];
|
||||
let lines = data_coverage_report("SYMA", &months).expect("known symbol");
|
||||
assert_eq!(lines, vec!["SYMA span: 2024-01..2024-03", "SYMA no gaps"]);
|
||||
}
|
||||
|
||||
/// The Copper failure shape itself: one interior gap collapses to a single
|
||||
/// `missing: YYYY-MM..YYYY-MM` line naming the whole contiguous hole, not
|
||||
/// one line per missing month (#264).
|
||||
#[test]
|
||||
fn data_coverage_report_collapses_an_interior_gap_to_one_range_line() {
|
||||
let months = [(2024, 1), (2024, 2), (2024, 5), (2024, 6)];
|
||||
let lines = data_coverage_report("GAPSYM", &months).expect("known symbol");
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec!["GAPSYM span: 2024-01..2024-06", "GAPSYM missing: 2024-03..2024-04"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,16 +254,30 @@ const DEFLATION_SEED: u64 = 0xDEF1_A7ED;
|
||||
/// fully-prosed message string (wrapped from `override_paths`' own
|
||||
/// `Result<_, String>`) — unwrapped here rather than Debug-framed (#269), so
|
||||
/// the walkforward path's rejection reaches stderr as bare prose too.
|
||||
///
|
||||
/// #328 render-seam fix: `BindError::MissingKnob`/`KindMismatch` carry the
|
||||
/// WRAPPED knob name (the terminal's own bind-time frame — untouched by this
|
||||
/// cycle, per the minuted deviation on acceptance-criterion 2). No
|
||||
/// user-facing surface may print a wrapped axis name, so this renderer strips
|
||||
/// the one leading node segment (`wrapped_to_raw_axis`, the same convention
|
||||
/// `axes.rs` defines) before the name ever reaches prose — the fix lives at
|
||||
/// the RENDER seam only, not in the error type or the resolution machinery.
|
||||
pub fn render_bind_error(e: &BindError) -> String {
|
||||
match e {
|
||||
BindError::MissingKnob(name) => format!(
|
||||
"axis {name}: an open param with no axis and no bound default — \
|
||||
bind it with `--axis {name}=<value>` — see `aura sweep <bp> --list-axes`"
|
||||
),
|
||||
BindError::KindMismatch { knob, expected, got } => format!(
|
||||
"axis {knob}: expected {expected:?}, supplied {got:?} — \
|
||||
see `aura sweep <bp> --list-axes`"
|
||||
),
|
||||
BindError::MissingKnob(name) => {
|
||||
let name = crate::axes::wrapped_to_raw_axis(name);
|
||||
format!(
|
||||
"axis {name}: an open param with no axis and no bound default — \
|
||||
bind it with `--axis {name}=<value>` — see `aura sweep <bp> --list-axes`"
|
||||
)
|
||||
}
|
||||
BindError::KindMismatch { knob, expected, got } => {
|
||||
let knob = crate::axes::wrapped_to_raw_axis(knob);
|
||||
format!(
|
||||
"axis {knob}: expected {expected:?}, supplied {got:?} — \
|
||||
see `aura sweep <bp> --list-axes`"
|
||||
)
|
||||
}
|
||||
BindError::UnknownKnob(msg) => msg.clone(),
|
||||
BindError::DuplicateBinding(name) => format!(
|
||||
"axis {name}: bound twice — each param takes exactly one axis — \
|
||||
@@ -394,11 +408,17 @@ fn validate_axis_grid(
|
||||
) -> Result<(), BindError> {
|
||||
let overrides = override_paths(axes, raw_space, probe_signal).map_err(BindError::UnknownKnob)?;
|
||||
let probe = blueprint_axis_probe_reopened(doc, env, &overrides);
|
||||
let space = probe.param_space();
|
||||
// #328: `axes` names are RAW (refused at the walkforward dispatch-boundary
|
||||
// preflight ahead of this call, mirroring the plain sweep verb) — translate
|
||||
// each onto the ONE wrapped `space` slot it suffix-matches
|
||||
// (`wrapped_name_of`, the same convention `blueprint_sweep_family` feeds its
|
||||
// own `SweepBinder`) before this terminal's exact-name resolution.
|
||||
let mut iter = axes.iter();
|
||||
let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis");
|
||||
let mut binder = probe.axis(first_name, first_vals.clone());
|
||||
let mut binder = probe.axis(&crate::axes::wrapped_name_of(first_name, &space), first_vals.clone());
|
||||
for (n, vals) in iter {
|
||||
binder = binder.axis(n, vals.clone());
|
||||
binder = binder.axis(&crate::axes::wrapped_name_of(n, &space), vals.clone());
|
||||
}
|
||||
binder.sweep_with_lattice(|_| axis_grid_probe_report()).map(|_| ())
|
||||
}
|
||||
@@ -463,15 +483,38 @@ pub fn blueprint_sweep_family(
|
||||
&overrides,
|
||||
)
|
||||
};
|
||||
// seed the named axes verbatim: the first via Composite::axis (consumes the probe),
|
||||
// the rest via SweepBinder::axis. resolve_axes name- and kind-checks them at the
|
||||
// sweep terminal, so an UnknownKnob / KindMismatch is returned, not panicked.
|
||||
// seed the named axes: `axes` names are RAW (#328 — refused at intake before
|
||||
// this fn ever runs), so each is translated to the ONE wrapped `space` slot
|
||||
// it suffix-matches (`raw_matches_wrapped`, the same convention
|
||||
// `override_paths` just resolved the override set through) before feeding
|
||||
// `Composite::axis`/`SweepBinder::axis`, which still key by the exact
|
||||
// wrapped `param_space()` name. `resolve_axes` name- and kind-checks the
|
||||
// translated axes at the sweep terminal, so an UnknownKnob / KindMismatch
|
||||
// is returned, not panicked (translation cannot itself introduce one: every
|
||||
// incoming name was already validated raw-or-bound at the intake / override
|
||||
// preflight above).
|
||||
let wrapped_name_of = |raw: &str| -> String {
|
||||
space
|
||||
.iter()
|
||||
.find(|p| crate::axes::raw_matches_wrapped(raw, &p.name))
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| raw.to_string())
|
||||
};
|
||||
let mut iter = axes.iter();
|
||||
let (first_name, first_vals) = iter.next().expect("a blueprint sweep declares >= 1 axis");
|
||||
let mut binder = probe.axis(first_name, first_vals.clone());
|
||||
let mut binder = probe.axis(&wrapped_name_of(first_name), first_vals.clone());
|
||||
for (n, vals) in iter {
|
||||
binder = binder.axis(n, vals.clone());
|
||||
binder = binder.axis(&wrapped_name_of(n), vals.clone());
|
||||
}
|
||||
// manifest.params records RAW names (#328): a name-only reshaping of `space`
|
||||
// (order/kind untouched) fed to `run_blueprint_member`, which only zips it
|
||||
// against `point` BY POSITION (`zip_params`) — never re-resolves by name —
|
||||
// so renaming here is purely the manifest's own namespace, with no effect
|
||||
// on member resolution.
|
||||
let manifest_space: Vec<ParamSpec> = space
|
||||
.iter()
|
||||
.map(|p| ParamSpec { name: crate::axes::wrapped_to_raw_axis(&p.name).to_string(), kind: p.kind })
|
||||
.collect();
|
||||
// #278: `run_blueprint_member` runs bare here (no #272 fault boundary of its
|
||||
// own), so a member-compile panic (e.g. an `Sma::new` length assert) would
|
||||
// otherwise unwind straight through this sweep to an uncaught exit 101 —
|
||||
@@ -485,7 +528,7 @@ pub fn blueprint_sweep_family(
|
||||
// fresh per-member graph (Composite is !Clone, reload per member) run through
|
||||
// the shared reduce-mode member path — the same fn reproduction re-runs.
|
||||
match catch_member_panic(|| {
|
||||
run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT)
|
||||
run_blueprint_member(reload(doc), point, &manifest_space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT)
|
||||
}) {
|
||||
Ok(report) => report,
|
||||
Err(msg) => {
|
||||
@@ -545,11 +588,16 @@ pub fn blueprint_sweep_over(
|
||||
let overrides = override_paths(axes, &raw_space, &probe_signal).map_err(BindError::UnknownKnob)?;
|
||||
let probe = blueprint_axis_probe_reopened(doc, env, &overrides);
|
||||
let space = probe.param_space();
|
||||
// #328: `axes` names are RAW (refused at the walkforward dispatch-boundary
|
||||
// preflight ahead of the caller, mirroring the plain sweep verb) —
|
||||
// translate each onto the ONE wrapped `space` slot it suffix-matches
|
||||
// (`wrapped_name_of`, the same convention `blueprint_sweep_family` feeds
|
||||
// its own `SweepBinder`) before this terminal's exact-name resolution.
|
||||
let mut iter = axes.iter();
|
||||
let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis");
|
||||
let mut binder = probe.axis(first_name, first_vals.clone());
|
||||
let mut binder = probe.axis(&crate::axes::wrapped_name_of(first_name, &space), first_vals.clone());
|
||||
for (n, vals) in iter {
|
||||
binder = binder.axis(n, vals.clone());
|
||||
binder = binder.axis(&crate::axes::wrapped_name_of(n, &space), vals.clone());
|
||||
}
|
||||
let faults: Mutex<Vec<(Vec<Cell>, String)>> = Mutex::new(Vec::new());
|
||||
let (family, lattice) = binder.sweep_with_lattice(|point| {
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::collections::BTreeMap;
|
||||
use aura_core::{Scalar, Timestamp};
|
||||
use aura_engine::{Composite, Harness, MeasurementReport, RunManifest};
|
||||
|
||||
use crate::member::{key_supply, resolve_run_data, wrapped_bound_defaults, RunData};
|
||||
use crate::member::{key_supply, raw_bound_defaults, resolve_run_data, RunData};
|
||||
use crate::project::Env;
|
||||
use crate::tap_plan::{bind_tap_plan, TapPlan};
|
||||
|
||||
@@ -79,7 +79,7 @@ pub fn run_measurement(
|
||||
std::process::exit(1);
|
||||
}
|
||||
let names: Vec<String> = signal.param_space().iter().map(|p| p.name.clone()).collect();
|
||||
let defaults = wrapped_bound_defaults(&signal);
|
||||
let defaults = raw_bound_defaults(&signal);
|
||||
let (sources, window, _pip_size) = resolve_run_data(&data, env, &binding);
|
||||
|
||||
// Compile the signal DIRECTLY — no wrap_r, no broker/executor/eq-ex-r sinks.
|
||||
|
||||
@@ -82,22 +82,20 @@ pub fn sim_optimal_manifest(
|
||||
}
|
||||
}
|
||||
|
||||
/// The wrap-prefixed BOUND-param defaults of `signal` (#249): every
|
||||
/// `bound_param_space()` entry as a `(<signal.name()>.<param>, value)` pair, in
|
||||
/// `bound_param_space()` order — the `RunManifest.defaults` field. Must be read
|
||||
/// off `signal` BEFORE it is consumed by `wrap_r`/reopening: an axis-reopened
|
||||
/// bound param has already left `bound_param_space()` by construction
|
||||
/// (`Composite::reopen` forgets the bound value), so a caller that reopens
|
||||
/// overrides before calling this naturally excludes them — no separate filter
|
||||
/// needed. Same prefixing rule as `wrapped_bound_names`, kept separate because
|
||||
/// that helper discards the value this one needs.
|
||||
pub fn wrapped_bound_defaults(signal: &Composite) -> Vec<(String, Scalar)> {
|
||||
let prefix = format!("{}.", signal.name());
|
||||
signal
|
||||
.bound_param_space()
|
||||
.into_iter()
|
||||
.map(|b| (format!("{prefix}{}", b.name), b.value))
|
||||
.collect()
|
||||
/// The RAW-namespace BOUND-param defaults of `signal` (#249/#328): every
|
||||
/// `bound_param_space()` entry as a `(<param>, value)` pair, already RAW
|
||||
/// (`bound_param_space()`'s own path-qualified name, #203 — no wrap-prefix
|
||||
/// concatenation), in `bound_param_space()` order — the `RunManifest.defaults`
|
||||
/// field. Must be read off `signal` BEFORE it is consumed by
|
||||
/// `wrap_r`/reopening: an axis-reopened bound param has already left
|
||||
/// `bound_param_space()` by construction (`Composite::reopen` forgets the
|
||||
/// bound value), so a caller that reopens overrides before calling this
|
||||
/// naturally excludes them — no separate filter needed. Contrast
|
||||
/// `wrapped_bound_names`, which DOES wrap-prefix (it matches against the
|
||||
/// WRAPPED open `param_space()`, a different coordinate system than this
|
||||
/// manifest-recording helper needs).
|
||||
pub fn raw_bound_defaults(signal: &Composite) -> Vec<(String, Scalar)> {
|
||||
signal.bound_param_space().into_iter().map(|b| (b.name, b.value)).collect()
|
||||
}
|
||||
|
||||
/// The honest broker label for the dual-tap r-sma harness: it runs a RiskExecutor
|
||||
@@ -327,7 +325,7 @@ pub fn wrap_r(
|
||||
if !reduce {
|
||||
// r_equity = cum_realized_r + unrealized_r — one tapped series for charting.
|
||||
let r_equity = g.add(
|
||||
LinComb::builder(2)
|
||||
LinComb::configured(2)
|
||||
.bind("weights[0]", Scalar::f64(1.0))
|
||||
.bind("weights[1]", Scalar::f64(1.0)),
|
||||
);
|
||||
@@ -411,7 +409,7 @@ pub fn wrap_r(
|
||||
// net_r_equity = cum_realized_r + unrealized_r − Σcum_cost_in_r
|
||||
// − Σopen_cost_in_r (the #221-deleted LinComb(4), weights 1,1,-1,-1).
|
||||
let net_eq = g.add(
|
||||
LinComb::builder(4)
|
||||
LinComb::configured(4)
|
||||
.bind("weights[0]", Scalar::f64(1.0))
|
||||
.bind("weights[1]", Scalar::f64(1.0))
|
||||
.bind("weights[2]", Scalar::f64(-1.0))
|
||||
@@ -506,7 +504,7 @@ pub fn run_signal_r(
|
||||
.iter()
|
||||
.map(|p| p.name.clone())
|
||||
.collect();
|
||||
let defaults = wrapped_bound_defaults(&signal); // read before `signal` is consumed below
|
||||
let defaults = raw_bound_defaults(&signal); // read before `signal` is consumed below
|
||||
let (tx_eq, rx_eq) = mpsc::channel();
|
||||
let (tx_ex, rx_ex) = mpsc::channel();
|
||||
let (tx_r, rx_r) = mpsc::channel();
|
||||
@@ -580,7 +578,7 @@ pub fn run_blueprint_member(
|
||||
cost: &[aura_research::CostSpec],
|
||||
instrument: &str,
|
||||
) -> RunReport {
|
||||
let defaults = wrapped_bound_defaults(&signal); // read before `signal` is consumed below
|
||||
let defaults = raw_bound_defaults(&signal); // read before `signal` is consumed below
|
||||
let (tx_eq, rx_eq) = mpsc::channel();
|
||||
let (tx_ex, rx_ex) = mpsc::channel();
|
||||
let (tx_r, rx_r) = mpsc::channel();
|
||||
@@ -706,55 +704,72 @@ pub fn wrapped_bound_names(signal: &Composite) -> HashSet<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The override subset of `names` (#246): every WRAPPED-coordinate name
|
||||
/// missing the un-reopened wrapped OPEN space but naming a BOUND param of the
|
||||
/// strategy — returned in STRATEGY coordinates (the wrap prefix
|
||||
/// `<signal.name()>.` stripped) for `Composite::reopen`. Names matching
|
||||
/// neither space are skipped here; the caller decides whether that is an
|
||||
/// error (sweep boundary) or falls through to its existing resolution
|
||||
/// errors. The silent variant `reproduce_family_in` uses over the RECORDED
|
||||
/// manifest param names (the sweep boundary uses the stricter
|
||||
/// `override_paths`, which errors on an unmatched name instead). Contrast
|
||||
/// `axes::raw_bound_overrides_of`, whose `names` are already RAW (a campaign
|
||||
/// document's own namespace, #203) and needs no such stripping.
|
||||
/// The override subset of `names` (#246): every name missing the un-reopened
|
||||
/// wrapped OPEN space but naming a BOUND param of the strategy — returned in
|
||||
/// STRATEGY coordinates (the wrap segment stripped) for `Composite::reopen`.
|
||||
/// Names matching neither space are skipped here; the caller decides whether
|
||||
/// that is an error (sweep boundary) or falls through to its existing
|
||||
/// resolution errors. The silent variant `reproduce_family_in`/`runner.rs`
|
||||
/// use over the RECORDED manifest param names (the sweep boundary uses the
|
||||
/// stricter `override_paths`, which errors on an unmatched name instead).
|
||||
///
|
||||
/// #328: matches via [`crate::axes::raw_matches_wrapped`], so `names` may be
|
||||
/// WRAPPED (every route this cycle leaves untouched) OR RAW (a `Sweep` family
|
||||
/// minted by `blueprint_sweep_family`, whose manifest now records the raw
|
||||
/// campaign namespace, #328) — `reproduce_family_in` reproduces families from
|
||||
/// either route generically, so this helper must tolerate both shapes rather
|
||||
/// than assume one. Contrast `axes::raw_bound_overrides_of`, whose `names` are
|
||||
/// ALWAYS RAW (a campaign document's own namespace, #203) by contract.
|
||||
pub fn wrapped_bound_overrides_of(
|
||||
names: &[String],
|
||||
open_space: &[ParamSpec],
|
||||
signal: &Composite,
|
||||
) -> Vec<String> {
|
||||
let open: HashSet<&str> = open_space.iter().map(|p| p.name.as_str()).collect();
|
||||
let prefix = format!("{}.", signal.name());
|
||||
let bound = wrapped_bound_names(signal);
|
||||
names
|
||||
.iter()
|
||||
.filter(|n| !open.contains(n.as_str()) && bound.contains(*n))
|
||||
.map(|n| n[prefix.len()..].to_string())
|
||||
.filter(|n| !open_space.iter().any(|p| crate::axes::raw_matches_wrapped(n, &p.name)))
|
||||
.filter_map(|n| {
|
||||
bound
|
||||
.iter()
|
||||
.find(|b| crate::axes::raw_matches_wrapped(n, b))
|
||||
.map(|b| crate::axes::wrapped_to_raw_axis(b).to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The sweep-boundary variant (#246): like `wrapped_bound_overrides_of`, but
|
||||
/// an axis matching NEITHER the open nor the bound space is the error — the
|
||||
/// honest replacement of the retired "fully bound; nothing to sweep" refusal.
|
||||
///
|
||||
/// #328: matches via [`crate::axes::raw_matches_wrapped`] rather than exact
|
||||
/// wrapped-name equality, so a RAW incoming name (`fast.length`) resolves
|
||||
/// against the WRAPPED open/bound space exactly like an exact wrapped hit
|
||||
/// (`sma_signal.fast.length`) would — the one shared suffix convention
|
||||
/// `axes.rs` defines, reused rather than re-derived here. This is the
|
||||
/// resolution mechanism, not an acceptance gate (contrast the CLI's own
|
||||
/// `--axis` intake, which refuses a wrapped name before this ever runs on the
|
||||
/// sweep-verb route); callers whose axes are not intake-filtered this cycle
|
||||
/// (the walk-forward routes) keep accepting either form unchanged.
|
||||
pub fn override_paths(
|
||||
axes: &[(String, Vec<Scalar>)],
|
||||
open_space: &[ParamSpec],
|
||||
signal: &Composite,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let open: HashSet<&str> = open_space.iter().map(|p| p.name.as_str()).collect();
|
||||
let prefix = format!("{}.", signal.name());
|
||||
let bound = wrapped_bound_names(signal);
|
||||
let mut overrides = Vec::new();
|
||||
for (name, _) in axes {
|
||||
if open.contains(name.as_str()) {
|
||||
if open_space.iter().any(|p| crate::axes::raw_matches_wrapped(name, &p.name)) {
|
||||
continue;
|
||||
}
|
||||
if bound.contains(name) {
|
||||
overrides.push(name[prefix.len()..].to_string());
|
||||
} else {
|
||||
return Err(format!(
|
||||
"axis {name}: names no param of this blueprint (open or bound) — \
|
||||
see `aura sweep <bp> --list-axes`"
|
||||
));
|
||||
match bound.iter().find(|b| crate::axes::raw_matches_wrapped(name, b)) {
|
||||
Some(b) => overrides.push(crate::axes::wrapped_to_raw_axis(b).to_string()),
|
||||
None => {
|
||||
return Err(format!(
|
||||
"axis {name}: names no param of this blueprint (open or bound) — \
|
||||
see `aura sweep <bp> --list-axes`"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(overrides)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use aura_core::Scalar;
|
||||
use aura_engine::{blueprint_from_json, window_of};
|
||||
use aura_registry::{group_families, Family, FamilyKind, Registry};
|
||||
use aura_backtest::point_from_params;
|
||||
@@ -142,7 +143,31 @@ pub fn reproduce_family_in(
|
||||
});
|
||||
}
|
||||
let space = crate::member::wrap_r(reload()?, tx_eq, tx_ex, tx_r, tx_req, stop, true, SYNTHETIC_PIP_SIZE, &binding, None).param_space();
|
||||
let point = point_from_params(&space, &stored.manifest.params)
|
||||
// #328: `stored.manifest.params` is RAW on every mint route now (the
|
||||
// synthetic sweep family and, since this cycle's tidy fix, the
|
||||
// real/campaign route too) — but a family minted before this fix may
|
||||
// still carry WRAPPED names on disk (C29: no retroactive rewrite of a
|
||||
// registered artifact), so `reproduce_family_in` reproduces either
|
||||
// shape generically: each recorded name is translated onto its
|
||||
// matching WRAPPED `space` slot (`raw_matches_wrapped`, tolerant of
|
||||
// both shapes) before `point_from_params`, which still keys by the
|
||||
// exact wrapped name. Non-axis stamps (`stop_length`, `cost[k].<knob>`)
|
||||
// carry no wrap segment either way and translate to themselves (no
|
||||
// `space` hit).
|
||||
let wrapped_params: Vec<(String, Scalar)> = stored
|
||||
.manifest
|
||||
.params
|
||||
.iter()
|
||||
.map(|(n, v)| {
|
||||
let wrapped = space
|
||||
.iter()
|
||||
.find(|p| crate::axes::raw_matches_wrapped(n, &p.name))
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| n.clone());
|
||||
(wrapped, *v)
|
||||
})
|
||||
.collect();
|
||||
let point = point_from_params(&space, &wrapped_params)
|
||||
.map_err(|m| RunnerError { exit_code: 1, message: m })?;
|
||||
// A MonteCarlo member carries no tuning params (the params-join is empty), so its
|
||||
// reproduce line would print a BLANK member label; the seed IS its realization
|
||||
|
||||
@@ -116,6 +116,21 @@ impl MemberRunner for DefaultMemberRunner<'_> {
|
||||
.param_space();
|
||||
let point = bind_axes(&space, &cell.strategy_id, params)?;
|
||||
let signal = reopen_all(signal, &overrides);
|
||||
// manifest.params records RAW names (#328: the real/campaign route was
|
||||
// the one mint left wrapped after the synthetic route's own switch) —
|
||||
// a name-only reshaping of `space` (order/kind untouched) fed to
|
||||
// `run_blueprint_member`, which only zips it against `point` BY
|
||||
// POSITION (`zip_params`) — never re-resolves by name — so renaming
|
||||
// here is purely the manifest's own namespace, with no effect on
|
||||
// member resolution (`space` itself stays wrapped for `bind_axes`
|
||||
// above, which still keys by the exact wrapped `param_space()` name).
|
||||
let manifest_space: Vec<aura_core::ParamSpec> = space
|
||||
.iter()
|
||||
.map(|p| aura_core::ParamSpec {
|
||||
name: crate::axes::wrapped_to_raw_axis(&p.name).to_string(),
|
||||
kind: p.kind,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// The member's resolved input binding (campaign data.bindings
|
||||
// overrides win over name defaults). A refusal is a member fault,
|
||||
@@ -173,7 +188,7 @@ impl MemberRunner for DefaultMemberRunner<'_> {
|
||||
let mut report = run_blueprint_member(
|
||||
signal,
|
||||
&point,
|
||||
&space,
|
||||
&manifest_space,
|
||||
sources,
|
||||
(from, to),
|
||||
0,
|
||||
@@ -490,7 +505,28 @@ pub fn persist_campaign_traces(
|
||||
// is the member report's own `manifest.window` — already
|
||||
// epoch-ns (`run_blueprint_member` stamped the post-seam
|
||||
// bounds), so no second ms->ns crossing here.
|
||||
let point = point_from_params(&space, &member_report.manifest.params)?;
|
||||
//
|
||||
// #328: `member_report.manifest.params` is RAW (this cycle's tidy
|
||||
// fix put the real/campaign mint on the raw frame too), but
|
||||
// `point_from_params` still keys by the exact WRAPPED `space`
|
||||
// name — translate each recorded name onto its matching wrapped
|
||||
// slot first (`raw_matches_wrapped`, tolerant of either shape, the
|
||||
// same `reproduce_family_in` recipe), never re-resolving by a
|
||||
// renamed identity.
|
||||
let wrapped_params: Vec<(String, Scalar)> = member_report
|
||||
.manifest
|
||||
.params
|
||||
.iter()
|
||||
.map(|(n, v)| {
|
||||
let wrapped = space
|
||||
.iter()
|
||||
.find(|p| crate::axes::raw_matches_wrapped(n, &p.name))
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| n.clone());
|
||||
(wrapped, *v)
|
||||
})
|
||||
.collect();
|
||||
let point = point_from_params(&space, &wrapped_params)?;
|
||||
let (from, to) = member_report.manifest.window;
|
||||
let no_data = || {
|
||||
format!(
|
||||
|
||||
@@ -7,13 +7,6 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
aura-core = { path = "../aura-core" }
|
||||
# DST-correct wall-clock math for the `Session` node. A vetted
|
||||
# standard crate for timezone/DST — never hand-rolled (C16 per-case policy).
|
||||
# `chrono` is already a transitive workspace dep (0.4 via aura-ingest's
|
||||
# data-server); aligned to that major. `chrono-tz` brings the IANA `Europe/Berlin`
|
||||
# zone + DST transitions.
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
chrono-tz = { version = "0.10", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
aura-strategy = { path = "../aura-strategy" }
|
||||
|
||||
@@ -7,10 +7,15 @@
|
||||
//! indexed `F64` knobs that `Composite::param_space` aggregates (C8/C12/C19).
|
||||
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
||||
ScalarKind,
|
||||
ArgKind, ArgSpec, ArgValue, Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
|
||||
PrimitiveBuilder, ScalarKind,
|
||||
};
|
||||
|
||||
/// The declared construction args of a `LinComb` recipe: `arity` fixes the
|
||||
/// node's input/param count (topology, C19), taken through the `args`
|
||||
/// channel instead of a Rust-side builder parameter.
|
||||
const LINCOMB_ARGS: &[ArgSpec] = &[ArgSpec { name: "arity", kind: ArgKind::Count }];
|
||||
|
||||
/// Weighted sum of `N` f64 inputs: `Σ weights[i] · input[i]`. The `weights` are
|
||||
/// construction parameters that configure the node and fix its arity
|
||||
/// (`weights.len()` inputs, in slot order). Emits `None` until *all* inputs
|
||||
@@ -40,10 +45,29 @@ impl LinComb {
|
||||
Self { weights, out: [Cell::from_f64(0.0)] }
|
||||
}
|
||||
|
||||
/// The param-generic recipe for a blueprint primitive. The `arity` is topology
|
||||
/// (fixed per blueprint, C19), taken as a builder arg; only the weight *values*
|
||||
/// are injected, slot by slot, through `LinComb::new` (the single sizing gate).
|
||||
pub fn builder(arity: usize) -> PrimitiveBuilder {
|
||||
/// Roster factory: zero-arg, arg-bearing. `arity` is topology (fixed per
|
||||
/// blueprint, C19), now taken through the `args` channel instead of a
|
||||
/// Rust-side parameter.
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::pending(
|
||||
"LinComb",
|
||||
"linear combination of its inputs with constant weights",
|
||||
LINCOMB_ARGS,
|
||||
Self::make,
|
||||
)
|
||||
}
|
||||
|
||||
/// Turn a validated `arity` into the real, arity-sized signature; only
|
||||
/// the weight *values* are injected, slot by slot, through `LinComb::new`
|
||||
/// (the single sizing gate) once params are bound/built.
|
||||
fn make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
|
||||
let arity = values
|
||||
.iter()
|
||||
.find_map(|(n, v)| match (n.as_str(), v) {
|
||||
("arity", ArgValue::Count(n)) => Some(*n),
|
||||
_ => None,
|
||||
})
|
||||
.expect("try_args validated `arity` as ArgKind::Count before calling make");
|
||||
let inputs = (0..arity)
|
||||
.map(|i| PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: format!("term[{i}]") })
|
||||
.collect();
|
||||
@@ -63,6 +87,13 @@ impl LinComb {
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Rust-path convenience — same recipe as the data path (twin identity).
|
||||
pub fn configured(arity: usize) -> PrimitiveBuilder {
|
||||
Self::builder()
|
||||
.try_args(&[("arity".to_string(), arity.to_string())])
|
||||
.expect("configured: a positive arity is always the canonical strict-form Count string")
|
||||
}
|
||||
}
|
||||
|
||||
impl Node for LinComb {
|
||||
@@ -149,7 +180,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn input_slots_are_named_term_index() {
|
||||
let lc = LinComb::builder(3);
|
||||
let lc = LinComb::configured(3);
|
||||
let names: Vec<String> = lc.schema().inputs.iter().map(|p| p.name.clone()).collect();
|
||||
assert_eq!(names, ["term[0]", "term[1]", "term[2]"]);
|
||||
}
|
||||
@@ -157,7 +188,7 @@ mod tests {
|
||||
#[test]
|
||||
fn chained_bind_reconstructs_positional_vector() {
|
||||
// bind BOTH weights, in reverse slot order, to DISTINCT values; build empty.
|
||||
let builder = LinComb::builder(2)
|
||||
let builder = LinComb::configured(2)
|
||||
.bind("weights[1]", Scalar::f64(2.0))
|
||||
.bind("weights[0]", Scalar::f64(0.5));
|
||||
assert!(builder.params().is_empty());
|
||||
@@ -173,7 +204,7 @@ mod tests {
|
||||
assert_eq!(lc.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(11.0)].as_slice()));
|
||||
|
||||
// partial: bind weights[0], leave weights[1] open → inject it at build
|
||||
let partial = LinComb::builder(2).bind("weights[0]", Scalar::f64(0.5));
|
||||
let partial = LinComb::configured(2).bind("weights[0]", Scalar::f64(0.5));
|
||||
assert_eq!(
|
||||
partial.params().iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
|
||||
["weights[1]"],
|
||||
@@ -187,4 +218,14 @@ mod tests {
|
||||
inputs2[1].push(Scalar::f64(3.0)).unwrap();
|
||||
assert_eq!(lc2.eval(Ctx::new(&inputs2, Timestamp(0))), Some([Cell::from_f64(11.0)].as_slice()));
|
||||
}
|
||||
|
||||
/// Twin identity (spec §aura-std): `configured(arity)` is exactly
|
||||
/// `builder().try_args([("arity", arity)])`.
|
||||
#[test]
|
||||
fn lincomb_configured_twin_equals_builder_try_args() {
|
||||
let via_configured = LinComb::configured(3);
|
||||
let via_try_args = LinComb::builder().try_args(&[("arity".into(), "3".into())]).expect("valid arity configures");
|
||||
assert_eq!(via_configured.construction_args(), via_try_args.construction_args());
|
||||
assert_eq!(via_configured.schema(), via_try_args.schema());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ mod tests {
|
||||
vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
|
||||
);
|
||||
// vector knob expands flat to N indexed F64 entries
|
||||
let lc = LinComb::builder(2).schema().params.clone();
|
||||
let lc = LinComb::configured(2).schema().params.clone();
|
||||
assert_eq!(lc.len(), 2);
|
||||
assert_eq!(lc[0].name, "weights[0]");
|
||||
assert_eq!(lc[1].name, "weights[1]");
|
||||
|
||||
@@ -555,10 +555,10 @@ mod tests {
|
||||
ConstantCost::builder().schema().output.iter().map(|f| f.name.clone()).collect();
|
||||
assert_eq!(prod, COST_FIELD_NAMES.to_vec());
|
||||
let agg_out: Vec<String> =
|
||||
CostSum::builder(1).schema().output.iter().map(|f| f.name.clone()).collect();
|
||||
CostSum::configured(1).schema().output.iter().map(|f| f.name.clone()).collect();
|
||||
assert_eq!(agg_out, COST_FIELD_NAMES.to_vec());
|
||||
let agg_in: Vec<String> =
|
||||
CostSum::builder(1).schema().inputs.iter().map(|p| p.name.clone()).collect();
|
||||
CostSum::configured(1).schema().inputs.iter().map(|p| p.name.clone()).collect();
|
||||
let expected: Vec<String> =
|
||||
COST_FIELD_NAMES.iter().map(|f| format!("cost[0].{f}")).collect();
|
||||
assert_eq!(agg_in, expected);
|
||||
|
||||
@@ -7,9 +7,15 @@
|
||||
//! the cost path is uniform whether one or several cost nodes are wired.
|
||||
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind,
|
||||
ArgKind, ArgSpec, ArgValue, Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec,
|
||||
PrimitiveBuilder, ScalarKind,
|
||||
};
|
||||
|
||||
/// The declared construction args of a `CostSum` recipe: `n_costs` fixes the
|
||||
/// node's input count (topology, C19), taken through the `args` channel
|
||||
/// instead of a Rust-side builder parameter.
|
||||
const COST_SUM_ARGS: &[ArgSpec] = &[ArgSpec { name: "n_costs", kind: ArgKind::Count }];
|
||||
|
||||
/// The cost-record field triple and its width come from the shared cost contract
|
||||
/// (`crate::cost::{COST_FIELD_NAMES, COST_WIDTH}`) — one source of truth, read by
|
||||
/// both the producer side (`cost_node_builder`) and this aggregator. The input-name
|
||||
@@ -32,10 +38,29 @@ impl CostSum {
|
||||
Self { n_costs, out: [Cell::from_f64(0.0); COST_WIDTH] }
|
||||
}
|
||||
|
||||
/// The param-generic recipe. `n_costs` is topology (fixed per blueprint, C19),
|
||||
/// captured by the build closure (no per-build params). The input names are a
|
||||
/// lockstep contract with the connect side (`cost[k].<field>`).
|
||||
pub fn builder(n_costs: usize) -> PrimitiveBuilder {
|
||||
/// Roster factory: zero-arg, arg-bearing. `n_costs` is topology (fixed per
|
||||
/// blueprint, C19), now taken through the `args` channel instead of a
|
||||
/// Rust-side parameter.
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::pending(
|
||||
"CostSum",
|
||||
"sums cost-model contributions into one cost-in-R stream",
|
||||
COST_SUM_ARGS,
|
||||
Self::make,
|
||||
)
|
||||
}
|
||||
|
||||
/// Turn a validated `n_costs` into the real, arity-sized signature. The
|
||||
/// input names are a lockstep contract with the connect side
|
||||
/// (`cost[k].<field>`).
|
||||
fn make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
|
||||
let n_costs = values
|
||||
.iter()
|
||||
.find_map(|(n, v)| match (n.as_str(), v) {
|
||||
("n_costs", ArgValue::Count(n)) => Some(*n),
|
||||
_ => None,
|
||||
})
|
||||
.expect("try_args validated `n_costs` as ArgKind::Count before calling make");
|
||||
let mut inputs = Vec::with_capacity(n_costs * COST_WIDTH);
|
||||
for k in 0..n_costs {
|
||||
for field in COST_FIELD_NAMES {
|
||||
@@ -63,6 +88,13 @@ impl CostSum {
|
||||
move |_| Box::new(CostSum::new(n_costs)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Rust-path convenience — same recipe as the data path (twin identity).
|
||||
pub fn configured(n_costs: usize) -> PrimitiveBuilder {
|
||||
Self::builder()
|
||||
.try_args(&[("n_costs".to_string(), n_costs.to_string())])
|
||||
.expect("configured: a positive n_costs is always the canonical strict-form Count string")
|
||||
}
|
||||
}
|
||||
|
||||
impl Node for CostSum {
|
||||
@@ -140,7 +172,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn input_slots_are_named_cost_index_field() {
|
||||
let s = CostSum::builder(2);
|
||||
let s = CostSum::configured(2);
|
||||
let names: Vec<String> = s.schema().inputs.iter().map(|p| p.name.clone()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
@@ -161,4 +193,15 @@ mod tests {
|
||||
fn new_panics_on_zero() {
|
||||
let _ = CostSum::new(0);
|
||||
}
|
||||
|
||||
/// Twin identity (spec §aura-strategy): `configured(n_costs)` is exactly
|
||||
/// `builder().try_args([("n_costs", n_costs)])`.
|
||||
#[test]
|
||||
fn cost_sum_configured_twin_equals_builder_try_args() {
|
||||
let via_configured = CostSum::configured(2);
|
||||
let via_try_args =
|
||||
CostSum::builder().try_args(&[("n_costs".into(), "2".into())]).expect("valid n_costs configures");
|
||||
assert_eq!(via_configured.construction_args(), via_try_args.construction_args());
|
||||
assert_eq!(via_configured.schema(), via_try_args.schema());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,22 +8,27 @@
|
||||
//! takes it as an injected resolver, and a project `cdylib` later supplies its
|
||||
//! own (C16) through the same seam.
|
||||
//!
|
||||
//! Scope (#155): only the **zero-argument** `Type::builder()` factories are
|
||||
//! resolvable. Builders that take structural construction arguments
|
||||
//! (`LinComb(arity)`, `CostSum(n_costs)`, `SimBroker(pip_size)`, `Session(..)`)
|
||||
//! and the recording sinks (`Recorder`/`GatedRecorder`/`SeriesReducer`, which
|
||||
//! capture an `mpsc::Sender`) are deliberately absent — an absent `type_id`
|
||||
//! Scope (#155, extended #271): the roster resolves any type whose factory is
|
||||
//! `Type::builder()` with zero RUST-side arguments — this now includes
|
||||
//! arg-bearing types (`Session`, `LinComb`, `CostSum`), whose `builder()` is
|
||||
//! itself zero-arg and returns a *pending* `PrimitiveBuilder`, configured
|
||||
//! through the typed `args` channel (`try_args`) at the `add` op, before any
|
||||
//! bind. `SimBroker(pip_size)` stays out of scope: it is a plain scalar Rust
|
||||
//! parameter, not a structural construction arg — a different (unaddressed)
|
||||
//! gap. The recording sinks (`Recorder`/`GatedRecorder`/`SeriesReducer`, which
|
||||
//! capture an `mpsc::Sender`) stay deliberately absent — an absent `type_id`
|
||||
//! yields `None`, so the loader fails cleanly (`LoadError::UnknownNodeType`)
|
||||
//! rather than guessing. Serialising structural-axis construction args is a
|
||||
//! later, additive extension (#156/C20).
|
||||
//! rather than guessing.
|
||||
|
||||
use aura_backtest::PositionManagement;
|
||||
use aura_market::{Resample, SessionFrankfurt};
|
||||
use aura_market::{Resample, Session, SessionFrankfurt};
|
||||
use aura_std::{
|
||||
Abs, Add, And, Const, CumSum, Delay, Div, Ema, EqConst, Gt, Latch, Max, Min, Mul, RollingMax,
|
||||
RollingMin, Scale, Select, Sign, Sma, Sqrt, Sub, When,
|
||||
Abs, Add, And, Const, CumSum, Delay, Div, Ema, EqConst, Gt, Latch, LinComb, Max, Min, Mul,
|
||||
RollingMax, RollingMin, Scale, Select, Sign, Sma, Sqrt, Sub, When,
|
||||
};
|
||||
use aura_strategy::{
|
||||
Bias, CarryCost, ConstantCost, CostSum, FixedStop, LongOnly, Sizer, VolSlippageCost,
|
||||
};
|
||||
use aura_strategy::{Bias, CarryCost, ConstantCost, FixedStop, LongOnly, Sizer, VolSlippageCost};
|
||||
use aura_core::PrimitiveBuilder;
|
||||
|
||||
/// The single roster source (#160): one `"TypeId" => Type` line per zero-arg
|
||||
@@ -67,6 +72,7 @@ std_vocabulary_roster! {
|
||||
"CarryCost" => CarryCost,
|
||||
"Const" => Const,
|
||||
"ConstantCost" => ConstantCost,
|
||||
"CostSum" => CostSum,
|
||||
"CumSum" => CumSum,
|
||||
"Delay" => Delay,
|
||||
"Div" => Div,
|
||||
@@ -75,6 +81,7 @@ std_vocabulary_roster! {
|
||||
"FixedStop" => FixedStop,
|
||||
"Gt" => Gt,
|
||||
"Latch" => Latch,
|
||||
"LinComb" => LinComb,
|
||||
"LongOnly" => LongOnly,
|
||||
"Max" => Max,
|
||||
"Min" => Min,
|
||||
@@ -85,6 +92,7 @@ std_vocabulary_roster! {
|
||||
"RollingMin" => RollingMin,
|
||||
"Scale" => Scale,
|
||||
"Select" => Select,
|
||||
"Session" => Session,
|
||||
"SessionFrankfurt" => SessionFrankfurt,
|
||||
"Sign" => Sign,
|
||||
"Sizer" => Sizer,
|
||||
@@ -103,8 +111,11 @@ mod tests {
|
||||
/// label back, and only the rostered keys do. The builder's own `label()`
|
||||
/// is the independent oracle: a typo'd roster key fails the lookup-label
|
||||
/// agreement even though the match arms and the list share the same
|
||||
/// (mistyped) literal; construction-arg nodes and sinks stay absent so the
|
||||
/// loader fails cleanly rather than guessing.
|
||||
/// (mistyped) literal; an unresolvable id and a sink stay absent so the
|
||||
/// loader fails cleanly rather than guessing. `LinComb` — an arg-bearing
|
||||
/// type, #271 — now resolves to a PENDING builder (its own `.label()`
|
||||
/// still round-trips; it just is not yet constructible without
|
||||
/// `try_args`).
|
||||
#[test]
|
||||
fn std_vocabulary_resolves_known_and_rejects_unknown() {
|
||||
for &type_id in std_vocabulary_types() {
|
||||
@@ -116,9 +127,9 @@ mod tests {
|
||||
"resolver key must round-trip to the same type label"
|
||||
);
|
||||
}
|
||||
// an unknown id, a construction-arg node, and a sink are all absent
|
||||
// an unknown id and a sink are absent; LinComb is now resolved (#271)
|
||||
assert!(std_vocabulary("nope").is_none());
|
||||
assert!(std_vocabulary("LinComb").is_none());
|
||||
assert!(std_vocabulary("LinComb").is_some());
|
||||
assert!(std_vocabulary("Recorder").is_none());
|
||||
}
|
||||
|
||||
@@ -129,14 +140,16 @@ mod tests {
|
||||
/// act. The residual #160 drift — a new zero-arg node never rostered at
|
||||
/// all — is not catchable here (no enumeration of zero-arg builders
|
||||
/// exists); it fails safe (clean `UnknownNodeType` on load, merely absent
|
||||
/// from `--vocabulary`).
|
||||
/// from `--vocabulary`). #271: `Session`/`LinComb`/`CostSum` moved IN
|
||||
/// (arg-bearing types now have zero-arg `builder()` factories); the count
|
||||
/// pin moves 33 -> 36.
|
||||
#[test]
|
||||
fn std_vocabulary_types_lists_exactly_the_resolvable_keys() {
|
||||
// known non-members stay out of the list
|
||||
assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node
|
||||
assert!(std_vocabulary_types().contains(&"LinComb")); // now arg-bearing-reachable (#271)
|
||||
assert!(!std_vocabulary_types().contains(&"Recorder")); // sink
|
||||
assert!(!std_vocabulary_types().contains(&"nope"));
|
||||
// count guard: pins the roster at exactly 33 entries
|
||||
assert_eq!(std_vocabulary_types().len(), 33);
|
||||
// count guard: pins the roster at exactly 36 entries
|
||||
assert_eq!(std_vocabulary_types().len(), 36);
|
||||
}
|
||||
}
|
||||
|
||||
+84
-21
@@ -40,7 +40,7 @@ the same shape every node in `aura-std` already follows.
|
||||
repo (two tiers)") — a strategy over the std vocabulary is a
|
||||
blueprint/campaign document, not Rust: the scaffold ships **one** closed
|
||||
`signal.json` starter that serves both verbs — `aura run` uses its bound
|
||||
values as-is, `aura sweep --axis <bp>.fast.length=2,4,8` overrides them
|
||||
values as-is, `aura sweep --axis fast.length=2,4,8` overrides them
|
||||
(bound = default, not fixed); `--list-axes` lists the open knobs and the
|
||||
bound defaults alike, so every override target is discoverable.
|
||||
- **A project-specific *native* node type** — the moment §0's three-part
|
||||
@@ -49,8 +49,9 @@ the same shape every node in `aura-std` already follows.
|
||||
`src/lib.rs`, registered under the project's own `<namespace>::` prefix)
|
||||
and appends its path to the project's `Aura.toml [nodes]` section.
|
||||
- **A block promoted to universal** — reused across projects and folded into
|
||||
the engine itself — lives in `crates/aura-std/src/`, unprefixed, and is
|
||||
rostered in `crates/aura-std/src/vocabulary.rs` instead of a node crate's
|
||||
the engine itself — lives in `crates/aura-std/` (or its sibling domain
|
||||
crates: `aura-market`, `aura-strategy`), unprefixed, and is rostered in
|
||||
`crates/aura-vocabulary/src/lib.rs` instead of a node crate's
|
||||
`vocabulary()` function. The pattern below is identical either way; only
|
||||
the rostering call site differs (see "Rostering the type" below).
|
||||
|
||||
@@ -89,11 +90,16 @@ Every node type — std or project-local — is three things:
|
||||
`aura_core::aura_project!` macro wires up (every `aura new` scaffold
|
||||
emits a starter pair — see the worked example below). Add one match arm
|
||||
to `vocabulary()` and one entry to `type_ids()`'s slice.
|
||||
- **Std-side** (only when promoting a node into `aura-std` itself): one
|
||||
line in the `std_vocabulary_roster!` macro invocation in
|
||||
[`crates/aura-std/src/vocabulary.rs`](../crates/aura-std/src/vocabulary.rs)
|
||||
- **Std-side** (only when promoting a node into the shipped std
|
||||
vocabulary): one line in the `std_vocabulary_roster!` macro invocation in
|
||||
[`crates/aura-vocabulary/src/lib.rs`](../crates/aura-vocabulary/src/lib.rs)
|
||||
— `"TypeId" => Type,` — which expands into both the resolver `match` and
|
||||
the enumerable type-id list, so the two surfaces cannot drift apart.
|
||||
the enumerable type-id list, so the two surfaces cannot drift apart. A
|
||||
zero-arg `Type::builder()` rosters directly; an **arg-bearing** type
|
||||
(structural, non-scalar construction — see "Session anchoring" below and
|
||||
`docs/glossary.md`'s `construction arg` entry, #271) rosters the exact
|
||||
same way — its `builder()` is itself zero-arg, returning a *pending*
|
||||
recipe the `add` op's `args` configures before any `bind`.
|
||||
An unrostered type fails safe either way: the loader refuses with a clean
|
||||
`LoadError::UnknownNodeType` naming the missing id, and the type is simply
|
||||
absent from `aura graph introspect --vocabulary` — never a silent partial
|
||||
@@ -221,14 +227,15 @@ $ aura graph build < smacross.json > blueprint.json
|
||||
Nodes are referenced by an **identifier** (given by `add`, see below); ports
|
||||
are dotted `<identifier>.<port>` on both sides of a wire.
|
||||
|
||||
### The ten ops
|
||||
### The eleven ops
|
||||
|
||||
| op | JSON shape | does |
|
||||
|---|---|---|
|
||||
| `name` | `{"op":"name","name":<str>}` | set the composite's **render name**, script-level and at-most-once (a second `name` op refuses: `a script names its blueprint at most once`). The name must be a single path segment (non-empty, no `/`, `\`, `.` or `..`) since it keys a trace directory (`traces/<name>/`) at run time. Omitting the op keeps the CLI's own default, `"graph"`. |
|
||||
| `doc` | `{"op":"doc","text":<str>}` | declare the composite's one-line meaning (C29) — the op-script twin of the Rust builder's `.doc(...)`. Required before `aura graph register` accepts the product (the store refuses a doc-less composite); at most one per script (a second refuses: `a doc op may appear at most once`). The text is gated: empty or merely restating the composite's name refuses at register. |
|
||||
| `source` | `{"op":"source","role":<str>,"kind":<ScalarKind>}` | reserve a bound root **source** role of `kind` — a real input the harness feeds (e.g. `"price"`). |
|
||||
| `input` | `{"op":"input","role":<str>}` | reserve an open root **input** role (kind inferred from the slots it feeds) — the formal parameter of an **open pattern**, a fragment meant to be wired by an *enclosing* graph. An open pattern builds and registers like any blueprint. Running it standalone is governed by the ordinary run gates: the harness binds input roles to archive columns **by name** (C26), so a pattern whose roles match the data runs as-is; what refuses, by name, is a role the harness cannot bind — or the run surface's other gates (a signal without a bias output or tap; free knobs). |
|
||||
| `add` | `{"op":"add","type":<TypeId>,"name":<str>?,"bind":{<param>:<Scalar>}?}` | instantiate a node of a type in the closed vocabulary (`aura graph introspect --vocabulary`) — see §0 below for how a type gets into that vocabulary in the first place. `name` becomes the node's identifier for later ops (default: the type's own lowercase label — two unnamed nodes of the same type then collide). `bind` sets zero or more of its params. |
|
||||
| `add` | `{"op":"add","type":<TypeId>,"name":<str>?,"args":{<arg>:<str>}?,"bind":{<param>:<Scalar>}?}` | instantiate a node of a type in the closed vocabulary (`aura graph introspect --vocabulary`) — see §0 below for how a type gets into that vocabulary in the first place. `name` becomes the node's identifier for later ops (default: the type's own lowercase label — two unnamed nodes of the same type then collide). `args` (#271) configures an **arg-bearing** type's structural, non-scalar construction (`Session`'s timezone/open, `LinComb`/`CostSum`'s arity) — a closed table of string values, applied BEFORE `bind`; `aura graph introspect --node <T>` lists a type's declared `arg` rows. `bind` sets zero or more of the (now real) params. |
|
||||
| `use` | `{"op":"use","ref":{"content_id":<id-or-prefix>}\|{"name":<label>},"name":<str>?,"bind":{<path>:<Scalar>}?}` | splice a **registered blueprint** into the graph as a nested composite under an instance identifier (`name`; default: the stored composite's own name). The ref resolves by content id (full or unique prefix) or by a register-time label; `graph build` echoes every resolution (`aura: note: use "…": … -> <id>`) so the exact id can be pinned. The instance's open input roles wire as `<instance>.<role>`, its outputs as `<instance>.<field>`; its open params surface path-qualified (`<instance>.<node>.<param>`), sweepable (ganging an instance's params is not yet supported — the `gang` op refuses a composite instance's member path). A doc-less stored source refuses at the op (C29). The emitted blueprint contains the subgraph *inline* — no reference survives into the artifact. |
|
||||
| `feed` | `{"op":"feed","role":<str>,"into":[<port>, …]}` | fan a previously-declared role into one or more interior input slots, all-or-nothing (a failing target leaves none of the batch wired). |
|
||||
| `connect` | `{"op":"connect","from":<port>,"to":<port>}` | wire one interior output field to one interior input slot. A `connect` that would close a dataflow cycle is rejected immediately — the only legal feedback path is an explicit delay/state node (domain invariant 5). |
|
||||
@@ -236,6 +243,14 @@ are dotted `<identifier>.<port>` on both sides of a wire.
|
||||
| `tap` | `{"op":"tap","from":<port>,"as":<str>}` | declare a **measurement tap** on an interior output field under the name `as` — the output-side twin of `expose` (a recorded observation point, not a boundary output; a `Composite.taps` entry, C27). A single `aura run` constructs a recorder at each declared tap and persists its per-cycle series as a `ColumnarTrace`; a sweep leaves it inert. Tap names are their own namespace and must be unique (a second `tap` under one name refuses: `duplicate tap name`). |
|
||||
| `gang` | `{"op":"gang","as":"channel_length","into":["channel_hi.length","channel_lo.length"]}` | Fuse two or more sibling params into ONE public knob: the member addresses leave the sweepable param space and `as` replaces them; the bound or swept value fans out to every member at bootstrap. Members must share one scalar kind and stay open (un-bound). |
|
||||
|
||||
Omitting `name` is fine for a single strategy, but it has three live
|
||||
consequences once a project has more than one: every unnamed store document
|
||||
is indistinguishable by name (`"graph"` again), every unnamed strategy's
|
||||
default tap recording lands in the same shared `traces/graph/` directory
|
||||
(later runs overwrite earlier ones), and two `use`-splices of unnamed
|
||||
blueprints collide on the same default instance identifier (`graph`) inside
|
||||
the same composing script. A one-line `name` op dissolves all three at once.
|
||||
|
||||
Value forms are the typed-tag representations used everywhere in this
|
||||
family of artifacts:
|
||||
|
||||
@@ -278,7 +293,7 @@ A param therefore has three states: **open** (an axis every sweep MUST bind),
|
||||
**bound** (a default any axis MAY override, #246), and **ganged** (open, but
|
||||
fused with its siblings under ONE public knob declared by a `gang` op; the
|
||||
member addresses are unbindable and only the gang's own single-segment name —
|
||||
wrapped like any knob, e.g. `graph.channel_length` — appears in `--list-axes`).
|
||||
e.g. `channel_length` — appears in `--list-axes`).
|
||||
|
||||
### Worked example: declaring a measurement tap
|
||||
|
||||
@@ -338,10 +353,42 @@ separate in-session bool — `bars_since_open` alone is the contract. DST is
|
||||
handled by `chrono-tz`, so the same local minute reads the same index in
|
||||
summer (CEST) and winter (CET).
|
||||
|
||||
The index is derived from the clock (`ctx.now()`), never counted from trigger
|
||||
firings — so the trigger's cadence only sets how *often* the node emits, not
|
||||
what it emits. Feeding a denser stream (e.g. a raw m1 `price`) is equally
|
||||
valid: the node fires once per input tick and reports the same
|
||||
`period_minutes`-bar index throughout that bar. The once-per-bar wiring above
|
||||
is the convention when you want exactly one emission per completed bar; it is
|
||||
not a correctness requirement.
|
||||
|
||||
Wire it like any node —
|
||||
`{"op":"add","type":"SessionFrankfurt","bind":{"period_minutes":{"I64":15}}}`,
|
||||
feed its `trigger` from a once-per-bar stream, and read `bars_since_open`.
|
||||
|
||||
**Any other timezone/open: `Session` + `args` (#271).** `SessionFrankfurt`
|
||||
stays baked sugar for the one Frankfurt open; the canonical path for ANY IANA
|
||||
timezone and local open time is the base `Session` type through the typed
|
||||
construction-args channel — `args` configures it BEFORE `bind`:
|
||||
|
||||
```json
|
||||
{"op":"add","type":"Session","name":"ny",
|
||||
"args":{"tz":"America/New_York","open":"09:30"},
|
||||
"bind":{"period_minutes":{"I64":15}}}
|
||||
```
|
||||
|
||||
`tz` is `chrono_tz`'s own exact, case-sensitive IANA name (`"berlin"` refuses;
|
||||
`"Europe/Berlin"` accepts); `open` is strict zero-padded local `HH:MM`
|
||||
(`"9:30"` refuses; `"09:30"` accepts); a `Count` arg (`LinComb`/`CostSum`'s
|
||||
arity) is a plain positive decimal — no sign, no leading zeros (`"03"`
|
||||
refuses; `"3"` accepts). The accepted string IS the canonical form, no
|
||||
normalization — and note the deliberate asymmetry: each kind's canonical
|
||||
form is its domain's conventional notation, fixed-width for wall-clock
|
||||
times, minimal for numbers. Giving `Session` no `args` at all refuses the `add`
|
||||
op (`node <name> is missing required arg "tz"`) — an arg-bearing type never
|
||||
silently enters the graph half-configured. `aura graph introspect --node
|
||||
Session` lists both declared `arg` rows with their kind and hint text before
|
||||
you write the JSON.
|
||||
|
||||
### Commands
|
||||
|
||||
```
|
||||
@@ -375,23 +422,39 @@ slow.length:I64
|
||||
bias.scale:F64
|
||||
```
|
||||
|
||||
These printed names are the **raw param-space namespace**: op-script params
|
||||
and a campaign document's `strategies[].axes` keys (§3) share this one raw
|
||||
form. There is a third, *wrapped* surface: the dissolved `aura sweep
|
||||
<blueprint> --axis` CLI (glossary `sweep`) accepts only the names `aura
|
||||
sweep <blueprint> --list-axes` prints — the raw name prefixed with the
|
||||
root-composite instance name, `graph.<param>` — never the raw form; the
|
||||
campaign document the sweep sugar generates still stores the raw form
|
||||
(#210):
|
||||
These printed names are the **one** axis namespace (#328): op-script params, a
|
||||
campaign document's `strategies[].axes` keys (§3), and `aura sweep <blueprint>
|
||||
--list-axes` / `--axis` (glossary `sweep`) all speak the same raw
|
||||
`<node>.<param>` form — `--params` and `--list-axes` are line-identical (open
|
||||
params bare, bound params trailing `default=<value>`), and every discovered
|
||||
name is verbatim legal as a document axis key, bound params included (#246's
|
||||
re-open contract):
|
||||
|
||||
```
|
||||
raw (--params, campaign axes): fast.length
|
||||
wrapped (--list-axes, --axis): graph.fast.length
|
||||
$ aura sweep smacross.json --axis fast.length=3:9:2 # synthetic: raw works
|
||||
$ aura sweep smacross.json --axis bias.scale=0.25,0.5 --real ... # real: raw works
|
||||
```
|
||||
|
||||
The older `<blueprint>.<node>.<param>` wrapped form (e.g. `graph.fast.length`,
|
||||
what pre-#328 transcripts and `--list-axes` output used to print) is retired
|
||||
from the surface; naming it refuses with a translation pointer to the raw
|
||||
candidate, on both intake seams:
|
||||
|
||||
```
|
||||
$ aura sweep smacross.json --axis graph.fast.length=...
|
||||
aura: axis "graph.fast.length": axis names are raw node.param paths —
|
||||
use "fast.length" (the wrapped --list-axes form was retired, #328)
|
||||
|
||||
$ aura campaign validate wrapped.json # a document quoting an old transcript
|
||||
aura: campaign references do not resolve:
|
||||
strategy f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240:
|
||||
axis "graph.fast.length" is not in the param space; axis names are raw
|
||||
node.param paths — did you mean "fast.length"?
|
||||
```
|
||||
|
||||
A ganged knob's raw address has one path segment less than a member address
|
||||
would — it sits at the composite's own level, like a role name (e.g.
|
||||
`channel_length`, not `channel_hi.length`) — and wraps identically (`graph.channel_length`).
|
||||
`channel_length`, not `channel_hi.length`).
|
||||
|
||||
`--content-id`, `--identity-id`, `--params`, and `graph register` all accept
|
||||
**either** shape: the raw op-script array or an already-built `#155`
|
||||
|
||||
@@ -17,10 +17,14 @@ stream; reference data feeds source/session nodes from beside the hot path.
|
||||
|
||||
**Session context — `bars_since_open` is the shipped contract (#154).** The
|
||||
session-context node `Session` (`crates/aura-market/src/session.rs`) emits
|
||||
**one** scalar stream, `bars_since_open: i64` (tz-aware, DST-correct, over a
|
||||
baked Frankfurt open), and takes no scalar params; the zero-arg
|
||||
`SessionFrankfurt` roster preset (#261) exposes it with a single
|
||||
`period_minutes` knob. This is a deliberate narrowing pinned in the node's own
|
||||
**one** scalar stream, `bars_since_open: i64` (tz-aware, DST-correct), over a
|
||||
timezone/open time that is now data-authorable via the typed construction-args
|
||||
channel (`Session` roster-reaches `std_vocabulary` as an arg-bearing type,
|
||||
#271: `args: {"tz": <IANA name>, "open": "HH:MM"}`, applied before its one
|
||||
scalar param, `period_minutes`). The zero-arg `SessionFrankfurt` roster preset
|
||||
(#261) stays as baked sugar over the 09:00 `Europe/Berlin` open — the
|
||||
canonical path for any OTHER open/timezone is `Session` + `args`, not a new
|
||||
preset per market. This is a deliberate narrowing pinned in the node's own
|
||||
contract: `bars_since_open` alone is the gate — a downstream `EqConst(== N)`
|
||||
subsumes an `in_session: bool` stream (pre-open instants read `<= 0`, which
|
||||
never match), and nothing consumes a `session_open_ts: timestamp`. The two
|
||||
|
||||
@@ -133,6 +133,18 @@ show <content-id>` prints a registered document's canonical bytes (#300), so the
|
||||
generate → retrieve → hand-extend → re-register loop needs no direct store
|
||||
filesystem access.
|
||||
|
||||
**The bound-override coincidence (#246, ratified #328).** A campaign axis naming a
|
||||
**bound** param of the referenced blueprint is deliberately accepted alongside one
|
||||
naming an open param: `validate_campaign_refs` checks each axis's name against
|
||||
`param_space()` **OR** `bound_param_space()` — the bound value stands as the
|
||||
axis's default, and the axis's own values re-open it for that campaign. There is
|
||||
no schema flag distinguishing the two cases (no `open`/`bound` marker on the axis
|
||||
entry); name coincidence against one of the two namespaces IS the mechanism, and
|
||||
the referenced blueprint is the single source of truth for which namespace a name
|
||||
falls in. Pinned by
|
||||
`referential_tier_accepts_a_kind_correct_axis_over_a_bound_param`
|
||||
(`aura-registry/src/lib.rs`).
|
||||
|
||||
**The campaign executor.** `aura campaign run <file|content-id>` executes a
|
||||
campaign (a file is register-then-run sugar; the content id is canonical): a
|
||||
zero-fault referential gate, then the process pipeline. The executable shape is
|
||||
|
||||
@@ -9,7 +9,12 @@ entirely (there is no composite in the flat graph). The blueprint's field /
|
||||
input-role *names* are **non-load-bearing** — the wiring resolves by index, and names
|
||||
survive at most as **informative debug symbols** (exactly as `FieldSpec.name` already
|
||||
is, C8), kept for tracing / rendering (C9 graph-as-data) but carrying no run
|
||||
semantics. The flat graph is then the target of **behaviour-preserving optimisation**
|
||||
semantics. (Reconciling with the root composite's own render name, #331: that
|
||||
claim stands unweakened — the name never influences compilation, identity, or
|
||||
execution semantics — but at *run* time, outside compilation, it keys the trace
|
||||
directory (`traces/<name>/`); that operational role, not any load-bearing
|
||||
weight inside the flat graph, is exactly why the authored-blueprint intakes
|
||||
shape-gate it.) The flat graph is then the target of **behaviour-preserving optimisation**
|
||||
— any transform that leaves every observable sink trace bit-identical (C1 is the
|
||||
correctness invariant) — on two levels:
|
||||
|
||||
|
||||
@@ -70,8 +70,10 @@ loop for the round-trippable vocabulary.
|
||||
|
||||
**Out of the round-trippable set** (deliberate; fails clean as `UnknownNodeType`):
|
||||
recording sinks (they capture an `mpsc::Sender` — runtime identity, not param-generic
|
||||
data, C19) and construction-arg builders (`LinComb` / `CostSum` / `SimBroker` /
|
||||
`Session` — structural-axis args, a C20 concern), additively addable later (#156).
|
||||
data, C19) and `SimBroker` (a scalar-typed baked value, a narrower gap than the args
|
||||
channel). `LinComb` / `CostSum` / `Session` — formerly listed here — entered the
|
||||
round-trippable set with #271's typed construction args (see the add-op `args`
|
||||
clause and the data-driven `format_version` below).
|
||||
|
||||
### Runs and families are built FROM blueprint-data
|
||||
|
||||
@@ -126,9 +128,14 @@ there is no default; pin a knob you do not want to vary with a single-value axis
|
||||
single `blueprint_axis_probe` (`aura-runner`) single-sources the wrapped probe for the
|
||||
sweep terminal, the MC closed-check, and the listing, so **listed == swept by
|
||||
construction** (and stays so across the harness retirement — the listing tracks
|
||||
whatever the sweep actually resolves). The names are prefixed by the current wrapping
|
||||
(`sma_signal.fast.length`, the nested-composite prefix), which is why discovery lives
|
||||
on the sweep verb (it owns the wrapping), not `graph introspect`. `--trace` on
|
||||
whatever the sweep actually resolves). One raw namespace, `<node>.<param>` (a splice
|
||||
path keeps its interior path, e.g. `anchor.sess.period_minutes`), is the only
|
||||
user-facing axis name (#328): `graph introspect --params` and `--list-axes` are
|
||||
line-identical (open params bare, bound params with `default=`) and both print
|
||||
exactly what `--axis` binds, on both sweep routes. The wrapped
|
||||
`<blueprint>.<node>.<param>` form the probe still resolves against internally is
|
||||
retired from the surface, with a translation refusal naming the raw candidate on
|
||||
both intake seams (`--axis` and `campaign validate`). `--trace` on
|
||||
`sweep` / `walkforward` writes per-member traces on the real-data campaign path
|
||||
(depth-2 fan-out, chartable by the printed family handle, #224); the synthetic path
|
||||
still refuses (`run` / `mc` refuse `--trace` outright). The live trace-writer is the
|
||||
@@ -189,18 +196,42 @@ rejected at construction.
|
||||
|
||||
The op-script is a JSON **array of ops**, each object internally tagged by `"op"`,
|
||||
replayed in order; nodes are referenced **by identifier**, ports as dotted
|
||||
`<identifier>.<port>`. The ten verbs:
|
||||
`<identifier>.<port>`. The eleven verbs:
|
||||
|
||||
- `name` — `{"op":"name","name":<str>}` — set the composite's render name
|
||||
(#331), script-level and at-most-once (a second refuses); omitted, the
|
||||
built composite keeps the CLI's own seed default (`"graph"`). Gated by a
|
||||
shared shape check (non-empty, single path segment, no `/`, `\`, `.` or
|
||||
`..`) applied at this op intake **plus every CLI intake that reads an
|
||||
authored blueprint envelope from a file** — `register`, `introspect
|
||||
--content-id <FILE>`, the bare graph-file viewer, `run`, `introspect
|
||||
--params <FILE>`, `sweep --list-axes`, and each of
|
||||
`sweep`/`walkforward`/`mc`/`generalize`'s file-reading entry points
|
||||
(synthetic and `--real` alike) — one class of intake, one gate. The
|
||||
refusal's core sentence (`name_gate_fault_prose`) is byte-uniform across
|
||||
every one of these sites; only the leading context prefix varies —
|
||||
`run` and the bare graph-file viewer prepend `aura: <path>:`, while
|
||||
`register`, the sweep-axis family (`validate_and_register_axes`), and
|
||||
`introspect` prepend bare `aura: `. Store read-back (`reproduce`, `use`
|
||||
resolution, `introspect`/`--params` by content id) stays deliberately
|
||||
ungated — C29: a registered artifact is never retroactively invalidated.
|
||||
- `source` — `{"op":"source","role":<str>,"kind":<ScalarKind>}` — declare a root
|
||||
source role producing a base column of `kind`.
|
||||
- `input` — `{"op":"input","role":<str>}` — declare a root input role (kind inferred
|
||||
from the slots it feeds) — a reusable **pattern**'s formal parameter, left open
|
||||
until an enclosing graph wires it (#317's open patterns): `finish()` accepts an
|
||||
op-script that never binds it, only `compile`/bootstrap require it bound.
|
||||
- `add` — `{"op":"add","type":<TypeId>,"name":<str>?,"bind":{<param>:<Scalar>}?}` —
|
||||
- `add` — `{"op":"add","type":<TypeId>,"name":<str>?,"args":{<arg>:<str>}?,"bind":{<param>:<Scalar>}?}` —
|
||||
add a node of compiled-in type identity `type`; **`name` is its identifier**
|
||||
(mirrors the builder's `.named(...)`; defaults to the lowercased type label, so
|
||||
two unnamed nodes of one type collide); `bind` sets params.
|
||||
two unnamed nodes of one type collide); `args` (#271) configures an **arg-bearing**
|
||||
type's structural, non-scalar construction (`Session`'s IANA timezone/open,
|
||||
`LinComb`/`CostSum`'s arity) through the closed, load-time-validated `ArgKind`
|
||||
table (`Tz`/`TimeOfDay`/`Count`) — applied BEFORE `bind`, so a bound param
|
||||
`args` unlocks (e.g. `LinComb`'s `weights[i]`) is only bindable once `arity` is
|
||||
consumed; an arg-bearing type given no `args` refuses (`BadArg(MissingArg)`), a
|
||||
`Plain` type given `args` it does not declare also refuses
|
||||
(`BadArg(NotArgBearing)`); `bind` sets the (now real) params.
|
||||
- `feed` — `{"op":"feed","role":<str>,"into":[<port>,…]}` — fan a root role into
|
||||
interior input slots.
|
||||
- `connect` — `{"op":"connect","from":<port>,"to":<port>}` — wire an interior output
|
||||
@@ -262,12 +293,21 @@ Tier-1 (additive-optional) is serde-default silent-ignore with **no** `format_ve
|
||||
bump — a new optional field defaults to prior behaviour (C1). Tier-2 (must-understand:
|
||||
a new node type, edge semantics, or structural-axis kind) **bumps** `format_version` so
|
||||
an old reader refuses cleanly (`LoadError::UnsupportedVersion` / `UnknownNodeType`).
|
||||
**Pre-ship dormancy (#61):** until the first external ship there are no out-of-repo
|
||||
readers — reader and writer change atomically in one commit — so the Tier-2 bump
|
||||
discipline is dormant and structurally-semantic additions (the `gangs` section) land as
|
||||
additive-optional fields of v1; the first ship consciously freezes v1, gangs included,
|
||||
and activates the bump discipline. The per-section required-flag scheme is deferred (no
|
||||
current Tier-2 section to validate it; recorded on #156).
|
||||
**Pre-ship dormancy (#61, ended by #271):** until the first external ship there were
|
||||
no out-of-repo readers — reader and writer changed atomically in one commit — so the
|
||||
Tier-2 bump discipline lay dormant and structurally-semantic additions (the `gangs`
|
||||
section) landed as additive-optional fields of v1. #271's construction args are the
|
||||
first exercised Tier-2 bump (data-driven, next paragraph); v1 remains the version of
|
||||
every args-free document, gangs included. The per-section required-flag scheme stays
|
||||
deferred (no per-section case yet; recorded on #156).
|
||||
|
||||
**First data-driven bump (#271):** construction args are the first Tier-2 case whose
|
||||
version is decided PER DOCUMENT, not a single global bump. The writer emits
|
||||
`format_version: 1` for an args-free document (byte-identical to every pre-#271
|
||||
document — content ids stable) and `2` the moment any primitive, at any composite
|
||||
nesting depth, carries `args` — the must-understand signal an old (pre-#271) reader
|
||||
needs, since it cannot construct a pending arg-bearing type at all. The loader accepts
|
||||
the closed range `1..=2`.
|
||||
|
||||
### Enforcement shift — invariant 9 on the data plane
|
||||
|
||||
|
||||
+12
-4
@@ -11,6 +11,10 @@ Entries are alphabetical.
|
||||
|
||||
---
|
||||
|
||||
### arg kind
|
||||
**Avoid:** ArgKind, construction-arg type
|
||||
The closed vocabulary of construction-arg value shapes (`Tz`/`TimeOfDay`/`Count`, #271) — deliberately NOT a `scalar base type`: args are bootstrap metadata, never streamed, so the two closedness axes stay separate. Each kind's accepted strict form IS its canonical form (an exact IANA name; zero-padded `HH:MM`; a plain positive decimal count, no sign or leading zeros) — there is no normalization layer, so a near-miss string refuses rather than being rewritten. The per-kind forms are deliberately asymmetric (`"03"` refuses as a count while `"09:30"` requires the pad): canonical is each domain's conventional notation — fixed-width for wall-clock times, minimal for numbers.
|
||||
|
||||
### atomic sim unit
|
||||
**Avoid:** atomic unit, sim unit
|
||||
The primitive `(frozen topology + param-set + data-window + RNG-seed) → deterministic run → metrics` over which the four orchestration axes operate. One frozen-topology unit equals one harness instance.
|
||||
@@ -33,7 +37,7 @@ The param-generic, input-role-generic graph-as-data produced by running a Rust b
|
||||
|
||||
### blueprint label
|
||||
**Avoid:** —
|
||||
A registry-level name pointing at a registered blueprint's content id (`graph register --name <label>`, #317) — a mutable, latest-wins pointer over the immutable content-addressed store, not a second identity: re-registering under an existing label *repoints* it (the earlier content id stays reachable by its own id, never invalidated). `graph introspect --registered` lists every label with its content-id prefix and root `doc` line — the discovery surface a `use (op)` reference by name resolves against.
|
||||
A registry-level name pointing at a registered blueprint's content id (`graph register --name <label>`, #317) — a mutable, latest-wins pointer over the immutable content-addressed store, not a second identity: re-registering under an existing label *repoints* it (the earlier content id stays reachable by its own id, never invalidated). `graph introspect --registered` lists every label with its content-id prefix and root `doc` line — the discovery surface a `use (op)` reference by name resolves against. Orthogonal to the composite's own **render name** (the `name` field carried in the blueprint bytes, op-settable via `{"op":"name",...}`, #331; defaults to `"graph"` if omitted): the render name keys the run's trace directory (`traces/<name>/`) and a `use`-splice's default instance identifier, while the label is an explicit, separately-set store pointer.
|
||||
|
||||
### bootstrap
|
||||
**Avoid:** —
|
||||
@@ -67,6 +71,10 @@ The type-erased 64-bit word holding one scalar-base-type value with its kind str
|
||||
**Avoid:** —
|
||||
A node that wires a sub-graph and exposes one output (a combined signal, or a strategy) — composition is fractal and acyclic. May carry an optional authored rationale (`doc`, #125) — a non-load-bearing debug symbol shown in the graph tooltip, identity-blind like the name. Also names the multi-column stream a node emits: the **record** a producer's `eval` returns, bundling 1..K base scalar columns (e.g. OHLCV), each bound field-wise by a consumer (C7/C8).
|
||||
|
||||
### construction arg
|
||||
**Avoid:** construction-time param, arg
|
||||
A typed, closed, non-scalar construction input (`args`, #271) that configures an **arg-bearing** vocabulary type's structural shape — `Session`'s IANA timezone/open, `LinComb`/`CostSum`'s arity — through a second channel beside `bind`, consumed once at construction (`PrimitiveBuilder::try_args`) before any param binds, never streamed, never swept, never bound. Values are closed, load-time-validated strings (see `arg kind`); the accepted pairs are id-bearing (they enter the blueprint's content id, like `bind` values) and survive the identity projection.
|
||||
|
||||
### content id
|
||||
**Avoid:** —
|
||||
The SHA-256 (hex) of an artifact's byte-canonical form — for a blueprint, `blueprint_to_json` (the byte-exact identity that keys the reproduction store and anchors `aura reproduce`, stamped into a run manifest as the `topology hash`); for a `process document` / `campaign document`, its canonical JSON (keying the sibling registry stores). One primitive, library-hosted in `aura-research` (`content_id_of`; the CLI delegates). Surfaced as `aura graph|process|campaign introspect --content-id`; two artifacts share a content id only when their canonical bytes agree, debug names included — contrast `identity id`.
|
||||
@@ -263,7 +271,7 @@ The four streamed scalar kinds — `i64`, `f64`, `bool`, `timestamp` (a newtype
|
||||
|
||||
### session node
|
||||
**Avoid:** session window
|
||||
A node that exposes session context as a scalar stream so session logic stays inside the stream model. The shipped `Session` / `SessionFrankfurt` node (`aura-std`) emits ONE `i64` field, `bars_since_open` — the count of completed bar-periods since the local (tz-aware, DST-correct) session open, indexed off the just-closed bar's close instant (in-session closes read exact positive multiples: 09:15→1, 09:45→3; pre-open ≤0). `SessionFrankfurt` bakes the Frankfurt 09:00 `Europe/Berlin` open with a single `period_minutes` knob; its `trigger` input only clocks it once per completed bar (the value is ignored). Calendars and instrument specs remain metadata beside the hot path.
|
||||
A node that exposes session context as a scalar stream so session logic stays inside the stream model. The shipped `Session` / `SessionFrankfurt` node (`aura-market`) emits ONE `i64` field, `bars_since_open` — the count of completed bar-periods since the local (tz-aware, DST-correct) session open, indexed off the just-closed bar's close instant (in-session closes read exact positive multiples: 09:15→1, 09:45→3; pre-open ≤0). `Session` is roster-reachable for ANY IANA timezone/open through the typed **construction arg** channel (#271: `args: {"tz": <IANA name>, "open": "HH:MM"}`, applied before its one scalar param, `period_minutes`); `SessionFrankfurt` stays as baked sugar over the Frankfurt 09:00 `Europe/Berlin` open. Its `trigger` input only clocks it once per completed bar (the value is ignored). Calendars and instrument specs remain metadata beside the hot path.
|
||||
|
||||
### sign-agreement
|
||||
**Avoid:** sign consistency, market breadth
|
||||
@@ -323,7 +331,7 @@ The harness's structural parameterization — which strategy, instrument(s), bro
|
||||
|
||||
### sweep
|
||||
**Avoid:** param-sweep, parameter sweep
|
||||
An orchestration axis varying tuning params (grid or random) within a fixed structure. The inner, param-tuning loop, distinct from the structural experiment matrix. On a loaded blueprint every open knob (`--list-axes`) is **required** — a subset is refused with the missing knob named; pin an unwanted knob with a single-value axis (`--axis name=<one-value>`), there is no default. `aura sweep --axis` takes the `--list-axes`-printed, root-composite-wrapped name (e.g. `graph.fast.length`) — not the raw `param_space` name (`fast.length`) that `graph introspect --params` and a campaign document's axes use; the CLI strips exactly one leading wrapper segment (#210). The `aura sweep` CLI verb is now thin sugar over the `campaign document` path — its blueprint form (`<bp.json> --real`) translates to a generated, content-addressed campaign run through the one executor (#210); the built-in `--strategy` sweep surface was retired by #159 (its hard-wired harnesses removed) — sweep now runs from a blueprint + `--axis`, and a retired `--strategy` token falls to the generic usage error. A ganged pair contributes ONE axis.
|
||||
An orchestration axis varying tuning params (grid or random) within a fixed structure. The inner, param-tuning loop, distinct from the structural experiment matrix. On a loaded blueprint every open knob (`--list-axes`) is **required** — a subset is refused with the missing knob named; pin an unwanted knob with a single-value axis (`--axis name=<one-value>`), there is no default. One raw namespace, `<node>.<param>` (a splice path keeps its interior path), is the only axis name (#328): `graph introspect --params` and `aura sweep --list-axes` are line-identical (open params bare, bound params with `default=`), and `--axis` accepts exactly that raw form on both sweep routes — the older `<blueprint>.<node>.<param>` wrapped form (e.g. `graph.fast.length`) is retired from the surface; naming it on `--axis` refuses (`axis names are raw node.param paths — use "fast.length" …`), and quoting it in a campaign document gets a did-you-mean toward the raw candidate. The `aura sweep` CLI verb is now thin sugar over the `campaign document` path — its blueprint form (`<bp.json> --real`) translates to a generated, content-addressed campaign run through the one executor (#210); the built-in `--strategy` sweep surface was retired by #159 (its hard-wired harnesses removed) — sweep now runs from a blueprint + `--axis`, and a retired `--strategy` token falls to the generic usage error. A ganged pair contributes ONE axis.
|
||||
|
||||
### tap
|
||||
**Avoid:** probe, monitor, scope (for the observation slot — "probe" is taken by the sweep-terminal `blueprint_axis_probe` sense; the #77 `Recorder`→`Probe` rename was retired, 2026-07-21)
|
||||
@@ -339,7 +347,7 @@ The `content id` of a run's signal blueprint in its run-record role: stamped int
|
||||
|
||||
### use (op)
|
||||
**Avoid:** —
|
||||
The construction op that splices a **registered** blueprint into the building graph as a nested `composite` under a chosen instance name (`{"op":"use","ref":{"content_id"|"name":…},"name":…,"bind":{…}}`, #317): the reference — a content id, a unique content-id prefix, or a `blueprint label` — resolves CLI-side, before the engine ever sees it; the fetched composite is C29-gated, spliced, and its resolution echoed on stderr (`aura: note:`, the existing benign marker). The emitted blueprint carries the splice inline as an ordinary nested composite — no reference or registry dependency survives into the artifact. An instance's open input roles become its `<instance>.<role>` in-ports, its output boundary fields its `<instance>.<field>` out-fields — the existing nested-composite param-prefix discipline extends unchanged (`graph.<instance>.<node>.<param>` on `sweep --list-axes`). Pairs with the `input` op: a pattern meant to be `use`d declares its formal parameters as open `input` roles, left unbound at `finish()` (#317's open-pattern amendment) — only running it standalone requires them bound.
|
||||
The construction op that splices a **registered** blueprint into the building graph as a nested `composite` under a chosen instance name (`{"op":"use","ref":{"content_id"|"name":…},"name":…,"bind":{…}}`, #317): the reference — a content id, a unique content-id prefix, or a `blueprint label` — resolves CLI-side, before the engine ever sees it; the fetched composite is C29-gated, spliced, and its resolution echoed on stderr (`aura: note:`, the existing benign marker). The emitted blueprint carries the splice inline as an ordinary nested composite — no reference or registry dependency survives into the artifact. An instance's open input roles become its `<instance>.<role>` in-ports, its output boundary fields its `<instance>.<field>` out-fields — the existing nested-composite param-prefix discipline extends unchanged (`<instance>.<node>.<param>`, the raw axis form since #328). Pairs with the `input` op: a pattern meant to be `use`d declares its formal parameters as open `input` roles, left unbound at `finish()` (#317's open-pattern amendment) — only running it standalone requires them bound.
|
||||
|
||||
### veto
|
||||
**Avoid:** risk-manager
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# Fieldtest transcript — cycle #271 (typed construction args)
|
||||
|
||||
Verbatim command/output capture for the `fieldtests/construction-args/` fixtures.
|
||||
Binary: `target/debug/aura` built from HEAD `6ca359a` via `cargo build --workspace`
|
||||
(debug profile). Runs used a scratch project `aura new ca_lab` (in `/tmp/ca_lab`);
|
||||
real data resolved from the built-in archive (`/mnt/tickdata/Pepperstone`). All
|
||||
op-scripts authored from the public interface only (README, authoring guide,
|
||||
glossary, design ledger C24, `aura graph introspect`).
|
||||
|
||||
Public interface only — no crate source read.
|
||||
|
||||
---
|
||||
|
||||
## Discovery loop (axis 3) — is the args grammar learnable from the binary?
|
||||
|
||||
```
|
||||
$ aura graph introspect --vocabulary | wc -l
|
||||
36
|
||||
|
||||
$ aura graph introspect --node Session
|
||||
Session — bars elapsed since the session open, from the configured open time and timezone
|
||||
arg tz: Tz (IANA timezone name, e.g. Europe/Berlin)
|
||||
arg open: TimeOfDay (local wall-clock HH:MM)
|
||||
note ports and params form at construction; args are required
|
||||
|
||||
$ aura graph introspect --node LinComb
|
||||
LinComb — linear combination of its inputs with constant weights
|
||||
arg arity: Count (positive integer count)
|
||||
note ports and params form at construction; args are required
|
||||
|
||||
$ aura graph introspect --node CostSum
|
||||
CostSum — sums cost-model contributions into one cost-in-R stream
|
||||
arg n_costs: Count (positive integer count)
|
||||
note ports and params form at construction; args are required
|
||||
```
|
||||
|
||||
The arg *grammar* (name, kind, hint) is fully self-describing. But `--node <T>`
|
||||
stops at the note — it does NOT reveal the post-construction ports/params. To
|
||||
learn the port names you must author a partial op-list carrying the args and pipe
|
||||
it through `--unwired`; to learn the param names you must build and `--list-axes`.
|
||||
Nothing on the `--node` surface names those follow-up steps. First naive guess at
|
||||
LinComb's input port was `lc.in0` — wrong:
|
||||
|
||||
```
|
||||
$ echo '[{"op":"add","type":"LinComb","name":"lc","args":{"arity":"3"}}]' \
|
||||
| aura graph introspect --unwired
|
||||
lc.term[0]:F64
|
||||
lc.term[1]:F64
|
||||
lc.term[2]:F64
|
||||
|
||||
$ echo '[{"op":"add","type":"CostSum","name":"cs","args":{"n_costs":"3"}}]' \
|
||||
| aura graph introspect --unwired
|
||||
cs.cost[0].cost_in_r:F64
|
||||
cs.cost[0].cum_cost_in_r:F64
|
||||
cs.cost[0].open_cost_in_r:F64
|
||||
cs.cost[1].cost_in_r:F64 (… ×3 slots, 3 fields each)
|
||||
```
|
||||
|
||||
Weight param names, discovered only after a build:
|
||||
|
||||
```
|
||||
$ aura graph build < lc_probe.ops.json > lc_probe.bp.json
|
||||
$ aura sweep lc_probe.bp.json --list-axes
|
||||
graph.lc.weights[0]:F64 # open by default → sweepable (C24)
|
||||
graph.lc.weights[1]:F64
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 1 — `ca_1_ny_session` (axis 1: non-Frankfurt Session as data)
|
||||
|
||||
```
|
||||
$ aura graph build < ca_1_ny_session.ops.json > ca_1_ny_session.bp.json
|
||||
$ head -c 25 ca_1_ny_session.bp.json
|
||||
{"format_version":2,"blue # arg-bearing → v2
|
||||
|
||||
$ aura graph introspect --content-id --identity-id < ca_1_ny_session.ops.json
|
||||
5b085a5b3f411072e48d4b54717b3d09e88be50b0bd797d345c2ee90baa41c37 # content id
|
||||
4bf0513a4d2e852ab580fd8846737f57c1825b944ff9df1a48040a07fe92446b # identity id
|
||||
|
||||
# --- inside ca_lab/ ---
|
||||
$ aura run ca_1_ny_session.bp.json --real US500 --from 1725148800000 --to 1725580800000
|
||||
{"manifest":{... "topology_hash":"5b085a5b...b40d95cf"? -> 5b085a5b... (== content id) ...},
|
||||
"metrics":{... "expectancy_r":0.7853745294474523,"n_trades":18 ...}} (exit 0)
|
||||
|
||||
# synthetic refuses cleanly (OHLC strategy, close-only synthetic stream):
|
||||
$ aura run ca_1_ny_session.bp.json
|
||||
aura: strategy "graph" consumes columns beyond close (open, high, low) —
|
||||
synthetic data generates a close series only; run with --real <SYMBOL>
|
||||
```
|
||||
|
||||
Full report captured in `ca_1_ny_session.run.json`.
|
||||
|
||||
---
|
||||
|
||||
## Example 2 — `ca_2_lincomb_blend` (axis 2: LinComb arity + weights + swept period)
|
||||
|
||||
Three SMA spreads blended through `LinComb(arity 3)` with bound `weights[i]`,
|
||||
NY-session-gated, `Session.period_minutes` left OPEN (sweepable).
|
||||
|
||||
```
|
||||
$ aura graph build < ca_2_lincomb_blend.ops.json > ca_2_lincomb_blend.bp.json
|
||||
$ head -c 25 ca_2_lincomb_blend.bp.json
|
||||
{"format_version":2,"blue
|
||||
|
||||
$ aura sweep ca_2_lincomb_blend.bp.json --list-axes
|
||||
graph.sess.period_minutes:I64 # open
|
||||
graph.lc.weights[0]:F64 default=0.5 # bound (re-openable by --axis)
|
||||
graph.lc.weights[1]:F64 default=0.3
|
||||
graph.lc.weights[2]:F64 default=0.2
|
||||
...
|
||||
|
||||
# --- inside ca_lab/ --- sweep the arity-unlocked weight AND the period param:
|
||||
$ aura sweep ca_2_lincomb_blend.bp.json --real US500 --from 1725148800000 --to 1725580800000 \
|
||||
--axis graph.sess.period_minutes=15,30 --axis 'graph.lc.weights[0]=0.3,0.5' --name ca2_blend
|
||||
{"family_id":"caa3508a-0-US500-w0-r0-s0-0", ... 4 members ...} (exit 0)
|
||||
|
||||
$ aura reproduce caa3508a-0-US500-w0-r0-s0-0
|
||||
... member graph.lc.weights[0]=0.3, graph.sess.period_minutes=15 ... reproduced: bit-identical
|
||||
... (×4)
|
||||
reproduced 4/4 members bit-identically
|
||||
```
|
||||
|
||||
Sweep output in `ca_2_lincomb_blend.sweep.jsonl`. NB: at this gating, the two
|
||||
`weights[0]` values yield identical member metrics (the gated blend's sign is
|
||||
weight-insensitive here) — a semantic property of the strategy, not the arg
|
||||
channel; the params bound and swept correctly.
|
||||
|
||||
CostSum arity probe: `ca_2b_costsum_arity.ops.json` → `--unwired` shows
|
||||
`cost[i].{cost_in_r,cum_cost_in_r,open_cost_in_r}` (3 slots × 3 fields). Wiring
|
||||
CostSum end to end needs the RiskExecutor context (cost nodes take
|
||||
`closed/open/entry_price/stop_price`) — no op-script path for that exists in the
|
||||
public corpus (see finding SG2).
|
||||
|
||||
---
|
||||
|
||||
## Example 3 — refusal battery (axis 4: strict-form refusal prose)
|
||||
|
||||
Every case: `aura graph build < FIXTURE`, stderr + exit code.
|
||||
|
||||
```
|
||||
ca_3a_bad_tz aura: op 1 (add): node sess arg "tz" expects Tz (IANA timezone name, e.g. Europe/Berlin) — got "berlin" exit 1
|
||||
ca_3b_unpadded_time aura: op 1 (add): node sess arg "open" expects TimeOfDay (local wall-clock HH:MM) — got "9:30" exit 1
|
||||
ca_3c_zero_count aura: op 2 (add): node lc arg "arity" expects Count (positive integer count) — got "0" exit 1
|
||||
ca_3d_garbage_count aura: op 1 (add): node lc arg "arity" expects Count (positive integer count) — got "three" exit 1
|
||||
ca_3e_missing_arg aura: op 1 (add): node sess is missing required arg "tz" exit 1
|
||||
ca_3f_args_on_argless aura: op 1 (add): node a takes no construction args exit 1
|
||||
ca_3g_unknown_arg_key aura: op 1 (add): node sess has no construction arg "region" exit 1
|
||||
ca_3h_partial_args aura: op 1 (add): node sess is missing required arg "open" exit 1
|
||||
```
|
||||
|
||||
Edge-form strictness (ad-hoc, `--unwired`):
|
||||
|
||||
```
|
||||
Count arity: "3"→ok "03"→refuse "+3"→refuse "3.0"→refuse "1e1"→refuse "-2"→refuse " 3"→refuse "0x3"→refuse
|
||||
Time open: "09:30"→ok "00:00"→ok "23:59"→ok "24:00"→refuse "09:60"→refuse "9:05"→refuse "09:5"→refuse "09:30:00"→refuse "0930"→refuse
|
||||
```
|
||||
|
||||
Note the padding asymmetry: `open` REQUIRES a zero-padded hour (`09:05`), but
|
||||
`arity` REFUSES a leading zero (`03`). See finding SG1.
|
||||
|
||||
---
|
||||
|
||||
## Example 4 — `ca_4` register → use (axis 5: #317 interplay)
|
||||
|
||||
```
|
||||
$ aura graph build < ca_4_ny_anchor_pattern.ops.json > ca_4_ny_anchor_pattern.bp.json
|
||||
$ head -c 25 ca_4_ny_anchor_pattern.bp.json
|
||||
{"format_version":2,"blue
|
||||
$ aura graph introspect --content-id --identity-id < ca_4_ny_anchor_pattern.ops.json
|
||||
d51132463b2d1780b3ed9b83dc9f9a229a9d6b93fce3e15c7c0572970e2e4564
|
||||
863c89ddeaa2dfe7d79c7e55944263145399c67397a27c0955bdd768cc6ed72e
|
||||
|
||||
# --- inside ca_lab/ ---
|
||||
$ aura graph register .../ca_4_ny_anchor_pattern.bp.json --name ny_anchor
|
||||
registered blueprint d51132463b2d... (.../runs/blueprints/d51132463b2d....json)
|
||||
label "ny_anchor" -> d51132463b2d...
|
||||
|
||||
$ aura graph introspect --registered
|
||||
ny_anchor d51132463b2d NY cash-open session anchor: bars elapsed since the 09:30 America/New_York open
|
||||
|
||||
$ aura graph build < ca_4_consumer.ops.json > consumer.bp.json
|
||||
# stderr: aura: note: use "anchor": ny_anchor -> d51132463b2d...
|
||||
$ head -c 25 consumer.bp.json
|
||||
{"format_version":2,"blue # v2 propagates THROUGH the splice
|
||||
$ aura graph introspect --content-id --identity-id < ca_4_consumer.ops.json
|
||||
985991f62cc6b92895018312c5ea86e493ef1c09dd53cf6e0813551baa97b671
|
||||
bbe7060f4136255e594e1b01242ae80c3f5decfc543f23198d66adff6ce52a7b
|
||||
|
||||
$ aura run consumer.bp.json --real US500 --from 1725148800000 --to 1725580800000
|
||||
{"manifest":{... "topology_hash":"985991f6..." (== content id),
|
||||
"defaults":[["graph.anchor.sess.period_minutes",{"I64":15}], ...] ...},
|
||||
"metrics":{... "n_trades":8 ...}} (exit 0)
|
||||
|
||||
# doc gate on the args-bearing composite (doc stripped):
|
||||
$ aura graph register ca4_docless.bp.json --name ny_docless
|
||||
aura: blueprint: composite `graph` carries no doc — a registered composite
|
||||
describes itself (C29); add a doc line (...) before register exit 1
|
||||
```
|
||||
|
||||
Consumer report captured in `ca_4_consumer.run.json`.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting confirmations
|
||||
|
||||
```
|
||||
# args-free document stays format_version 1 (byte-stability invariant, C18):
|
||||
$ echo '[{"op":"source",...},{"op":"add","type":"SMA",...},...]' | aura graph build | head -c 25
|
||||
{"format_version":1,"blue
|
||||
|
||||
# content id deterministic across builds:
|
||||
$ aura graph introspect --content-id < ca_1_ny_session.ops.json (×2) -> identical
|
||||
5b085a5b3f411072e48d4b54717b3d09e88be50b0bd797d345c2ee90baa41c37
|
||||
5b085a5b3f411072e48d4b54717b3d09e88be50b0bd797d345c2ee90baa41c37
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":2,"blueprint":{"name":"graph","nodes":[{"primitive":{"type":"Resample","name":"c15","bound":[{"pos":0,"name":"period_minutes","kind":"I64","value":{"I64":15}}]}},{"primitive":{"type":"Sub","name":"body"}},{"primitive":{"type":"Sign","name":"dir"}},{"primitive":{"type":"Session","name":"sess","args":[{"name":"tz","value":"America/New_York"},{"name":"open","value":"09:30"}],"bound":[{"pos":0,"name":"period_minutes","kind":"I64","value":{"I64":15}}]}},{"primitive":{"type":"EqConst","name":"isfirst","bound":[{"pos":0,"name":"target","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"When","name":"firstmom"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":1.0}}]}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":3},{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":0,"to":3,"slot":0,"from_field":3},{"from":3,"to":4,"slot":0,"from_field":0},{"from":2,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":5,"to":6,"slot":0,"from_field":0}],"input_roles":[{"name":"open","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"high","targets":[{"node":0,"slot":1}],"source":"F64"},{"name":"low","targets":[{"node":0,"slot":2}],"source":"F64"},{"name":"close","targets":[{"node":0,"slot":3}],"source":"F64"}],"output":[{"node":6,"field":0,"name":"bias"}]}}
|
||||
@@ -0,0 +1,28 @@
|
||||
[
|
||||
{"op": "source", "role": "open", "kind": "F64"},
|
||||
{"op": "source", "role": "high", "kind": "F64"},
|
||||
{"op": "source", "role": "low", "kind": "F64"},
|
||||
{"op": "source", "role": "close", "kind": "F64"},
|
||||
{"op": "add", "type": "Resample", "name": "c15", "bind": {"period_minutes": {"I64": 15}}},
|
||||
{"op": "feed", "role": "open", "into": ["c15.open"]},
|
||||
{"op": "feed", "role": "high", "into": ["c15.high"]},
|
||||
{"op": "feed", "role": "low", "into": ["c15.low"]},
|
||||
{"op": "feed", "role": "close", "into": ["c15.close"]},
|
||||
{"op": "add", "type": "Sub", "name": "body"},
|
||||
{"op": "connect", "from": "c15.close", "to": "body.lhs"},
|
||||
{"op": "connect", "from": "c15.open", "to": "body.rhs"},
|
||||
{"op": "add", "type": "Sign", "name": "dir"},
|
||||
{"op": "connect", "from": "body.value", "to": "dir.value"},
|
||||
{"op": "add", "type": "Session", "name": "sess",
|
||||
"args": {"tz": "America/New_York", "open": "09:30"},
|
||||
"bind": {"period_minutes": {"I64": 15}}},
|
||||
{"op": "connect", "from": "c15.close", "to": "sess.trigger"},
|
||||
{"op": "add", "type": "EqConst", "name": "isfirst", "bind": {"target": {"I64": 1}}},
|
||||
{"op": "connect", "from": "sess.bars_since_open", "to": "isfirst.value"},
|
||||
{"op": "add", "type": "When", "name": "firstmom"},
|
||||
{"op": "connect", "from": "dir.value", "to": "firstmom.value"},
|
||||
{"op": "connect", "from": "isfirst.value", "to": "firstmom.gate"},
|
||||
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 1.0}}},
|
||||
{"op": "connect", "from": "firstmom.value", "to": "bias.signal"},
|
||||
{"op": "expose", "from": "bias.bias", "as": "bias"}
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
[1m"manifest"[0m: {
|
||||
[1m"commit"[0m: [32m"6ca359ae00e8e7921f08557be383bc2d0a2ce199"[0m,
|
||||
[1m"params"[0m: [],
|
||||
[1m"defaults"[0m: [
|
||||
[
|
||||
[32m"graph.c15.period_minutes"[0m,
|
||||
{
|
||||
[1m"I64"[0m: [33m15[0m
|
||||
}
|
||||
],
|
||||
[
|
||||
[32m"graph.sess.period_minutes"[0m,
|
||||
{
|
||||
[1m"I64"[0m: [33m15[0m
|
||||
}
|
||||
],
|
||||
[
|
||||
[32m"graph.isfirst.target"[0m,
|
||||
{
|
||||
[1m"I64"[0m: [33m1[0m
|
||||
}
|
||||
],
|
||||
[
|
||||
[32m"graph.bias.scale"[0m,
|
||||
{
|
||||
[1m"F64"[0m: [33m1.0[0m
|
||||
}
|
||||
]
|
||||
],
|
||||
[1m"window"[0m: [
|
||||
[33m1725235200000000000[0m,
|
||||
[33m1725580800000000000[0m
|
||||
],
|
||||
[1m"seed"[0m: [33m0[0m,
|
||||
[1m"broker"[0m: [32m"sim-optimal+risk-executor(pip_size=1)"[0m,
|
||||
[1m"topology_hash"[0m: [32m"5b085a5b3f411072e48d4b54717b3d09e88be50b0bd797d345c2ee90baa41c37"[0m,
|
||||
[1m"project"[0m: {
|
||||
[1m"commit"[0m: [32m"d7e3a8f39881cc8eba42a906fa846676bfbb1010"[0m
|
||||
}
|
||||
},
|
||||
[1m"metrics"[0m: {
|
||||
[1m"total_pips"[0m: [33m8.700000000000728[0m,
|
||||
[1m"max_drawdown"[0m: [33m77.0[0m,
|
||||
[1m"bias_sign_flips"[0m: [33m2[0m,
|
||||
[1m"r"[0m: {
|
||||
[1m"expectancy_r"[0m: [33m0.7853745294474523[0m,
|
||||
[1m"n_trades"[0m: [33m18[0m,
|
||||
[1m"win_rate"[0m: [33m0.1111111111111111[0m,
|
||||
[1m"avg_win_r"[0m: [33m17.115118267387366[0m,
|
||||
[1m"avg_loss_r"[0m: [33m-1.2558434377950367[0m,
|
||||
[1m"profit_factor"[0m: [33m1.7035481645543986[0m,
|
||||
[1m"max_r_drawdown"[0m: [33m16.041140339815094[0m,
|
||||
[1m"n_open_at_end"[0m: [33m1[0m,
|
||||
[1m"sqn"[0m: [33m0.4343599983483773[0m,
|
||||
[1m"sqn_normalized"[0m: [33m0.4343599983483773[0m,
|
||||
[1m"net_expectancy_r"[0m: [33m0.7853745294474523[0m,
|
||||
[1m"conviction_terciles_r"[0m: [
|
||||
[33m-1.3650686711197901[0m,
|
||||
[33m-1.2338202185443914[0m,
|
||||
[33m4.955012478006539[0m
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":2,"blueprint":{"name":"graph","doc":"NY-session-gated blend of three SMA spreads via a LinComb(arity 3); sweepable session period","nodes":[{"primitive":{"type":"SMA","name":"a","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"b","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":5}}]}},{"primitive":{"type":"SMA","name":"c","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":10}}]}},{"primitive":{"type":"Sub","name":"sA"}},{"primitive":{"type":"Sub","name":"sB"}},{"primitive":{"type":"Sub","name":"sC"}},{"primitive":{"type":"LinComb","name":"lc","args":[{"name":"arity","value":"3"}],"bound":[{"pos":0,"name":"weights[0]","kind":"F64","value":{"F64":0.5}},{"pos":1,"name":"weights[1]","kind":"F64","value":{"F64":0.3}},{"pos":2,"name":"weights[2]","kind":"F64","value":{"F64":0.2}}]}},{"primitive":{"type":"Session","name":"sess","args":[{"name":"tz","value":"America/New_York"},{"name":"open","value":"09:30"}]}},{"primitive":{"type":"EqConst","name":"insession","bound":[{"pos":0,"name":"target","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"When","name":"gated"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":1.0}}]}}],"edges":[{"from":0,"to":3,"slot":0,"from_field":0},{"from":2,"to":3,"slot":1,"from_field":0},{"from":1,"to":4,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":0,"to":5,"slot":0,"from_field":0},{"from":1,"to":5,"slot":1,"from_field":0},{"from":3,"to":6,"slot":0,"from_field":0},{"from":4,"to":6,"slot":1,"from_field":0},{"from":5,"to":6,"slot":2,"from_field":0},{"from":7,"to":8,"slot":0,"from_field":0},{"from":6,"to":9,"slot":0,"from_field":0},{"from":8,"to":9,"slot":1,"from_field":0},{"from":9,"to":10,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":2,"slot":0},{"node":7,"slot":0}],"source":"F64"}],"output":[{"node":10,"field":0,"name":"bias"}]}}
|
||||
@@ -0,0 +1,33 @@
|
||||
[
|
||||
{"op": "doc", "text": "NY-session-gated blend of three SMA spreads via a LinComb(arity 3); sweepable session period"},
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "add", "type": "SMA", "name": "a", "bind": {"length": {"I64": 2}}},
|
||||
{"op": "add", "type": "SMA", "name": "b", "bind": {"length": {"I64": 5}}},
|
||||
{"op": "add", "type": "SMA", "name": "c", "bind": {"length": {"I64": 10}}},
|
||||
{"op": "feed", "role": "price", "into": ["a.series", "b.series", "c.series"]},
|
||||
{"op": "add", "type": "Sub", "name": "sA"},
|
||||
{"op": "connect", "from": "a.value", "to": "sA.lhs"},
|
||||
{"op": "connect", "from": "c.value", "to": "sA.rhs"},
|
||||
{"op": "add", "type": "Sub", "name": "sB"},
|
||||
{"op": "connect", "from": "b.value", "to": "sB.lhs"},
|
||||
{"op": "connect", "from": "c.value", "to": "sB.rhs"},
|
||||
{"op": "add", "type": "Sub", "name": "sC"},
|
||||
{"op": "connect", "from": "a.value", "to": "sC.lhs"},
|
||||
{"op": "connect", "from": "b.value", "to": "sC.rhs"},
|
||||
{"op": "add", "type": "LinComb", "name": "lc", "args": {"arity": "3"},
|
||||
"bind": {"weights[0]": {"F64": 0.5}, "weights[1]": {"F64": 0.3}, "weights[2]": {"F64": 0.2}}},
|
||||
{"op": "connect", "from": "sA.value", "to": "lc.term[0]"},
|
||||
{"op": "connect", "from": "sB.value", "to": "lc.term[1]"},
|
||||
{"op": "connect", "from": "sC.value", "to": "lc.term[2]"},
|
||||
{"op": "add", "type": "Session", "name": "sess",
|
||||
"args": {"tz": "America/New_York", "open": "09:30"}},
|
||||
{"op": "feed", "role": "price", "into": ["sess.trigger"]},
|
||||
{"op": "add", "type": "EqConst", "name": "insession", "bind": {"target": {"I64": 1}}},
|
||||
{"op": "connect", "from": "sess.bars_since_open", "to": "insession.value"},
|
||||
{"op": "add", "type": "When", "name": "gated"},
|
||||
{"op": "connect", "from": "lc.value", "to": "gated.value"},
|
||||
{"op": "connect", "from": "insession.value", "to": "gated.gate"},
|
||||
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 1.0}}},
|
||||
{"op": "connect", "from": "gated.value", "to": "bias.signal"},
|
||||
{"op": "expose", "from": "bias.bias", "as": "bias"}
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
{"family_id":"a457bcb1-0-US500-w0-r0-s0-0","report":{"manifest":{"commit":"6ca359ae00e8e7921f08557be383bc2d0a2ce199","params":[["graph.lc.weights[0]",{"F64":0.3}],["graph.sess.period_minutes",{"I64":15}],["stop_length",{"I64":3}],["stop_k",{"F64":2.0}]],"defaults":[["graph.a.length",{"I64":2}],["graph.b.length",{"I64":5}],["graph.c.length",{"I64":10}],["graph.lc.weights[1]",{"F64":0.3}],["graph.lc.weights[2]",{"F64":0.2}],["graph.insession.target",{"I64":1}],["graph.bias.scale",{"F64":1.0}]],"window":[1725235200000000000,1725580800000000000],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=1)","instrument":"US500","topology_hash":"ef8e0a4eb23bda78ae3914bb3bcc742314750827498e841fb2a2a7f9aea4c864","project":{"commit":"d7e3a8f39881cc8eba42a906fa846676bfbb1010"}},"metrics":{"total_pips":-96.06109999999117,"max_drawdown":137.94459999999816,"bias_sign_flips":10,"r":{"expectancy_r":-0.709681826087026,"n_trades":37,"win_rate":0.13513513513513514,"avg_win_r":3.454890860540305,"avg_loss_r":-1.3603963083725465,"profit_factor":0.3968157614344175,"max_r_drawdown":28.676655116297415,"n_open_at_end":1,"sqn":-1.7501753655158518,"sqn_normalized":-1.7501753655158518,"net_expectancy_r":-0.709681826087026,"conviction_terciles_r":[0.010138828240044861,-1.4112281254600723,-0.7265504614292024]}}}}
|
||||
{"family_id":"a457bcb1-0-US500-w0-r0-s0-0","report":{"manifest":{"commit":"6ca359ae00e8e7921f08557be383bc2d0a2ce199","params":[["graph.lc.weights[0]",{"F64":0.3}],["graph.sess.period_minutes",{"I64":30}],["stop_length",{"I64":3}],["stop_k",{"F64":2.0}]],"defaults":[["graph.a.length",{"I64":2}],["graph.b.length",{"I64":5}],["graph.c.length",{"I64":10}],["graph.lc.weights[1]",{"F64":0.3}],["graph.lc.weights[2]",{"F64":0.2}],["graph.insession.target",{"I64":1}],["graph.bias.scale",{"F64":1.0}]],"window":[1725235200000000000,1725580800000000000],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=1)","instrument":"US500","topology_hash":"ef8e0a4eb23bda78ae3914bb3bcc742314750827498e841fb2a2a7f9aea4c864","project":{"commit":"d7e3a8f39881cc8eba42a906fa846676bfbb1010"}},"metrics":{"total_pips":-53.55989999998029,"max_drawdown":120.4151999999832,"bias_sign_flips":18,"r":{"expectancy_r":-0.05940492516372425,"n_trades":34,"win_rate":0.23529411764705882,"avg_win_r":3.3843995517241234,"avg_loss_r":-1.1190370718984466,"profit_factor":0.9305801696597508,"max_r_drawdown":18.95335695076066,"n_open_at_end":1,"sqn":-0.13880221854288846,"sqn_normalized":-0.13880221854288846,"net_expectancy_r":-0.05940492516372425,"conviction_terciles_r":[0.92760843970395,-1.1133959931000275,0.001991302649185419]}}}}
|
||||
{"family_id":"a457bcb1-0-US500-w0-r0-s0-0","report":{"manifest":{"commit":"6ca359ae00e8e7921f08557be383bc2d0a2ce199","params":[["graph.lc.weights[0]",{"F64":0.5}],["graph.sess.period_minutes",{"I64":15}],["stop_length",{"I64":3}],["stop_k",{"F64":2.0}]],"defaults":[["graph.a.length",{"I64":2}],["graph.b.length",{"I64":5}],["graph.c.length",{"I64":10}],["graph.lc.weights[1]",{"F64":0.3}],["graph.lc.weights[2]",{"F64":0.2}],["graph.insession.target",{"I64":1}],["graph.bias.scale",{"F64":1.0}]],"window":[1725235200000000000,1725580800000000000],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=1)","instrument":"US500","topology_hash":"ef8e0a4eb23bda78ae3914bb3bcc742314750827498e841fb2a2a7f9aea4c864","project":{"commit":"d7e3a8f39881cc8eba42a906fa846676bfbb1010"}},"metrics":{"total_pips":-90.72209999998813,"max_drawdown":138.7875999999975,"bias_sign_flips":10,"r":{"expectancy_r":-0.709681826087026,"n_trades":37,"win_rate":0.13513513513513514,"avg_win_r":3.454890860540305,"avg_loss_r":-1.3603963083725465,"profit_factor":0.3968157614344175,"max_r_drawdown":28.676655116297415,"n_open_at_end":1,"sqn":-1.7501753655158518,"sqn_normalized":-1.7501753655158518,"net_expectancy_r":-0.709681826087026,"conviction_terciles_r":[0.010138828240044861,-1.294302553607114,-0.8344817585242409]}}}}
|
||||
{"family_id":"a457bcb1-0-US500-w0-r0-s0-0","report":{"manifest":{"commit":"6ca359ae00e8e7921f08557be383bc2d0a2ce199","params":[["graph.lc.weights[0]",{"F64":0.5}],["graph.sess.period_minutes",{"I64":30}],["stop_length",{"I64":3}],["stop_k",{"F64":2.0}]],"defaults":[["graph.a.length",{"I64":2}],["graph.b.length",{"I64":5}],["graph.c.length",{"I64":10}],["graph.lc.weights[1]",{"F64":0.3}],["graph.lc.weights[2]",{"F64":0.2}],["graph.insession.target",{"I64":1}],["graph.bias.scale",{"F64":1.0}]],"window":[1725235200000000000,1725580800000000000],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=1)","instrument":"US500","topology_hash":"ef8e0a4eb23bda78ae3914bb3bcc742314750827498e841fb2a2a7f9aea4c864","project":{"commit":"d7e3a8f39881cc8eba42a906fa846676bfbb1010"}},"metrics":{"total_pips":-49.96429999999597,"max_drawdown":125.2664000000041,"bias_sign_flips":18,"r":{"expectancy_r":-0.05940492516372425,"n_trades":34,"win_rate":0.23529411764705882,"avg_win_r":3.3843995517241234,"avg_loss_r":-1.1190370718984466,"profit_factor":0.9305801696597508,"max_r_drawdown":18.95335695076066,"n_open_at_end":1,"sqn":-0.13880221854288846,"sqn_normalized":-0.13880221854288846,"net_expectancy_r":-0.05940492516372425,"conviction_terciles_r":[0.92760843970395,-0.9291244696270301,-0.16692426053439502]}}}}
|
||||
@@ -0,0 +1,3 @@
|
||||
[
|
||||
{"op": "add", "type": "CostSum", "name": "cs", "args": {"n_costs": "3"}}
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "add", "type": "Session", "name": "sess",
|
||||
"args": {"tz": "berlin", "open": "09:30"},
|
||||
"bind": {"period_minutes": {"I64": 15}}},
|
||||
{"op": "feed", "role": "price", "into": ["sess.trigger"]},
|
||||
{"op": "expose", "from": "sess.bars_since_open", "as": "bars"}
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "add", "type": "Session", "name": "sess",
|
||||
"args": {"tz": "America/New_York", "open": "9:30"},
|
||||
"bind": {"period_minutes": {"I64": 15}}},
|
||||
{"op": "feed", "role": "price", "into": ["sess.trigger"]},
|
||||
{"op": "expose", "from": "sess.bars_since_open", "as": "bars"}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "add", "type": "SMA", "name": "a", "bind": {"length": {"I64": 2}}},
|
||||
{"op": "add", "type": "LinComb", "name": "lc", "args": {"arity": "0"}},
|
||||
{"op": "feed", "role": "price", "into": ["a.series"]},
|
||||
{"op": "expose", "from": "lc.value", "as": "out"}
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "add", "type": "LinComb", "name": "lc", "args": {"arity": "three"}},
|
||||
{"op": "expose", "from": "lc.value", "as": "out"}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "add", "type": "Session", "name": "sess",
|
||||
"bind": {"period_minutes": {"I64": 15}}},
|
||||
{"op": "feed", "role": "price", "into": ["sess.trigger"]},
|
||||
{"op": "expose", "from": "sess.bars_since_open", "as": "bars"}
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "add", "type": "SMA", "name": "a",
|
||||
"args": {"window": "3"},
|
||||
"bind": {"length": {"I64": 2}}},
|
||||
{"op": "feed", "role": "price", "into": ["a.series"]},
|
||||
{"op": "expose", "from": "a.value", "as": "out"}
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "add", "type": "Session", "name": "sess",
|
||||
"args": {"tz": "America/New_York", "open": "09:30", "region": "east"},
|
||||
"bind": {"period_minutes": {"I64": 15}}},
|
||||
{"op": "feed", "role": "price", "into": ["sess.trigger"]},
|
||||
{"op": "expose", "from": "sess.bars_since_open", "as": "bars"}
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "add", "type": "Session", "name": "sess",
|
||||
"args": {"tz": "America/New_York"},
|
||||
"bind": {"period_minutes": {"I64": 15}}},
|
||||
{"op": "feed", "role": "price", "into": ["sess.trigger"]},
|
||||
{"op": "expose", "from": "sess.bars_since_open", "as": "bars"}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":2,"blueprint":{"name":"graph","doc":"consumer: splice the NY session anchor by name, go long 1.0 on the first session bar, flat otherwise","nodes":[{"composite":{"name":"anchor","doc":"NY cash-open session anchor: bars elapsed since the 09:30 America/New_York open","nodes":[{"primitive":{"type":"Session","name":"sess","args":[{"name":"tz","value":"America/New_York"},{"name":"open","value":"09:30"}],"bound":[{"pos":0,"name":"period_minutes","kind":"I64","value":{"I64":15}}]}}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0}]}],"output":[{"node":0,"field":0,"name":"bars"}]}},{"primitive":{"type":"EqConst","name":"isfirst","bound":[{"pos":0,"name":"target","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Const","name":"one","bound":[{"pos":0,"name":"value","kind":"F64","value":{"F64":1.0}}]}},{"primitive":{"type":"Const","name":"zero","bound":[{"pos":0,"name":"value","kind":"F64","value":{"F64":0.0}}]}},{"primitive":{"type":"Select","name":"sel"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":1.0}}]}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":1,"to":4,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":4,"slot":2,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":2,"slot":0},{"node":3,"slot":0}],"source":"F64"}],"output":[{"node":5,"field":0,"name":"bias"}]}}
|
||||
@@ -0,0 +1,19 @@
|
||||
[
|
||||
{"op": "doc", "text": "consumer: splice the NY session anchor by name, go long 1.0 on the first session bar, flat otherwise"},
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "use", "ref": {"name": "ny_anchor"}, "name": "anchor"},
|
||||
{"op": "feed", "role": "price", "into": ["anchor.price"]},
|
||||
{"op": "add", "type": "EqConst", "name": "isfirst", "bind": {"target": {"I64": 1}}},
|
||||
{"op": "connect", "from": "anchor.bars", "to": "isfirst.value"},
|
||||
{"op": "add", "type": "Const", "name": "one", "bind": {"value": {"F64": 1.0}}},
|
||||
{"op": "feed", "role": "price", "into": ["one.clock"]},
|
||||
{"op": "add", "type": "Const", "name": "zero", "bind": {"value": {"F64": 0.0}}},
|
||||
{"op": "feed", "role": "price", "into": ["zero.clock"]},
|
||||
{"op": "add", "type": "Select", "name": "sel"},
|
||||
{"op": "connect", "from": "isfirst.value", "to": "sel.cond"},
|
||||
{"op": "connect", "from": "one.value", "to": "sel.then"},
|
||||
{"op": "connect", "from": "zero.value", "to": "sel.otherwise"},
|
||||
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 1.0}}},
|
||||
{"op": "connect", "from": "sel.value", "to": "bias.signal"},
|
||||
{"op": "expose", "from": "bias.bias", "as": "bias"}
|
||||
]
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
[1m"manifest"[0m: {
|
||||
[1m"commit"[0m: [32m"6ca359ae00e8e7921f08557be383bc2d0a2ce199"[0m,
|
||||
[1m"params"[0m: [],
|
||||
[1m"defaults"[0m: [
|
||||
[
|
||||
[32m"graph.anchor.sess.period_minutes"[0m,
|
||||
{
|
||||
[1m"I64"[0m: [33m15[0m
|
||||
}
|
||||
],
|
||||
[
|
||||
[32m"graph.isfirst.target"[0m,
|
||||
{
|
||||
[1m"I64"[0m: [33m1[0m
|
||||
}
|
||||
],
|
||||
[
|
||||
[32m"graph.one.value"[0m,
|
||||
{
|
||||
[1m"F64"[0m: [33m1.0[0m
|
||||
}
|
||||
],
|
||||
[
|
||||
[32m"graph.zero.value"[0m,
|
||||
{
|
||||
[1m"F64"[0m: [33m0.0[0m
|
||||
}
|
||||
],
|
||||
[
|
||||
[32m"graph.bias.scale"[0m,
|
||||
{
|
||||
[1m"F64"[0m: [33m1.0[0m
|
||||
}
|
||||
]
|
||||
],
|
||||
[1m"window"[0m: [
|
||||
[33m1725235200000000000[0m,
|
||||
[33m1725580800000000000[0m
|
||||
],
|
||||
[1m"seed"[0m: [33m0[0m,
|
||||
[1m"broker"[0m: [32m"sim-optimal+risk-executor(pip_size=1)"[0m,
|
||||
[1m"topology_hash"[0m: [32m"985991f62cc6b92895018312c5ea86e493ef1c09dd53cf6e0813551baa97b671"[0m,
|
||||
[1m"project"[0m: {
|
||||
[1m"commit"[0m: [32m"d7e3a8f39881cc8eba42a906fa846676bfbb1010"[0m
|
||||
}
|
||||
},
|
||||
[1m"metrics"[0m: {
|
||||
[1m"total_pips"[0m: [33m-19.5[0m,
|
||||
[1m"max_drawdown"[0m: [33m35.5[0m,
|
||||
[1m"bias_sign_flips"[0m: [33m8[0m,
|
||||
[1m"r"[0m: {
|
||||
[1m"expectancy_r"[0m: [33m-0.7143093152455215[0m,
|
||||
[1m"n_trades"[0m: [33m8[0m,
|
||||
[1m"win_rate"[0m: [33m0.125[0m,
|
||||
[1m"avg_win_r"[0m: [33m2.704952265814223[0m,
|
||||
[1m"avg_loss_r"[0m: [33m-1.2027752553969135[0m,
|
||||
[1m"profit_factor"[0m: [33m0.32127510981397456[0m,
|
||||
[1m"max_r_drawdown"[0m: [33m7.371844351604779[0m,
|
||||
[1m"n_open_at_end"[0m: [33m0[0m,
|
||||
[1m"sqn"[0m: [33m-1.3625154866298599[0m,
|
||||
[1m"sqn_normalized"[0m: [33m-1.3625154866298599[0m,
|
||||
[1m"net_expectancy_r"[0m: [33m-0.7143093152455215[0m,
|
||||
[1m"conviction_terciles_r"[0m: [
|
||||
[33m-1.0191864862283455[0m,
|
||||
[33m-1.1307276003565727[0m,
|
||||
[33m-0.09463958281258789[0m
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":2,"blueprint":{"name":"graph","doc":"NY cash-open session anchor: bars elapsed since the 09:30 America/New_York open","nodes":[{"primitive":{"type":"Session","name":"sess","args":[{"name":"tz","value":"America/New_York"},{"name":"open","value":"09:30"}],"bound":[{"pos":0,"name":"period_minutes","kind":"I64","value":{"I64":15}}]}}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0}]}],"output":[{"node":0,"field":0,"name":"bars"}]}}
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{"op": "doc", "text": "NY cash-open session anchor: bars elapsed since the 09:30 America/New_York open"},
|
||||
{"op": "input", "role": "price"},
|
||||
{"op": "add", "type": "Session", "name": "sess",
|
||||
"args": {"tz": "America/New_York", "open": "09:30"},
|
||||
"bind": {"period_minutes": {"I64": 15}}},
|
||||
{"op": "feed", "role": "price", "into": ["sess.trigger"]},
|
||||
{"op": "expose", "from": "sess.bars_since_open", "as": "bars"}
|
||||
]
|
||||
@@ -0,0 +1,109 @@
|
||||
# Fieldtest transcript — cycle #328 (axis-namespace reconciliation)
|
||||
|
||||
Per-cycle field test of the one-raw-namespace axis surface: the raw
|
||||
`<node>.<param>` form as the only user-facing sweep-axis name across every
|
||||
surface (discovery, intake on all three verbs, run manifests, campaign
|
||||
documents), with the wrapped `<blueprint>.<node>.<param>` form retired behind
|
||||
translation refusals.
|
||||
|
||||
Binary: `target/release/aura`, built from HEAD `4474814` via
|
||||
`cargo build --release -p aura-cli` (Finished in 5.45s — HEAD builds clean).
|
||||
Scratch project `aura new ax_lab` in `/tmp/axwork/ax_lab`; real data from the
|
||||
built-in archive (US500). Public interface only (README, authoring-guide,
|
||||
glossary, design ledger, `aura … --help`) — no crate source read.
|
||||
|
||||
Base blueprint `cycle328_crossover`: an SMA-crossover → bias signal with a
|
||||
deliberate mix — `fast.length` **open**, `slow.length` **bound** (default=6),
|
||||
`bias.scale` **bound** (default=0.5) — so both discovery line shapes and the
|
||||
bound-override contract are exercised. Content id
|
||||
`f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240`.
|
||||
|
||||
---
|
||||
|
||||
## Example 1 — discovery-surface identity (`--params` vs `--list-axes`)
|
||||
|
||||
```
|
||||
$ aura graph introspect --params cycle328_crossover.bp.json
|
||||
fast.length:I64
|
||||
slow.length:I64 default=6
|
||||
bias.scale:F64 default=0.5
|
||||
$ aura sweep cycle328_crossover.bp.json --list-axes
|
||||
fast.length:I64
|
||||
slow.length:I64 default=6
|
||||
bias.scale:F64 default=0.5
|
||||
$ diff params.txt listaxes.txt # → IDENTICAL (no diff)
|
||||
```
|
||||
|
||||
Byte-identical, raw `<node>.<param>` form, bound params carrying `default=<v>`.
|
||||
`--params` discovers bound params too. (Finding W1.)
|
||||
|
||||
## Example 2 — document-first loop (paste verbatim, incl. bound-override)
|
||||
|
||||
Axes pasted verbatim from `--params` into a hand-written campaign document
|
||||
(`cycle328_2_campaign.json`), including two bound-override axes (`slow.length`,
|
||||
`bias.scale` — bound defaults re-opened by name, #246 coincidence contract):
|
||||
|
||||
```
|
||||
$ aura campaign validate cycle328_2_campaign.json # inside project, refs registered
|
||||
campaign document valid (intrinsic): 1 strategy(ies), 3 axes (4 points), 1 instrument(s), 1 window(s), 1 regime(s) (default) — 1 cell(s)
|
||||
campaign document valid (referential): all references resolve, axes are in the param space
|
||||
campaign document valid (executable): pipeline shape and static guards pass
|
||||
$ aura campaign run cycle328_2_campaign.json # exit 0
|
||||
aura: campaign run 0 recorded: 1 cells
|
||||
# manifest.params (raw): fast.length, slow.length, bias.scale (+ stop knobs)
|
||||
```
|
||||
|
||||
All three tiers pass; the bound-override axes validate; the run manifest records
|
||||
raw names. (Finding W2.)
|
||||
|
||||
## Example 3 — translation seams (wrapped form on every intake)
|
||||
|
||||
```
|
||||
$ aura sweep cycle328_crossover.bp.json --axis graph.fast.length=2,4 ... (exit 2)
|
||||
$ aura sweep cycle328_crossover.bp.json --real US500 --axis graph.fast.length=... (exit 2)
|
||||
$ aura walkforward cycle328_crossover.bp.json --real US500 --axis graph.fast.length=... (exit 2)
|
||||
aura: axis "graph.fast.length": axis names are raw node.param paths — use "fast.length" (the wrapped --list-axes form was retired, #328)
|
||||
|
||||
$ aura campaign validate cycle328_3d_wrapped_campaign.json (exit 1)
|
||||
aura: campaign references do not resolve:
|
||||
strategy f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240: axis "graph.fast.length" is not in the param space; axis names are raw node.param paths — did you mean "fast.length"?
|
||||
```
|
||||
|
||||
All three verbs refuse identically, naming the raw candidate; campaign validate
|
||||
appends a did-you-mean. Note the campaign-validate strategy identifier is the
|
||||
FULL 64-hex content id, where the authoring guide's transcript shows a 4-hex
|
||||
`strategy 9f3a:` placeholder (Finding SG1). Edge probes:
|
||||
|
||||
```
|
||||
$ aura sweep ... --axis fastt.length=2,4 # typo
|
||||
aura: axis fastt.length: names no param of this blueprint (open or bound) — see `aura sweep <bp> --list-axes`
|
||||
$ aura sweep ... --axis graph.fast.bogus=1 # wrapped, tail not a param
|
||||
aura: axis graph.fast.bogus: names no param of this blueprint (open or bound) — see `aura sweep <bp> --list-axes`
|
||||
$ aura sweep ... --axis project.graph.fast.length=2 # two leading segments (> one)
|
||||
aura: axis project.graph.fast.length: names no param of this blueprint (open or bound) — see `aura sweep <bp> --list-axes`
|
||||
```
|
||||
|
||||
Typos and >1-segment wrappings fall to a clean generic error — consistent with
|
||||
the documented "one leading segment strips to a hit" contract. (Finding W3.)
|
||||
|
||||
## Example 4 — manifest raw names + reproduce
|
||||
|
||||
```
|
||||
$ aura sweep cycle328_crossover.bp.json --axis fast.length=2,4 --axis slow.length=8,16 --name cycle328-sweep-bo
|
||||
# each member manifest:
|
||||
# params: [["fast.length",..],["slow.length",..],["stop_length",..],["stop_k",..]] (raw)
|
||||
# defaults: [["bias.scale",{"F64":0.5}]] (raw)
|
||||
$ aura reproduce cycle328-sweep-bo-0 (exit 0)
|
||||
cycle328-sweep-bo-0 member fast.length=2, slow.length=8, stop_length=3, stop_k=2 reproduced: bit-identical
|
||||
... reproduced 4/4 members bit-identically
|
||||
```
|
||||
|
||||
Positive real/walkforward routes also record raw (params AND defaults) and run
|
||||
clean (exit 0): `cycle328_6a_sweep_real.*`, `cycle328_6b_walkforward.*`. No
|
||||
wrapped name appears on any surface. (Finding W4.)
|
||||
|
||||
---
|
||||
|
||||
Not exercised: the "old wrapped-name manifests remain reproducible" backward-
|
||||
compat claim (no pre-#328 on-disk family is reachable through the public
|
||||
interface to a source-blind consumer).
|
||||
@@ -0,0 +1,3 @@
|
||||
fast.length:I64
|
||||
slow.length:I64 default=6
|
||||
bias.scale:F64 default=0.5
|
||||
@@ -0,0 +1,3 @@
|
||||
fast.length:I64
|
||||
slow.length:I64 default=6
|
||||
bias.scale:F64 default=0.5
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "cycle328-doc-first-boundoverride",
|
||||
"seed": 7,
|
||||
"data": {
|
||||
"instruments": ["US500"],
|
||||
"windows": [ { "from_ms": 1725148800000, "to_ms": 1725580800000 } ]
|
||||
},
|
||||
"strategies": [
|
||||
{
|
||||
"ref": { "content_id": "f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240" },
|
||||
"axes": {
|
||||
"fast.length": { "kind": "I64", "values": [2, 4] },
|
||||
"slow.length": { "kind": "I64", "values": [8, 16] },
|
||||
"bias.scale": { "kind": "F64", "values": [0.5] }
|
||||
}
|
||||
}
|
||||
],
|
||||
"process": { "ref": { "content_id": "f14023a1fef5640311163690f1140532412ea5e1d1cbb93e293e63fcf0bf2bf9" } },
|
||||
"presentation": { "persist_taps": [], "emit": ["family_table"] }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":1,"kind":"process","name":"ax-sweep-select","description":"Sweep the grid and select the argmax-SQN cell.","pipeline":[{"block":"std::sweep","metric":"sqn","select":"argmax"}]}
|
||||
@@ -0,0 +1 @@
|
||||
aura: campaign run 0 recorded: 1 cells
|
||||
@@ -0,0 +1,5 @@
|
||||
{"family_id":"844fe329-0-US500-w0-r0-s0-0","report":{"manifest":{"commit":"4474814fa79e227cd2d93e34a6982b49f31ba846","params":[["fast.length",{"I64":2}],["slow.length",{"I64":8}],["bias.scale",{"F64":0.5}],["stop_length",{"I64":3}],["stop_k",{"F64":2.0}]],"defaults":[],"window":[1725148800000000000,1725580800000000000],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=1)","instrument":"US500","topology_hash":"f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240","project":{"commit":"6bb76063046d41062b68fbde79950b9d06fbd41d"}},"metrics":{"total_pips":46.422499999968466,"max_drawdown":125.89999999998915,"bias_sign_flips":861,"r":{"expectancy_r":0.052960279961470944,"n_trades":846,"win_rate":0.31796690307328607,"avg_win_r":1.8016249253621428,"avg_loss_r":-0.7622750573223781,"profit_factor":1.1018669053593053,"max_r_drawdown":29.11884107504975,"n_open_at_end":1,"sqn":0.8388429079176243,"sqn_normalized":0.2884001324999155,"net_expectancy_r":0.052960279961470944,"conviction_terciles_r":[-0.05618014716431794,0.13546662237309962,0.07959436467563116]}}}}
|
||||
{"family_id":"844fe329-0-US500-w0-r0-s0-0","report":{"manifest":{"commit":"4474814fa79e227cd2d93e34a6982b49f31ba846","params":[["fast.length",{"I64":2}],["slow.length",{"I64":16}],["bias.scale",{"F64":0.5}],["stop_length",{"I64":3}],["stop_k",{"F64":2.0}]],"defaults":[],"window":[1725148800000000000,1725580800000000000],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=1)","instrument":"US500","topology_hash":"f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240","project":{"commit":"6bb76063046d41062b68fbde79950b9d06fbd41d"}},"metrics":{"total_pips":23.387499999972977,"max_drawdown":113.9349999999979,"bias_sign_flips":521,"r":{"expectancy_r":0.09527242267306019,"n_trades":529,"win_rate":0.3005671077504726,"avg_win_r":2.0866957092558716,"avg_loss_r":-0.7605013680476617,"profit_factor":1.179110545808399,"max_r_drawdown":26.058017560225167,"n_open_at_end":1,"sqn":1.126033935020108,"sqn_normalized":0.4895799717478731,"net_expectancy_r":0.09527242267306019,"conviction_terciles_r":[-0.07002826150651001,0.25569891492505703,0.10011907679313316]}}}}
|
||||
{"family_id":"844fe329-0-US500-w0-r0-s0-0","report":{"manifest":{"commit":"4474814fa79e227cd2d93e34a6982b49f31ba846","params":[["fast.length",{"I64":4}],["slow.length",{"I64":8}],["bias.scale",{"F64":0.5}],["stop_length",{"I64":3}],["stop_k",{"F64":2.0}]],"defaults":[],"window":[1725148800000000000,1725580800000000000],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=1)","instrument":"US500","topology_hash":"f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240","project":{"commit":"6bb76063046d41062b68fbde79950b9d06fbd41d"}},"metrics":{"total_pips":80.28249999994746,"max_drawdown":87.96499999997366,"bias_sign_flips":780,"r":{"expectancy_r":0.13213069018419635,"n_trades":821,"win_rate":0.3617539585870889,"avg_win_r":1.848016846738723,"avg_loss_r":-0.8404231046568231,"profit_factor":1.2463301607589143,"max_r_drawdown":28.220480388404972,"n_open_at_end":1,"sqn":1.8795915057542347,"sqn_normalized":0.655981958519425,"net_expectancy_r":0.13213069018419635,"conviction_terciles_r":[0.11958074358694365,0.22575289096933593,0.05101263327150186]}}}}
|
||||
{"family_id":"844fe329-0-US500-w0-r0-s0-0","report":{"manifest":{"commit":"4474814fa79e227cd2d93e34a6982b49f31ba846","params":[["fast.length",{"I64":4}],["slow.length",{"I64":16}],["bias.scale",{"F64":0.5}],["stop_length",{"I64":3}],["stop_k",{"F64":2.0}]],"defaults":[],"window":[1725148800000000000,1725580800000000000],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=1)","instrument":"US500","topology_hash":"f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240","project":{"commit":"6bb76063046d41062b68fbde79950b9d06fbd41d"}},"metrics":{"total_pips":19.558750000014825,"max_drawdown":85.75000000000286,"bias_sign_flips":399,"r":{"expectancy_r":0.11821373408670934,"n_trades":479,"win_rate":0.31315240083507306,"avg_win_r":2.3901102129922043,"avg_loss_r":-0.9176053292440638,"profit_factor":1.1875649234489034,"max_r_drawdown":28.108663585145585,"n_open_at_end":1,"sqn":1.0959164423645071,"sqn_normalized":0.5007370035157714,"net_expectancy_r":0.11821373408670934,"conviction_terciles_r":[0.2576229784937875,0.13377757213522506,-0.035888040591340105]}}}}
|
||||
{"campaign_run":{"campaign":"844fe3290562346f37c756a74f3d76bab9a3c7257341ec8dafec0025ac2f68db","process":"f14023a1fef5640311163690f1140532412ea5e1d1cbb93e293e63fcf0bf2bf9","run":0,"seed":7,"cells":[{"strategy":"f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240","instrument":"US500","window_ms":[1725148800000,1725580800000],"stages":[{"block":"std::sweep","family_id":"844fe329-0-US500-w0-r0-s0-0","selection":{"winner_ordinal":2,"params":[["bias.scale",{"F64":0.5}],["fast.length",{"I64":4}],["slow.length",{"I64":8}]],"selection":{"selection_metric":"sqn","n_trials":4,"raw_winner_metric":1.8795915057542347,"mode":"Argmax"}}}],"coverage":{"effective_from_ms":1725235200000,"effective_to_ms":1725580800000}}]}}
|
||||
@@ -0,0 +1,3 @@
|
||||
campaign document valid (intrinsic): 1 strategy(ies), 3 axes (4 points), 1 instrument(s), 1 window(s), 1 regime(s) (default) — 1 cell(s)
|
||||
campaign document valid (referential): all references resolve, axes are in the param space
|
||||
campaign document valid (executable): pipeline shape and static guards pass
|
||||
@@ -0,0 +1 @@
|
||||
aura: axis "graph.fast.length": axis names are raw node.param paths — use "fast.length" (the wrapped --list-axes form was retired, #328)
|
||||
@@ -0,0 +1 @@
|
||||
aura: axis "graph.fast.length": axis names are raw node.param paths — use "fast.length" (the wrapped --list-axes form was retired, #328)
|
||||
@@ -0,0 +1 @@
|
||||
aura: axis "graph.fast.length": axis names are raw node.param paths — use "fast.length" (the wrapped --list-axes form was retired, #328)
|
||||
@@ -0,0 +1,4 @@
|
||||
campaign document valid (intrinsic): 1 strategy(ies), 1 axes (2 points), 1 instrument(s), 1 window(s), 1 regime(s) (default) — 1 cell(s)
|
||||
aura: campaign references do not resolve:
|
||||
strategy f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240: axis "graph.fast.length" is not in the param space; axis names are raw node.param paths — did you mean "fast.length"?
|
||||
strategy f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240: open param "fast.length" is bound by no campaign axis
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "cycle328-wrapped-axis-in-doc",
|
||||
"seed": 7,
|
||||
"data": {
|
||||
"instruments": ["US500"],
|
||||
"windows": [ { "from_ms": 1725148800000, "to_ms": 1725580800000 } ]
|
||||
},
|
||||
"strategies": [
|
||||
{
|
||||
"ref": { "content_id": "f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240" },
|
||||
"axes": {
|
||||
"graph.fast.length": { "kind": "I64", "values": [2, 4] }
|
||||
}
|
||||
}
|
||||
],
|
||||
"process": { "ref": { "content_id": "f14023a1fef5640311163690f1140532412ea5e1d1cbb93e293e63fcf0bf2bf9" } },
|
||||
"presentation": { "persist_taps": [], "emit": ["family_table"] }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"family_id":"844fe329-0-US500-w0-r0-s0-0","kind":"Sweep","members":4}
|
||||
{"family_id":"cycle328-sweep-bo-0","kind":"Sweep","members":4}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"manifest":{"commit":"4474814fa79e227cd2d93e34a6982b49f31ba846","params":[["fast.length",{"I64":2}],["slow.length",{"I64":8}],["stop_length",{"I64":3}],["stop_k",{"F64":2.0}]],"defaults":[["bias.scale",{"F64":0.5}]],"window":[1,18],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=0.0001)","topology_hash":"f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240","project":{"commit":"6bb76063046d41062b68fbde79950b9d06fbd41d"}},"metrics":{"total_pips":-0.23484999999997555,"max_drawdown":0.38129999999997777,"bias_sign_flips":2,"r":{"expectancy_r":0.05853072923940165,"n_trades":3,"win_rate":0.3333333333333333,"avg_win_r":1.127161364109783,"avg_loss_r":-0.47578458819578906,"profit_factor":1.18452908319715,"max_r_drawdown":0.2739524763392649,"n_open_at_end":1,"sqn":0.1070277396717771,"sqn_normalized":0.1070277396717771,"net_expectancy_r":0.05853072923940165,"conviction_terciles_r":[-0.2739524763392649,1.127161364109783,-0.6776167000523132]}}}
|
||||
{"manifest":{"commit":"4474814fa79e227cd2d93e34a6982b49f31ba846","params":[["fast.length",{"I64":2}],["slow.length",{"I64":16}],["stop_length",{"I64":3}],["stop_k",{"F64":2.0}]],"defaults":[["bias.scale",{"F64":0.5}]],"window":[1,18],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=0.0001)","topology_hash":"f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240","project":{"commit":"6bb76063046d41062b68fbde79950b9d06fbd41d"}},"metrics":{"total_pips":0.037062500000010136,"max_drawdown":0.04543749999999568,"bias_sign_flips":0,"r":{"expectancy_r":0.1570020451313814,"n_trades":1,"win_rate":1.0,"avg_win_r":0.1570020451313814,"avg_loss_r":0.0,"profit_factor":0.0,"max_r_drawdown":0.0,"n_open_at_end":1,"sqn":0.0,"sqn_normalized":0.0,"net_expectancy_r":0.1570020451313814,"conviction_terciles_r":[0.0,0.0,0.0]}}}
|
||||
{"manifest":{"commit":"4474814fa79e227cd2d93e34a6982b49f31ba846","params":[["fast.length",{"I64":4}],["slow.length",{"I64":8}],["stop_length",{"I64":3}],["stop_k",{"F64":2.0}]],"defaults":[["bias.scale",{"F64":0.5}]],"window":[1,18],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=0.0001)","topology_hash":"f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240","project":{"commit":"6bb76063046d41062b68fbde79950b9d06fbd41d"}},"metrics":{"total_pips":-0.5274999999999815,"max_drawdown":0.5609499999999865,"bias_sign_flips":2,"r":{"expectancy_r":-0.6426407768510923,"n_trades":3,"win_rate":0.3333333333333333,"avg_win_r":0.5474739899790243,"avg_loss_r":-1.2376981602661505,"profit_factor":0.22116619687844463,"max_r_drawdown":1.245647494511426,"n_open_at_end":1,"sqn":-1.0799322573025962,"sqn_normalized":-1.0799322573025962,"net_expectancy_r":-0.6426407768510923,"conviction_terciles_r":[-1.2456474945114262,0.5474739899790243,-1.229748826020875]}}}
|
||||
{"manifest":{"commit":"4474814fa79e227cd2d93e34a6982b49f31ba846","params":[["fast.length",{"I64":4}],["slow.length",{"I64":16}],["stop_length",{"I64":3}],["stop_k",{"F64":2.0}]],"defaults":[["bias.scale",{"F64":0.5}]],"window":[1,18],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=0.0001)","topology_hash":"f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240","project":{"commit":"6bb76063046d41062b68fbde79950b9d06fbd41d"}},"metrics":{"total_pips":0.0076125000000051846,"max_drawdown":0.02868749999999659,"bias_sign_flips":0,"r":{"expectancy_r":0.1570020451313814,"n_trades":1,"win_rate":1.0,"avg_win_r":0.1570020451313814,"avg_loss_r":0.0,"profit_factor":0.0,"max_r_drawdown":0.0,"n_open_at_end":1,"sqn":0.0,"sqn_normalized":0.0,"net_expectancy_r":0.1570020451313814,"conviction_terciles_r":[0.0,0.0,0.0]}}}
|
||||
@@ -0,0 +1,5 @@
|
||||
cycle328-sweep-bo-0 member fast.length=2, slow.length=8, stop_length=3, stop_k=2 reproduced: bit-identical
|
||||
cycle328-sweep-bo-0 member fast.length=2, slow.length=16, stop_length=3, stop_k=2 reproduced: bit-identical
|
||||
cycle328-sweep-bo-0 member fast.length=4, slow.length=8, stop_length=3, stop_k=2 reproduced: bit-identical
|
||||
cycle328-sweep-bo-0 member fast.length=4, slow.length=16, stop_length=3, stop_k=2 reproduced: bit-identical
|
||||
reproduced 4/4 members bit-identically
|
||||
@@ -0,0 +1,4 @@
|
||||
{"family_id":"cycle328-sweep-bo-0","report":{"manifest":{"commit":"4474814fa79e227cd2d93e34a6982b49f31ba846","params":[["fast.length",{"I64":2}],["slow.length",{"I64":8}],["stop_length",{"I64":3}],["stop_k",{"F64":2.0}]],"defaults":[["bias.scale",{"F64":0.5}]],"window":[1,18],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=0.0001)","topology_hash":"f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240","project":{"commit":"6bb76063046d41062b68fbde79950b9d06fbd41d"}},"metrics":{"total_pips":-0.23484999999997555,"max_drawdown":0.38129999999997777,"bias_sign_flips":2,"r":{"expectancy_r":0.05853072923940165,"n_trades":3,"win_rate":0.3333333333333333,"avg_win_r":1.127161364109783,"avg_loss_r":-0.47578458819578906,"profit_factor":1.18452908319715,"max_r_drawdown":0.2739524763392649,"n_open_at_end":1,"sqn":0.1070277396717771,"sqn_normalized":0.1070277396717771,"net_expectancy_r":0.05853072923940165,"conviction_terciles_r":[-0.2739524763392649,1.127161364109783,-0.6776167000523132]}}}}
|
||||
{"family_id":"cycle328-sweep-bo-0","report":{"manifest":{"commit":"4474814fa79e227cd2d93e34a6982b49f31ba846","params":[["fast.length",{"I64":2}],["slow.length",{"I64":16}],["stop_length",{"I64":3}],["stop_k",{"F64":2.0}]],"defaults":[["bias.scale",{"F64":0.5}]],"window":[1,18],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=0.0001)","topology_hash":"f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240","project":{"commit":"6bb76063046d41062b68fbde79950b9d06fbd41d"}},"metrics":{"total_pips":0.037062500000010136,"max_drawdown":0.04543749999999568,"bias_sign_flips":0,"r":{"expectancy_r":0.1570020451313814,"n_trades":1,"win_rate":1.0,"avg_win_r":0.1570020451313814,"avg_loss_r":0.0,"profit_factor":0.0,"max_r_drawdown":0.0,"n_open_at_end":1,"sqn":0.0,"sqn_normalized":0.0,"net_expectancy_r":0.1570020451313814,"conviction_terciles_r":[0.0,0.0,0.0]}}}}
|
||||
{"family_id":"cycle328-sweep-bo-0","report":{"manifest":{"commit":"4474814fa79e227cd2d93e34a6982b49f31ba846","params":[["fast.length",{"I64":4}],["slow.length",{"I64":8}],["stop_length",{"I64":3}],["stop_k",{"F64":2.0}]],"defaults":[["bias.scale",{"F64":0.5}]],"window":[1,18],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=0.0001)","topology_hash":"f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240","project":{"commit":"6bb76063046d41062b68fbde79950b9d06fbd41d"}},"metrics":{"total_pips":-0.5274999999999815,"max_drawdown":0.5609499999999865,"bias_sign_flips":2,"r":{"expectancy_r":-0.6426407768510923,"n_trades":3,"win_rate":0.3333333333333333,"avg_win_r":0.5474739899790243,"avg_loss_r":-1.2376981602661505,"profit_factor":0.22116619687844463,"max_r_drawdown":1.245647494511426,"n_open_at_end":1,"sqn":-1.0799322573025962,"sqn_normalized":-1.0799322573025962,"net_expectancy_r":-0.6426407768510923,"conviction_terciles_r":[-1.2456474945114262,0.5474739899790243,-1.229748826020875]}}}}
|
||||
{"family_id":"cycle328-sweep-bo-0","report":{"manifest":{"commit":"4474814fa79e227cd2d93e34a6982b49f31ba846","params":[["fast.length",{"I64":4}],["slow.length",{"I64":16}],["stop_length",{"I64":3}],["stop_k",{"F64":2.0}]],"defaults":[["bias.scale",{"F64":0.5}]],"window":[1,18],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=0.0001)","topology_hash":"f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240","project":{"commit":"6bb76063046d41062b68fbde79950b9d06fbd41d"}},"metrics":{"total_pips":0.0076125000000051846,"max_drawdown":0.02868749999999659,"bias_sign_flips":0,"r":{"expectancy_r":0.1570020451313814,"n_trades":1,"win_rate":1.0,"avg_win_r":0.1570020451313814,"avg_loss_r":0.0,"profit_factor":0.0,"max_r_drawdown":0.0,"n_open_at_end":1,"sqn":0.0,"sqn_normalized":0.0,"net_expectancy_r":0.1570020451313814,"conviction_terciles_r":[0.0,0.0,0.0]}}}}
|
||||
@@ -0,0 +1 @@
|
||||
aura: axis fastt.length: names no param of this blueprint (open or bound) — see `aura sweep <bp> --list-axes`
|
||||
@@ -0,0 +1 @@
|
||||
aura: axis graph.fast.bogus: names no param of this blueprint (open or bound) — see `aura sweep <bp> --list-axes`
|
||||
@@ -0,0 +1 @@
|
||||
aura: axis project.graph.fast.length: names no param of this blueprint (open or bound) — see `aura sweep <bp> --list-axes`
|
||||
@@ -0,0 +1,2 @@
|
||||
{"family_id":"1102542f-0-US500-w0-r0-s0-0","report":{"manifest":{"commit":"4474814fa79e227cd2d93e34a6982b49f31ba846","params":[["fast.length",{"I64":2}],["slow.length",{"I64":8}],["stop_length",{"I64":3}],["stop_k",{"F64":2.0}]],"defaults":[["bias.scale",{"F64":0.5}]],"window":[1725235200000000000,1725580800000000000],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=1)","instrument":"US500","topology_hash":"f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240","project":{"commit":"6bb76063046d41062b68fbde79950b9d06fbd41d"}},"metrics":{"total_pips":46.422499999968466,"max_drawdown":125.89999999998915,"bias_sign_flips":861,"r":{"expectancy_r":0.052960279961470944,"n_trades":846,"win_rate":0.31796690307328607,"avg_win_r":1.8016249253621428,"avg_loss_r":-0.7622750573223781,"profit_factor":1.1018669053593053,"max_r_drawdown":29.11884107504975,"n_open_at_end":1,"sqn":0.8388429079176243,"sqn_normalized":0.2884001324999155,"net_expectancy_r":0.052960279961470944,"conviction_terciles_r":[-0.05618014716431794,0.13546662237309962,0.07959436467563116]}}}}
|
||||
{"family_id":"1102542f-0-US500-w0-r0-s0-0","report":{"manifest":{"commit":"4474814fa79e227cd2d93e34a6982b49f31ba846","params":[["fast.length",{"I64":4}],["slow.length",{"I64":8}],["stop_length",{"I64":3}],["stop_k",{"F64":2.0}]],"defaults":[["bias.scale",{"F64":0.5}]],"window":[1725235200000000000,1725580800000000000],"seed":0,"broker":"sim-optimal+risk-executor(pip_size=1)","instrument":"US500","topology_hash":"f5fdf729d0333f0286c19d57143fb7933137f27a6128e72489c508096c7a2240","project":{"commit":"6bb76063046d41062b68fbde79950b9d06fbd41d"}},"metrics":{"total_pips":80.28249999994746,"max_drawdown":87.96499999997366,"bias_sign_flips":780,"r":{"expectancy_r":0.13213069018419635,"n_trades":821,"win_rate":0.3617539585870889,"avg_win_r":1.848016846738723,"avg_loss_r":-0.8404231046568231,"profit_factor":1.2463301607589143,"max_r_drawdown":28.220480388404972,"n_open_at_end":1,"sqn":1.8795915057542347,"sqn_normalized":0.655981958519425,"net_expectancy_r":0.13213069018419635,"conviction_terciles_r":[0.11958074358694365,0.22575289096933593,0.05101263327150186]}}}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user