Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb2b0a132c | |||
| 14c43474ab | |||
| 6ca359ae00 | |||
| bc8fb46110 | |||
| a8b1ba45c5 | |||
| e84ad6d0d2 | |||
| f449cb06f2 | |||
| 26b3d689df | |||
| cb3330ceb5 | |||
| 4a30222fdc | |||
| 2c2c2fdef6 | |||
| 623d304b7f | |||
| 9e26be60f3 | |||
| 7392075aa6 | |||
| 05e9a00afc | |||
| 120d116982 | |||
| 938397295d | |||
| 98342246f6 | |||
| fa7453dd9f | |||
| 8dbca82756 | |||
| 73ad87b08a | |||
| 7943b123ae | |||
| 7cc3ce0d9e | |||
| e482f0ec35 | |||
| 1baee774bb | |||
| 9124275bf3 | |||
| d26f0c84a4 | |||
| 162bf849ce | |||
| 829a1984e6 |
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]]
|
||||
|
||||
@@ -119,9 +119,12 @@ aura sweep crossover.bp.json --list-axes
|
||||
```
|
||||
|
||||
The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, `tap`,
|
||||
`gang`, and `doc` (`tap` declares a recorded measurement point on an interior
|
||||
wire; `doc` declares the composite's one-line meaning, required at register —
|
||||
C29; see the authoring guide). See
|
||||
`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; `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.
|
||||
|
||||
|
||||
@@ -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,22 +8,56 @@ use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use aura_engine::{
|
||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, BindOpError,
|
||||
CompileError, Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind,
|
||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, ArgOpError,
|
||||
BindOpError, BlueprintDoc, CompileError, Composite, GraphSession, LoadError, Op, OpError,
|
||||
Scalar, ScalarKind,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::research_docs::resolve_id_prefix;
|
||||
|
||||
// `std_vocabulary` is now only reached through `aura_runner::project::Env::resolve` in
|
||||
// production code; tests still exercise it directly.
|
||||
#[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.
|
||||
pub const OP_REFERENCE: &str = r#"Op-list reference (stdin: a JSON array of op objects, applied in order):
|
||||
{"op":"source","role":"price","kind":"F64"}
|
||||
declare a bound root input role of a scalar kind
|
||||
{"op":"input","role":"price"}
|
||||
declare an open input role (wired by an enclosing graph)
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}}
|
||||
instantiate a node ("name" optional; "bind" maps param -> typed scalar)
|
||||
{"op":"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"}
|
||||
wire a node output into an input slot
|
||||
{"op":"expose","from":"sub.value","as":"bias"}
|
||||
name a graph output field
|
||||
{"op":"tap","from":"sub.value","as":"spread"}
|
||||
declare a recordable tap on a wire (expose's output-side twin)
|
||||
{"op":"gang","as":"length","into":["fast.length","slow.length"]}
|
||||
fuse two or more sibling params into one public knob
|
||||
{"op":"doc","text":"..."}
|
||||
declare the composite's one-line meaning (C29)
|
||||
{"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)
|
||||
|
||||
Node types and their ports: aura graph introspect --vocabulary | --node <T>"#;
|
||||
|
||||
/// The wire DTO for one construction op — the document's by-identifier shape,
|
||||
/// internally tagged by `"op"`, mapped into the serde-free engine `Op`. Bind
|
||||
/// values are the typed `Scalar` form (`{"I64":2}`); `kind` the capitalized
|
||||
/// `ScalarKind` form (`"F64"`) — both the #155 representations.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "lowercase")]
|
||||
#[serde(tag = "op", rename_all = "lowercase", deny_unknown_fields)]
|
||||
enum OpDoc {
|
||||
Source { role: String, kind: ScalarKind },
|
||||
Input { role: String },
|
||||
@@ -34,6 +68,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>,
|
||||
},
|
||||
@@ -57,39 +97,108 @@ enum OpDoc {
|
||||
/// Declare the composite's one-line meaning (C29, #316) — the op-script
|
||||
/// twin of the builder's `.doc(...)`.
|
||||
Doc { text: String },
|
||||
/// Splice a registered blueprint into the building graph as a nested
|
||||
/// composite (#317) — the DTO the engine's `Op::Use` never sees
|
||||
/// directly: the CLI resolves `ref` (a store content id, a unique
|
||||
/// content-id prefix, or a registry name label) to the full content id
|
||||
/// at conversion time, before the engine ever runs.
|
||||
Use {
|
||||
#[serde(rename = "ref")]
|
||||
r#ref: UseRef,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
bind: BTreeMap<String, Scalar>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A `use` op's reference (#317) — exactly one of a store content id (full
|
||||
/// or a unique prefix, #302 semantics) or a registry name label
|
||||
/// (`graph register --name`, latest-wins).
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
enum UseRef {
|
||||
#[serde(rename = "content_id")]
|
||||
ContentId(String),
|
||||
#[serde(rename = "name")]
|
||||
Name(String),
|
||||
}
|
||||
|
||||
impl OpDoc {
|
||||
/// The op-kind label for the `op N (kind): cause` message.
|
||||
fn kind_label(&self) -> &'static str {
|
||||
/// The op-kind label for the `op N (kind): cause` message. A `use` op
|
||||
/// carries its instance name (when the author gave one) so a `use`
|
||||
/// fault reads `use "gate"`, not a bare, undifferentiated `use` — the
|
||||
/// only kind whose label is not a fixed string (#317).
|
||||
fn kind_label(&self) -> String {
|
||||
match self {
|
||||
OpDoc::Source { .. } => "source",
|
||||
OpDoc::Input { .. } => "input",
|
||||
OpDoc::Add { .. } => "add",
|
||||
OpDoc::Feed { .. } => "feed",
|
||||
OpDoc::Connect { .. } => "connect",
|
||||
OpDoc::Expose { .. } => "expose",
|
||||
OpDoc::Tap { .. } => "tap",
|
||||
OpDoc::Gang { .. } => "gang",
|
||||
OpDoc::Doc { .. } => "doc",
|
||||
OpDoc::Source { .. } => "source".to_string(),
|
||||
OpDoc::Input { .. } => "input".to_string(),
|
||||
OpDoc::Add { .. } => "add".to_string(),
|
||||
OpDoc::Feed { .. } => "feed".to_string(),
|
||||
OpDoc::Connect { .. } => "connect".to_string(),
|
||||
OpDoc::Expose { .. } => "expose".to_string(),
|
||||
OpDoc::Tap { .. } => "tap".to_string(),
|
||||
OpDoc::Gang { .. } => "gang".to_string(),
|
||||
OpDoc::Doc { .. } => "doc".to_string(),
|
||||
OpDoc::Use { name: Some(n), .. } => format!("use {n:?}"),
|
||||
OpDoc::Use { name: None, .. } => "use".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OpDoc> for Op {
|
||||
/// Infallible, context-free conversion — used directly only by
|
||||
/// build-free introspection paths (`introspect --unwired`, #317's
|
||||
/// spec: "Build-free introspection paths pass a `|_| None` closure"),
|
||||
/// which never resolve a `use` ref through the registry. A bare
|
||||
/// `OpDoc::Use` therefore maps its `UseRef` payload verbatim into
|
||||
/// `ref_id` (unresolved) — that session's `subgraph` closure is always
|
||||
/// `&|_| None`, so any `use` op there faults `UnknownSubgraph`
|
||||
/// regardless of the exact `ref_id` text; `graph build`'s real path
|
||||
/// (`composite_from_str`) never reaches this arm — it resolves and
|
||||
/// replaces each `Op::Use` before conversion (see `resolve_use_op`).
|
||||
fn from(d: OpDoc) -> 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 },
|
||||
OpDoc::Tap { from, as_name } => Op::Tap { from, as_name },
|
||||
OpDoc::Gang { as_name, into } => Op::Gang { as_name, into },
|
||||
OpDoc::Doc { text } => Op::Doc { text },
|
||||
OpDoc::Use { r#ref, name, bind } => Op::Use {
|
||||
ref_id: match r#ref {
|
||||
UseRef::ContentId(id) => id,
|
||||
UseRef::Name(name) => name,
|
||||
},
|
||||
name,
|
||||
bind: bind.into_iter().collect(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,10 +231,13 @@ fn format_op_error(e: &OpError) -> String {
|
||||
BindOpError::KindMismatch { param, expected, got } => {
|
||||
format!("param {node}.{param} expects {expected:?} but got {got:?}")
|
||||
}
|
||||
BindOpError::AlreadyGanged { param, gang } => {
|
||||
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::UnboundRootRole { role } => format!("root input role {role} is unbound"),
|
||||
OpError::GangKindMismatch { member, expected, got } => {
|
||||
format!("gang: member `{member}` is {got:?}, expected {expected:?}")
|
||||
}
|
||||
@@ -135,18 +247,174 @@ fn format_op_error(e: &OpError) -> String {
|
||||
OpError::GangArity { gang } => format!("gang `{gang}`: needs at least two members"),
|
||||
OpError::Incomplete(ce) => format!("{ce:?}"),
|
||||
OpError::DuplicateDoc => "a doc op may appear at most once".to_string(),
|
||||
// #317: `graph build`'s real path (`composite_from_str`) resolves and
|
||||
// fetches every `use` op before replay, so this never fires there —
|
||||
// but `introspect --unwired` stays build-free/subgraph-free by spec
|
||||
// (`&|_| None`, see `From<OpDoc> for Op`), so a `use` op in a partial
|
||||
// document reaches this arm through THAT path, by-identifier on the
|
||||
// (unresolved) `ref_id` text.
|
||||
OpError::UnknownSubgraph { ref_id } => {
|
||||
format!("use: no subgraph for content id {ref_id:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// label (latest-wins, [`aura_registry::Registry::resolve_blueprint_label`]).
|
||||
/// Returns the full id plus the `<label-or-prefix> -> <full id>` text the
|
||||
/// resolution echo shows — the bare cause on a miss (unknown label / unknown
|
||||
/// or ambiguous prefix), for the caller to attribute by op index.
|
||||
fn resolve_use_ref_id(r: &UseRef, registry: &aura_registry::Registry) -> Result<(String, String), String> {
|
||||
match r {
|
||||
UseRef::Name(label) => {
|
||||
let id = registry.resolve_blueprint_label(label).ok_or_else(|| {
|
||||
let labels = registry.blueprint_labels();
|
||||
if labels.is_empty() {
|
||||
format!("no registered blueprint labeled {label:?} — no labels registered")
|
||||
} else {
|
||||
let names = labels.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>().join(", ");
|
||||
format!("no registered blueprint labeled {label:?} — registered labels: {names}")
|
||||
}
|
||||
})?;
|
||||
Ok((id.clone(), format!("{label} -> {id}")))
|
||||
}
|
||||
UseRef::ContentId(raw) => {
|
||||
if aura_runner::axes::is_content_id(raw) {
|
||||
return Ok((raw.clone(), format!("{raw} -> {raw}")));
|
||||
}
|
||||
let candidates = registry.list_blueprint_ids().map_err(|e| e.to_string())?;
|
||||
match resolve_id_prefix(raw, &candidates)? {
|
||||
Some(full) => {
|
||||
let shown = format!("{raw} -> {full}");
|
||||
Ok((full, shown))
|
||||
}
|
||||
None => Err(format!("no registered blueprint matches content id {raw:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 12-char content-id prefix convention `graph introspect --registered`
|
||||
/// shows (#317), reused in the use-seam C29 refusal so both surfaces name a
|
||||
/// blueprint the same shortened way.
|
||||
fn id_prefix12(id: &str) -> &str {
|
||||
&id[..id.len().min(12)]
|
||||
}
|
||||
|
||||
/// The use-seam C29 doc-gate refusal cause (#317): re-run
|
||||
/// [`aura_registry::gate_composite_docs`] — the SAME walk `graph register`
|
||||
/// gates the write path with — over a FETCHED composite, before it ever
|
||||
/// reaches the session; names the fetched blueprint's id-prefix and the
|
||||
/// failing (root or nested — from the consumer's view, everything fetched is
|
||||
/// "nested") composite by identifier, mirroring `research_docs.rs`'s
|
||||
/// `BadDescription` phrasing for the same two `DocGateFault` kinds.
|
||||
fn use_doc_gate_cause(full_id: &str, e: &aura_registry::RegistryError) -> String {
|
||||
let aura_registry::RegistryError::UndescribedComposite { name, fault } = e else {
|
||||
// Defensive: `gate_composite_docs` only ever constructs
|
||||
// `UndescribedComposite`; every other `RegistryError` variant is
|
||||
// unreachable from this call, but the match stays total rather than
|
||||
// panicking on a surprise variant.
|
||||
return format!("registered blueprint {} fails the description gate", id_prefix12(full_id));
|
||||
};
|
||||
let rule = match fault {
|
||||
aura_core::DocGateFault::Empty => format!(
|
||||
"nested composite {name:?} has no description — re-register it with a \"doc\" op (C29)"
|
||||
),
|
||||
aura_core::DocGateFault::RestatesName => {
|
||||
format!("nested composite {name:?} has a doc that merely restates its name (C29)")
|
||||
}
|
||||
};
|
||||
format!("registered blueprint {} fails the description gate: {rule}", id_prefix12(full_id))
|
||||
}
|
||||
|
||||
/// Resolve, fetch, C29-gate, and echo one `use` op (#317) — the CLI-side
|
||||
/// half of the store/engine split (the engine never resolves a label, a
|
||||
/// prefix, or the registry itself). On success, caches the fetched
|
||||
/// CANONICAL JSON (not the parsed `Composite` — `Composite` is not `Clone`,
|
||||
/// mirroring the engine's own `use_fixture`-reload test pattern) under the
|
||||
/// full content id, so the `subgraph` closure handed to `replay` is a pure,
|
||||
/// registry-free lookup that re-parses on every call. Returns the bare
|
||||
/// cause on any refusal; the caller attributes it by op index.
|
||||
fn resolve_use_op(
|
||||
r#ref: UseRef,
|
||||
name: Option<String>,
|
||||
bind: BTreeMap<String, Scalar>,
|
||||
env: &aura_runner::project::Env,
|
||||
cache: &mut std::collections::HashMap<String, String>,
|
||||
) -> Result<Op, String> {
|
||||
let registry = env.registry();
|
||||
let (full_id, shown) = resolve_use_ref_id(&r#ref, ®istry)?;
|
||||
let json = registry
|
||||
.get_blueprint(&full_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("no registered blueprint {full_id}"))?;
|
||||
let doc: BlueprintDoc = serde_json::from_str(&json)
|
||||
.map_err(|e| format!("registered blueprint {full_id} is not a valid blueprint: {e}"))?;
|
||||
// C29 at the use seam: gate the DATA form (no vocabulary needed) before
|
||||
// the composite ever reaches the session.
|
||||
if let Err(e) = aura_registry::gate_composite_docs(&doc.blueprint) {
|
||||
return Err(use_doc_gate_cause(&full_id, &e));
|
||||
}
|
||||
// Resolve the fetched envelope through the FULL vocabulary here, at DTO
|
||||
// conversion (review finding, #317 follow-up): deferring this to the
|
||||
// `subgraph` closure's lazy re-parse let a deserialize/vocab-resolution
|
||||
// failure fold into `.ok() -> None`, which `replay` then misreports as
|
||||
// `UnknownSubgraph` — a fetched-but-unloadable blueprint is a runtime
|
||||
// content fault (exit 1), not a missing reference. `blueprint_load_prose`
|
||||
// + `unresolved_namespace_hint` are the same house-style pair
|
||||
// `blueprint_slot_prose`/`composite_from_any` already use over a
|
||||
// `LoadError`.
|
||||
if let Err(e) = blueprint_from_json(&json, &|t| env.resolve(t)) {
|
||||
let mut msg = blueprint_load_prose(&e);
|
||||
if let Some(hint) = unresolved_namespace_hint(&e, env) {
|
||||
msg.push_str(" — ");
|
||||
msg.push_str(&hint);
|
||||
}
|
||||
return Err(format!("registered blueprint {} is invalid: {msg}", id_prefix12(&full_id)));
|
||||
}
|
||||
let instance = name.clone().unwrap_or_else(|| doc.blueprint.name.clone());
|
||||
// The resolution echo (C14 benign class): stderr only, so stdout stays
|
||||
// clean payload (#164's convention, extended to this new note).
|
||||
crate::diag::note!("use {instance:?}: {shown}");
|
||||
cache.insert(full_id.clone(), json);
|
||||
Ok(Op::Use { ref_id: full_id, name, bind: bind.into_iter().collect() })
|
||||
}
|
||||
|
||||
/// Parse a JSON op-list document and replay it through the env's vocabulary into
|
||||
/// a built `Composite` — or a `op N (kind): cause` message (a per-op fault,
|
||||
/// attributed by the retained op-kind list) / `finalize: cause` (a holistic fault
|
||||
/// past the last op).
|
||||
/// past the last op). Every `use` op resolves through the registry HERE, before
|
||||
/// replay (#317): a resolution/doc-gate fault is attributed exactly like any
|
||||
/// other per-op construction fault (same `op N (kind): cause` shape, exit 1).
|
||||
fn composite_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
|
||||
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
|
||||
let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect();
|
||||
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
|
||||
replay("graph", ops, &|t| env.resolve(t)).map_err(|(idx, err)| {
|
||||
let labels: Vec<String> = docs.iter().map(OpDoc::kind_label).collect();
|
||||
let mut cache: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
||||
let mut ops: Vec<Op> = Vec::with_capacity(docs.len());
|
||||
for (idx, d) in docs.into_iter().enumerate() {
|
||||
let op = match d {
|
||||
OpDoc::Use { r#ref, name, bind } => match resolve_use_op(r#ref, name, bind, env, &mut cache) {
|
||||
Ok(op) => op,
|
||||
Err(cause) => return Err(format!("op {idx} ({}): {cause}", labels[idx])),
|
||||
},
|
||||
other => Op::from(other),
|
||||
};
|
||||
ops.push(op);
|
||||
}
|
||||
// The injected `subgraph` lookup (#317): a pure, registry-free map read —
|
||||
// every `use` op's blueprint was already fetched, gated, AND resolved
|
||||
// (`resolve_use_op`'s eager `blueprint_from_json` check) above; this
|
||||
// closure only re-parses the cached bytes (`Composite` is not `Clone`).
|
||||
// The `.ok()` is defensive, not a fallible path in practice: re-parsing
|
||||
// the SAME cached JSON through the SAME pure resolver cannot fail here
|
||||
// once it has already succeeded once above (review finding, #317
|
||||
// follow-up — the failure case now surfaces eagerly, at DTO conversion).
|
||||
let subgraph = |ref_id: &str| {
|
||||
cache.get(ref_id).and_then(|json| blueprint_from_json(json, &|t| env.resolve(t)).ok())
|
||||
};
|
||||
replay("graph", ops, &|t| env.resolve(t), &subgraph).map_err(|(idx, err)| {
|
||||
let mut cause = format_op_error(&err);
|
||||
// #244: the op-script path reports an unresolvable namespaced type as
|
||||
// `OpError::UnknownNodeType`, a different error family than the
|
||||
@@ -201,7 +469,19 @@ pub fn build_cmd(env: &aura_runner::project::Env) {
|
||||
pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
let builder = env.resolve(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?;
|
||||
let schema = builder.schema();
|
||||
let mut out = format!("{}\n", builder.label());
|
||||
// C29 (#315): the head line carries the node's one-line meaning.
|
||||
let mut out = format!("{} — {}\n", builder.label(), schema.doc);
|
||||
// #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));
|
||||
}
|
||||
@@ -219,7 +499,11 @@ pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result
|
||||
pub fn introspect_unwired(doc: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
|
||||
let resolver = |t: &str| env.resolve(t);
|
||||
let mut session = GraphSession::new("introspect", &resolver);
|
||||
// #317: build-free introspection stays subgraph-free by design (spec:
|
||||
// "Build-free introspection paths pass a `|_| None` closure") — a `use`
|
||||
// op here always misses (`OpError::UnknownSubgraph`, `From<OpDoc>`'s own
|
||||
// doc comment), never a registry read.
|
||||
let mut session = GraphSession::new("introspect", &resolver, &|_: &str| None);
|
||||
for (i, d) in docs.into_iter().enumerate() {
|
||||
session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?;
|
||||
}
|
||||
@@ -230,6 +514,33 @@ pub fn introspect_unwired(doc: &str, env: &aura_runner::project::Env) -> Result<
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `aura graph introspect --registered` (#317): one row per registry label —
|
||||
/// `<label> <12-char id prefix> <root doc line>`, latest-wins (the label
|
||||
/// sidecar's own resolution rule) — the discovery surface the worked
|
||||
/// acceptance program's last step reads. An empty store is `no labels
|
||||
/// registered` on stdout, exit 0 (a listing, not a fault); dangling label
|
||||
/// rows (content id no longer resolves) are already skipped by
|
||||
/// `blueprint_labels()`.
|
||||
fn introspect_registered(env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
let registry = env.registry();
|
||||
let labels = registry.blueprint_labels();
|
||||
if labels.is_empty() {
|
||||
return Ok("no labels registered\n".to_string());
|
||||
}
|
||||
let mut out = String::new();
|
||||
for (name, id) in labels {
|
||||
let json = registry
|
||||
.get_blueprint(&id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("label {name:?} names {id}, which is not in the store"))?;
|
||||
let doc: BlueprintDoc = serde_json::from_str(&json)
|
||||
.map_err(|e| format!("registered blueprint {id} is not a valid blueprint: {e}"))?;
|
||||
let root_doc = doc.blueprint.doc.as_deref().unwrap_or("");
|
||||
out.push_str(&format!("{name} {} {root_doc}\n", id_prefix12(&id)));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `aura graph introspect`: dispatch the read-only queries. Exactly one of
|
||||
/// `--vocabulary` / `--node <T>` / `--unwired` / `--params <FILE|ID>` / the id
|
||||
/// group must be set; zero or more than one is the usage error (exit 2). The id
|
||||
@@ -239,17 +550,46 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
|
||||
let count = cmd.vocabulary as usize
|
||||
+ cmd.node.is_some() as usize
|
||||
+ cmd.unwired as usize
|
||||
+ cmd.folds as usize
|
||||
+ cmd.registered as usize
|
||||
+ cmd.params.is_some() as usize
|
||||
+ (cmd.content_id.is_some() || cmd.identity_id) as usize;
|
||||
if count != 1 {
|
||||
eprintln!(
|
||||
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --params <FILE|ID> | --content-id [FILE] | --identity-id (the two id flags may be combined)"
|
||||
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --folds | --registered | --params <FILE|ID> | --content-id [FILE] | --identity-id (the two id flags may be combined)"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
if cmd.vocabulary {
|
||||
// C29 (#315): each type id carries its schema's one-line meaning —
|
||||
// bare names teach nothing (What do Latch, When, Select, Bias do?).
|
||||
// A rostered id that fails to resolve is an invariant breach; better
|
||||
// loud than a silent C29-incomplete bare row.
|
||||
for t in env.type_ids() {
|
||||
println!("{t}");
|
||||
let b = env.resolve(t).expect("every rostered type id resolves to a builder");
|
||||
println!("{t:<18} {}", b.schema().doc);
|
||||
}
|
||||
} else if cmd.folds {
|
||||
// The fold vocabulary binds at graph-declared taps (C27), so the
|
||||
// graph introspect namespace is its discovery surface (#315). #332:
|
||||
// this renders the fold-REGISTRY roster — the same roster `aura run
|
||||
// --tap TAP=FOLD` resolves labels against (aura-runner's layered
|
||||
// `FoldRegistry`) — not the aura-std `FoldKind` table: labels here
|
||||
// must be exactly what `--tap` accepts (lowercase), including the
|
||||
// `record` entry that has no `FoldKind` counterpart.
|
||||
for (label, doc) in aura_runner::FoldRegistry::core().roster() {
|
||||
println!("{label:<7} — {doc}");
|
||||
}
|
||||
} else if cmd.registered {
|
||||
// The label sidecar's discovery surface (#317): one row per
|
||||
// registered label — nothing to build, so a store-only read, exit 0
|
||||
// even on an empty store (a listing, not a fault).
|
||||
match introspect_registered(env) {
|
||||
Ok(s) => print!("{s}"),
|
||||
Err(m) => {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
} else if let Some(type_id) = cmd.node.as_deref() {
|
||||
match introspect_node(type_id, env) {
|
||||
@@ -365,6 +705,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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,13 +878,20 @@ fn params_lines(target: &str, env: &aura_runner::project::Env) -> Result<String,
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `aura graph register <blueprint.json>` (#196): parse the blueprint through
|
||||
/// the project vocabulary, canonicalize, content-address, and store — the
|
||||
/// `process register` pattern, printing the store path so the trail is
|
||||
/// followable. Prose to stderr + exit 1 on any refusal.
|
||||
pub fn register_cmd(file: &Path, env: &aura_runner::project::Env) {
|
||||
match register_blueprint(file, env) {
|
||||
Ok(line) => println!("{line}"),
|
||||
/// `aura graph register <blueprint.json> [--name <label>]` (#196, `--name`
|
||||
/// #317): parse the blueprint through the project vocabulary, canonicalize,
|
||||
/// content-address, and store — the `process register` pattern, printing
|
||||
/// the store path so the trail is followable. With `--name`, additionally
|
||||
/// labels the stored content id (`aura_registry::Registry::put_blueprint_label`),
|
||||
/// echoing the repoint when the label already pointed elsewhere. Prose to
|
||||
/// stderr + exit 1 on any refusal.
|
||||
pub fn register_cmd(file: &Path, name: Option<&str>, env: &aura_runner::project::Env) {
|
||||
match register_blueprint(file, name, env) {
|
||||
Ok(lines) => {
|
||||
for line in lines {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
Err(m) => {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(1);
|
||||
@@ -551,7 +899,11 @@ pub fn register_cmd(file: &Path, env: &aura_runner::project::Env) {
|
||||
}
|
||||
}
|
||||
|
||||
fn register_blueprint(file: &Path, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
fn register_blueprint(
|
||||
file: &Path,
|
||||
name: Option<&str>,
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let text = std::fs::read_to_string(file)
|
||||
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
|
||||
// Shape-discriminated like file-mode --content-id (#202): either shape
|
||||
@@ -561,10 +913,21 @@ fn register_blueprint(file: &Path, env: &aura_runner::project::Env) -> Result<St
|
||||
let id = crate::content_id(&canonical);
|
||||
let registry = env.registry();
|
||||
registry.put_blueprint(&id, &canonical).map_err(|e| e.to_string())?;
|
||||
Ok(format!(
|
||||
let mut lines = vec![format!(
|
||||
"registered blueprint {id} ({})",
|
||||
registry.blueprint_path(&id).display()
|
||||
))
|
||||
)];
|
||||
if let Some(label) = name {
|
||||
// Resolve BEFORE writing, so the repoint echo compares against the
|
||||
// pre-write target (#317).
|
||||
let previous = registry.resolve_blueprint_label(label);
|
||||
registry.put_blueprint_label(label, &id).map_err(|e| e.to_string())?;
|
||||
lines.push(match previous {
|
||||
Some(old) if old != id => format!("label {label:?} -> {id} (was {old})"),
|
||||
_ => format!("label {label:?} -> {id}"),
|
||||
});
|
||||
}
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -593,7 +956,8 @@ mod tests {
|
||||
assert_eq!(docs[1].kind_label(), "add");
|
||||
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
|
||||
// both SMA lengths are bound, so the only open param is bias.scale (f64).
|
||||
let composite = replay("graph", ops, &std_vocabulary).expect("replay resolves");
|
||||
let composite =
|
||||
replay("graph", ops, &std_vocabulary, &|_: &str| None).expect("replay resolves");
|
||||
composite.compile_with_params(&[Scalar::f64(0.5)]).expect("compiles");
|
||||
}
|
||||
|
||||
|
||||
+187
-9
@@ -46,7 +46,7 @@ use aura_measurement::information_coefficient;
|
||||
// (a sibling module reaching it via `crate::blueprint_axis_probe_reopened`,
|
||||
// the crate-root re-export this `use` gives it), not from this module itself.
|
||||
use aura_runner::member::{blueprint_axis_probe, blueprint_axis_probe_reopened, run_signal_r, wrapped_bound_names, RunData};
|
||||
use aura_runner::TapPlan;
|
||||
use aura_runner::{TapPlan, TapSubscription};
|
||||
#[cfg(test)]
|
||||
use aura_runner::member::{run_blueprint_member, wrap_r, SYNTHETIC_PIP_SIZE};
|
||||
// The family builders (blueprint sweep / walk-forward / MC), the shared
|
||||
@@ -105,6 +105,7 @@ use aura_std::Sma;
|
||||
#[cfg(test)]
|
||||
use std::sync::mpsc;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::BTreeSet;
|
||||
#[cfg(test)]
|
||||
use std::collections::BTreeMap;
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
@@ -1026,11 +1027,32 @@ fn version_string() -> &'static str {
|
||||
/// The `aura` root parser. `version` is built from `CARGO_PKG_VERSION` (the
|
||||
/// workspace `0.1.0`) plus the parenthesized `ENGINE_COMMIT`, so
|
||||
/// `aura --version` prints `aura 0.1.0 (<commit>)`.
|
||||
/// The two-layer concepts paragraph `aura --help` opens with (#315): the
|
||||
/// zero-setup self-description a reader without repo access gets.
|
||||
const CONCEPTS_HELP: &str = "\
|
||||
Author, backtest, and validate trading strategies — research CLI.
|
||||
|
||||
Two layers, one vocabulary: the research verbs are the convenience surface —
|
||||
over --real data, sweep, walkforward, mc, and generalize desugar to
|
||||
registered process/campaign documents and execute them (run is their
|
||||
single-backtest sibling; synthetic runs execute in-process). That document
|
||||
data plane is directly authorable: `aura process` / `aura campaign`
|
||||
(validate | introspect | register | run), growing a document from a bare {}
|
||||
via `introspect --unwired`.
|
||||
|
||||
Execution model: a strategy emits a bias in [-1,+1] per cycle, held as the
|
||||
continuously-tracked target position; a protective stop defines the risk unit
|
||||
R, and signal quality is measured in R.
|
||||
|
||||
Traces: `sweep --real --trace` / `walkforward --real --trace` record per-cycle
|
||||
taps under runs/, consumed by `aura chart` and `aura measure`.";
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "aura",
|
||||
version = version_string(),
|
||||
about = "Author, backtest, and validate trading strategies — research CLI",
|
||||
long_about = CONCEPTS_HELP,
|
||||
infer_long_args = true
|
||||
)]
|
||||
struct Cli {
|
||||
@@ -1044,22 +1066,43 @@ struct Cli {
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Run a single backtest (built-in harness or a loaded blueprint).
|
||||
///
|
||||
/// Part of the research sugar surface; the canonical, document-first form
|
||||
/// of any research run is a registered process + campaign pair — see
|
||||
/// `aura process` / `aura campaign`.
|
||||
Run(RunCmd),
|
||||
/// Render a recorded run's trace to an HTML chart.
|
||||
Chart(ChartCmd),
|
||||
/// Emit / construct / introspect a graph.
|
||||
Graph(GraphCmd),
|
||||
/// Sweep a parameter grid over a strategy or a loaded blueprint.
|
||||
///
|
||||
/// Sugar over the document layer: over --real data this desugars to a
|
||||
/// registered process (std::sweep) + campaign pair — author the documents
|
||||
/// directly via `aura process` / `aura campaign`.
|
||||
Sweep(SweepCmd),
|
||||
/// Walk-forward validation over a strategy or a loaded blueprint. Over --real, the
|
||||
/// fixed 90/30-day roller fits to a --from/--to window shorter than it, preserving
|
||||
/// the 3:1 IS:OOS ratio, instead of refusing the window outright.
|
||||
///
|
||||
/// Sugar over the document layer: over --real data this desugars to a
|
||||
/// registered process ([std::grid, std::walk_forward]) + campaign pair —
|
||||
/// author the documents directly via `aura process` / `aura campaign`.
|
||||
Walkforward(WalkforwardCmd),
|
||||
/// Grade one candidate across multiple instruments.
|
||||
///
|
||||
/// Sugar over the document layer: desugars to a registered process
|
||||
/// ([std::sweep (selection), std::generalize]) + campaign pair — author
|
||||
/// the documents directly via `aura process` / `aura campaign`.
|
||||
Generalize(GeneralizeCmd),
|
||||
/// Monte-Carlo over synthetic draws, an R-bootstrap, or a loaded blueprint. Over
|
||||
/// --real, the fixed 90/30-day walk-forward roller fits to a --from/--to window
|
||||
/// shorter than it, preserving the 3:1 IS:OOS ratio, instead of refusing outright.
|
||||
///
|
||||
/// Sugar over the document layer: over --real data this desugars to a
|
||||
/// registered process ([std::grid, std::walk_forward, std::monte_carlo]) +
|
||||
/// campaign pair — author the documents directly via `aura process` /
|
||||
/// `aura campaign`.
|
||||
Mc(McCmd),
|
||||
/// List or inspect recorded run families.
|
||||
Runs(RunsCmd),
|
||||
@@ -1181,6 +1224,7 @@ struct GraphCmd {
|
||||
#[derive(Subcommand)]
|
||||
enum GraphSub {
|
||||
/// Construct a graph from a stdin op-list.
|
||||
#[command(after_help = crate::graph_construct::OP_REFERENCE)]
|
||||
Build,
|
||||
/// Introspect a graph.
|
||||
Introspect(GraphIntrospectCmd),
|
||||
@@ -1188,12 +1232,16 @@ enum GraphSub {
|
||||
Register {
|
||||
/// The blueprint .json file to register.
|
||||
file: std::path::PathBuf,
|
||||
/// Label the registered content id for `use` by name (#317); a
|
||||
/// re-registered label repoints (latest-wins on resolve).
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct GraphIntrospectCmd {
|
||||
/// List the closed node vocabulary (one node type per line).
|
||||
/// List the closed node vocabulary (one node type + its meaning per line).
|
||||
#[arg(long)]
|
||||
vocabulary: bool,
|
||||
/// Describe one node type's ports by name.
|
||||
@@ -1202,6 +1250,12 @@ struct GraphIntrospectCmd {
|
||||
/// List the graph's unwired (unbound) ports.
|
||||
#[arg(long)]
|
||||
unwired: bool,
|
||||
/// List the closed tap-fold vocabulary (fold, bind rule, output kind, meaning).
|
||||
#[arg(long)]
|
||||
folds: bool,
|
||||
/// List every registered blueprint label (#317): label, id prefix, root doc.
|
||||
#[arg(long)]
|
||||
registered: bool,
|
||||
/// Print the graph's content id (topology hash). With FILE, read the
|
||||
/// document from it (a blueprint envelope or an op-list, shape-
|
||||
/// discriminated, #196); without FILE, read a stdin op-list as before.
|
||||
@@ -1297,6 +1351,12 @@ struct RunCmd {
|
||||
/// Not accepted — CLI-side trace persistence is retired (see #224).
|
||||
#[arg(long)]
|
||||
trace: Option<String>,
|
||||
/// Subscribe a declared tap to a fold for this run (repeatable,
|
||||
/// TAP=FOLD; e.g. --tap signal=mean). Replaces the record-all
|
||||
/// default: only listed taps are bound, unlisted taps stay unbound.
|
||||
/// Fold roster: `aura graph introspect --folds`.
|
||||
#[arg(long = "tap", value_name = "TAP=FOLD")]
|
||||
tap: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -1421,7 +1481,7 @@ fn is_blueprint_file(arg: &Option<String>) -> Option<&str> {
|
||||
/// mirroring the old `parse_blueprint_run_args` window guard (`--from`/`--to`
|
||||
/// require `--real`; empty symbol rejected). Refuses in place (stderr + exit 2).
|
||||
fn run_data_from(real: Option<&str>, from: Option<i64>, to: Option<i64>) -> RunData {
|
||||
let usage = "Usage: aura run <blueprint.json> [--params <json-cell-array>] [--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]]";
|
||||
let usage = "Usage: aura run <blueprint.json> [--params <json-cell-array>] [--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--tap <TAP=FOLD> …]";
|
||||
match real {
|
||||
Some(s) if !s.is_empty() => RunData::Real { symbol: s.to_string(), from, to },
|
||||
Some(_) => {
|
||||
@@ -1715,18 +1775,44 @@ fn campaign_window_ms(choice: DataChoice, env: &aura_runner::project::Env) -> (i
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the run's tap plan from repeated `--tap TAP=FOLD` selections
|
||||
/// (#310). No selections → the record-all default (today's behaviour).
|
||||
/// Any selection → an explicit plan that REPLACES the default entirely:
|
||||
/// only listed taps are bound; unlisted declared taps stay unbound,
|
||||
/// which C27 defines as inert, not an error. Tap/label existence is
|
||||
/// bind_tap_plan's to validate (roster refusal / UndeclaredTap), before
|
||||
/// any store I/O.
|
||||
fn tap_plan_from_args(args: &[String]) -> Result<TapPlan, String> {
|
||||
if args.is_empty() {
|
||||
return Ok(TapPlan::record_all());
|
||||
}
|
||||
let mut plan = TapPlan::empty();
|
||||
let mut seen = BTreeSet::new();
|
||||
for raw in args {
|
||||
let (tap, label) = match raw.split_once('=') {
|
||||
Some((t, l)) if !t.is_empty() && !l.is_empty() => (t, l),
|
||||
_ => return Err(format!("--tap expects TAP=FOLD, got \"{raw}\"")),
|
||||
};
|
||||
if !seen.insert(tap.to_string()) {
|
||||
return Err(format!("--tap names tap \"{tap}\" twice"));
|
||||
}
|
||||
plan.subscribe(tap, TapSubscription::named(label));
|
||||
}
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// `aura run`: the loaded-blueprint branch (an existing `.json` first-positional) or
|
||||
/// the built-in harness-kind dispatch.
|
||||
fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) {
|
||||
match is_blueprint_file(&a.blueprint) {
|
||||
Some(path) => {
|
||||
// The loaded-blueprint grammar takes only --params/--seed/--real/--from/--to;
|
||||
// The loaded-blueprint grammar takes only --params/--seed/--real/--from/--to/--tap;
|
||||
// the built-in-only flags are rejected here (exit 2), never silently dropped —
|
||||
// mirroring the sweep/mc blueprint branches, which reject their non-branch flags
|
||||
// exhaustively (refuse-don't-guess). clap's optional `[blueprint]` positional
|
||||
// makes these structurally parseable, so the guard is re-asserted at dispatch.
|
||||
if a.trace.is_some() {
|
||||
eprintln!("aura: Usage: aura run <blueprint.json> [--params <json-cell-array>] [--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]]");
|
||||
eprintln!("aura: Usage: aura run <blueprint.json> [--params <json-cell-array>] [--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--tap <TAP=FOLD> …]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let doc = std::fs::read_to_string(path).unwrap_or_else(|e| {
|
||||
@@ -1751,6 +1837,13 @@ fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) {
|
||||
// probing a no-`bias` signal would panic on UnknownOutPort).
|
||||
let has_bias = signal.output().iter().any(|o| o.name == "bias");
|
||||
let has_tap = !signal.taps().is_empty();
|
||||
let tap_plan = match tap_plan_from_args(&a.tap) {
|
||||
Ok(p) => p,
|
||||
Err(m) => {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
if has_bias {
|
||||
// Refuse an open (free-knob) blueprint at the dispatch boundary,
|
||||
// mirroring `blueprint_mc_family`'s closed-guard: `run` bootstraps
|
||||
@@ -1773,7 +1866,7 @@ fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) {
|
||||
None => Vec::new(),
|
||||
};
|
||||
let data = run_data_from(a.real.as_deref(), a.from, a.to);
|
||||
let report = run_signal_r(signal, ¶ms, data, a.seed.unwrap_or(0), env, TapPlan::record_all());
|
||||
let report = run_signal_r(signal, ¶ms, data, a.seed.unwrap_or(0), env, tap_plan);
|
||||
println!("{}", report.to_json());
|
||||
} else if has_tap {
|
||||
// Measurement path: wrap_r-free closed guard via the signal's own
|
||||
@@ -1794,7 +1887,7 @@ fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) {
|
||||
None => Vec::new(),
|
||||
};
|
||||
let data = run_data_from(a.real.as_deref(), a.from, a.to);
|
||||
let report = run_measurement(signal, ¶ms, data, a.seed.unwrap_or(0), env, TapPlan::record_all());
|
||||
let report = run_measurement(signal, ¶ms, data, a.seed.unwrap_or(0), env, tap_plan);
|
||||
println!("{}", report.to_json());
|
||||
} else {
|
||||
eprintln!(
|
||||
@@ -1807,7 +1900,8 @@ fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) {
|
||||
None => {
|
||||
eprintln!(
|
||||
"aura: Usage: aura run <blueprint.json> [--params <json-cell-array>] \
|
||||
[--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]]"
|
||||
[--seed <n>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] \
|
||||
[--tap <TAP=FOLD> …]"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
@@ -1856,7 +1950,7 @@ fn dispatch_graph(a: GraphCmd, env: &aura_runner::project::Env) {
|
||||
},
|
||||
Some(GraphSub::Build) => graph_construct::build_cmd(env),
|
||||
Some(GraphSub::Introspect(i)) => graph_construct::introspect_cmd(i, env),
|
||||
Some(GraphSub::Register { file }) => graph_construct::register_cmd(&file, env),
|
||||
Some(GraphSub::Register { file, name }) => graph_construct::register_cmd(&file, name.as_deref(), env),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3715,6 +3809,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 —
|
||||
@@ -3813,5 +3955,41 @@ mod tests {
|
||||
assert_eq!(stop_length, R_SMA_STOP_LENGTH, "omitted --stop-length defaults to the regime");
|
||||
assert_eq!(stop_k, R_SMA_STOP_K, "omitted --stop-k defaults to the regime");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tap_plan_from_args_empty_is_record_all_ok() {
|
||||
assert!(tap_plan_from_args(&[]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tap_plan_from_args_refuses_a_pair_without_equals() {
|
||||
let err = match tap_plan_from_args(&["fast_tapmean".to_string()]) {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("expected an error"),
|
||||
};
|
||||
assert_eq!(err, "--tap expects TAP=FOLD, got \"fast_tapmean\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tap_plan_from_args_refuses_empty_tap_or_label() {
|
||||
assert!(tap_plan_from_args(&["=mean".to_string()]).is_err());
|
||||
assert!(tap_plan_from_args(&["fast_tap=".to_string()]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tap_plan_from_args_refuses_the_same_tap_twice() {
|
||||
let args = vec!["a=mean".to_string(), "a=last".to_string()];
|
||||
let err = match tap_plan_from_args(&args) {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("expected an error"),
|
||||
};
|
||||
assert_eq!(err, "--tap names tap \"a\" twice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tap_plan_from_args_accepts_distinct_selections() {
|
||||
let args = vec!["a=mean".to_string(), "b=record".to_string()];
|
||||
assert!(tap_plan_from_args(&args).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,10 +107,12 @@ fn print_metric_roster() {
|
||||
(true, false) => "rankable",
|
||||
(false, false) => "annotation",
|
||||
};
|
||||
// C29 (#315): the roster carries each metric's one-line meaning, not
|
||||
// only where it may be used.
|
||||
if generalize {
|
||||
println!("{name:<24} {base} | generalize");
|
||||
println!("{name:<24} {base} | generalize — {}", m.doc);
|
||||
} else {
|
||||
println!("{name:<24} {base}");
|
||||
println!("{name:<24} {base} — {}", m.doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -292,7 +294,7 @@ fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
|
||||
/// caller's existing not-found prose applies unchanged. `Err`: two or more
|
||||
/// candidates share the prefix — refused by name, listing every candidate,
|
||||
/// rather than guessed.
|
||||
fn resolve_id_prefix(prefix: &str, candidates: &[String]) -> Result<Option<String>, String> {
|
||||
pub(crate) fn resolve_id_prefix(prefix: &str, candidates: &[String]) -> Result<Option<String>, String> {
|
||||
let matches: Vec<&String> = candidates.iter().filter(|c| c.starts_with(prefix)).collect();
|
||||
match matches.as_slice() {
|
||||
[] => Ok(None),
|
||||
@@ -372,6 +374,13 @@ fn describe_one_block(id: &str) -> Result<(), String> {
|
||||
for s in schema.slots {
|
||||
let req = if s.required { "required" } else { "optional" };
|
||||
println!(" {:<20} {req}, {}", s.name, slot_kind_label(s.kind));
|
||||
// C29 (#315): the tap slot's closed value vocabulary carries its
|
||||
// per-entry meanings, indented under the slot line.
|
||||
if matches!(s.kind, aura_research::SlotKind::TapKinds) {
|
||||
for t in aura_research::tap_vocabulary() {
|
||||
println!(" {:<14} {}", t.id, t.doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -165,6 +165,8 @@ impl Scale {
|
||||
}],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![ParamSpec { name: "factor".into(), kind: ScalarKind::F64 }],
|
||||
// C29: every vocabulary entry ships a one-line meaning — the
|
||||
// doc field is required (E0063 without it) and gated at load.
|
||||
doc: "scalar gain: emits the input times the factor param",
|
||||
},
|
||||
|p| Box::new(Scale::new(p[0].f64())),
|
||||
@@ -238,6 +240,14 @@ std vocabulary, anchored by `Aura.toml`. There is no crate and no build step.
|
||||
`aura nodes new <name>` scaffolds a node crate beside this project and
|
||||
attaches it via `[nodes]` in `Aura.toml` (build it with `cargo build`).
|
||||
- Topology is data (`blueprints/*.json`); results land in `runs/`.
|
||||
- Execution model: a strategy emits a bias in [-1,+1] per cycle, held as the
|
||||
continuously-tracked target position; a protective stop defines the risk
|
||||
unit R, and quality metrics are R-based. Entry signals become held state
|
||||
via the signal-side latch/edge-pulse idiom (see
|
||||
`aura graph introspect --vocabulary`).
|
||||
- Data plane: the research verbs are sugar over registered process/campaign
|
||||
documents — author them directly with `aura process` / `aura campaign`,
|
||||
growing one from a bare `{}` via `aura campaign introspect --unwired`.
|
||||
"#;
|
||||
|
||||
/// Render a data-only project template: only `__NAME__`/`__NAME_SNAKE__`
|
||||
@@ -455,6 +465,41 @@ mod tests {
|
||||
assert!(claude.contains("demo_lab_signal.fast.length"));
|
||||
}
|
||||
|
||||
/// #315: the scaffolded project CLAUDE.md teaches the execution semantics
|
||||
/// (bias as held target position, protective stop, R) and the document
|
||||
/// data plane — the two things the 2026-07-22 field agent had to discover
|
||||
/// forensically.
|
||||
#[test]
|
||||
fn project_claude_md_teaches_execution_semantics_and_the_data_plane() {
|
||||
let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab");
|
||||
assert!(claude.contains("target position"), "names the held-target model: {claude}");
|
||||
assert!(claude.contains("protective stop"), "names the stop that defines R: {claude}");
|
||||
assert!(claude.contains("aura process"), "points at the document layer: {claude}");
|
||||
assert!(claude.contains("introspect --unwired"), "names the bare-{{}} ramp: {claude}");
|
||||
}
|
||||
|
||||
/// #323: the authoring guide's S0 worked literal is byte-identical to the
|
||||
/// scaffold's `LIB_RS` template (rendered with the guide's `my_lab`
|
||||
/// namespace). The scaffold e2e tests compile that template for real, so
|
||||
/// this equality pin is a transitive compile guard — the guide literal
|
||||
/// can no longer drift silently from the schema (it broke against the
|
||||
/// required `NodeSchema.doc` once).
|
||||
#[test]
|
||||
fn guide_worked_example_matches_the_scaffold_template() {
|
||||
let guide = include_str!("../../../docs/authoring-guide.md");
|
||||
let anchor = "### Worked example: `Scale`";
|
||||
let after = &guide[guide.find(anchor).expect("guide keeps the S0 worked example")..];
|
||||
let start = after.find("```rust\n").expect("worked example is a rust fence") + 8;
|
||||
let fence = &after[start..];
|
||||
let fence = &fence[..fence.find("\n```").expect("fence closes") + 1];
|
||||
let body = &LIB_RS[LIB_RS.find("use aura_core::{").expect("template body starts at the import")..];
|
||||
let expected = body.replace("__NS__", "my_lab");
|
||||
assert_eq!(
|
||||
fence, expected,
|
||||
"docs/authoring-guide.md S0 literal drifted from the compiled scaffold template"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#183, fieldtest F2 gap): the scaffold's own starter node teaches
|
||||
/// the bound-param build closure. Its rendered `src/lib.rs` declares a param in
|
||||
/// the node schema and reads a bound value in the build closure (not the
|
||||
|
||||
@@ -170,8 +170,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 +184,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#"[
|
||||
@@ -354,6 +427,21 @@ fn graph_build_bad_stdin_content_is_runtime_exit_1() {
|
||||
assert!(stderr.contains("Nope"), "still names the cause: {stderr}");
|
||||
}
|
||||
|
||||
/// Property (#326): an op-list element carrying an unknown/typo'd key (e.g.
|
||||
/// `params` where the schema expects `bind`) is refused at parse — not
|
||||
/// silently dropped. Previously the unrecognized key vanished, the field it
|
||||
/// meant to set kept its default, and the wrong graph built with zero
|
||||
/// signal. Same content-fault family as `graph_build_bad_stdin_content_is_
|
||||
/// runtime_exit_1` (both fire from the same top-level deserialize), so the
|
||||
/// exit class matches: exit 1, stderr names the offending key.
|
||||
#[test]
|
||||
fn graph_build_rejects_an_unknown_op_field() {
|
||||
let doc = r#"[{"op":"add","type":"Const","params":{"value":{"F64":1.0}}}]"#;
|
||||
let (stderr, code) = run_code(&["graph", "build"], doc);
|
||||
assert_eq!(code, Some(1), "unknown op field is a content fault -> exit 1; stderr: {stderr}");
|
||||
assert!(stderr.contains("params"), "names the unknown key: {stderr}");
|
||||
}
|
||||
|
||||
/// Property (exit-code partition, #175 iteration 2): within the SAME `graph
|
||||
/// introspect` subcommand, an invalid `--node <T>` flag VALUE is a USAGE error (a
|
||||
/// command-line fault the user must fix in argv) — exit 2 — distinct from bad stdin
|
||||
@@ -451,6 +539,24 @@ fn run_in(dir: &std::path::Path, args: &[&str]) -> (String, String, Option<i32>)
|
||||
)
|
||||
}
|
||||
|
||||
/// Run `aura <args>` in `dir` with `stdin_doc` piped in; return (stderr, exit
|
||||
/// code) — the `run_code`/`run_in` cross, for a `use`-seam fault that needs
|
||||
/// BOTH a project cwd (the label sidecar lives under `<cwd>/runs/`) and a
|
||||
/// piped op-list.
|
||||
fn run_code_in(dir: &std::path::Path, args: &[&str], stdin_doc: &str) -> (String, Option<i32>) {
|
||||
let mut child = Command::new(BIN)
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn aura");
|
||||
child.stdin.take().unwrap().write_all(stdin_doc.as_bytes()).unwrap();
|
||||
let out = child.wait_with_output().expect("wait aura");
|
||||
(String::from_utf8_lossy(&out.stderr).into_owned(), out.status.code())
|
||||
}
|
||||
|
||||
/// The absolute path of a blueprint fixture shipped with this test crate.
|
||||
fn fixture(name: &str) -> String {
|
||||
format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -1536,3 +1642,300 @@ fn tap_authored_via_op_script_runs_and_persists_the_series() {
|
||||
assert!((g - e).abs() < 1e-9, "row {i}: got {g}, expected {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- `use` / label sidecar / open patterns (#317) ------------------------
|
||||
|
||||
/// A small reusable pattern: an open `x` input role feeds an `SMA`, whose
|
||||
/// value re-exports as `out` — the fixture every `use`-seam e2e test below
|
||||
/// registers under a label. `sma.length` stays UNBOUND: the pattern's own
|
||||
/// open param, so a consumer's `--list-axes` sees the bare (unbound) form.
|
||||
const OPEN_PATTERN_DOC: &str = r#"[
|
||||
{"op":"doc","text":"a reusable smoothing pattern"},
|
||||
{"op":"input","role":"x"},
|
||||
{"op":"add","type":"SMA","name":"sma"},
|
||||
{"op":"feed","role":"x","into":["sma.series"]},
|
||||
{"op":"expose","from":"sma.value","as":"out"}
|
||||
]"#;
|
||||
|
||||
/// Build `doc` via `aura graph build` in `dir` and write the resulting
|
||||
/// envelope to `dir/<stem>.bp.json`, returning its path.
|
||||
fn build_envelope_in(dir: &std::path::Path, doc: &str, stem: &str) -> std::path::PathBuf {
|
||||
let (bytes, _e, ok) = run(&["graph", "build"], doc);
|
||||
assert!(ok, "the fixture document builds: {doc}");
|
||||
let path = dir.join(format!("{stem}.bp.json"));
|
||||
std::fs::write(&path, &bytes).expect("write built envelope");
|
||||
path
|
||||
}
|
||||
|
||||
/// `graph register --name` (#317): the label sidecar echo on first
|
||||
/// registration, and the repoint echo (`(was <old-id>)`) on a second
|
||||
/// registration of DIFFERENT bytes under the SAME label.
|
||||
#[test]
|
||||
fn graph_register_name_labels_and_repoints() {
|
||||
let dir = temp_cwd("register-name");
|
||||
let bp = build_envelope_in(&dir, OPEN_PATTERN_DOC, "pattern");
|
||||
let (out1, err1, code1) =
|
||||
run_in(&dir, &["graph", "register", bp.to_str().unwrap(), "--name", "smooth"]);
|
||||
assert_eq!(code1, Some(0), "first register --name: {out1} {err1}");
|
||||
let id1 = registered_id(&out1);
|
||||
assert!(
|
||||
out1.lines().any(|l| l == format!("label \"smooth\" -> {id1}")),
|
||||
"the plain label echo (no repoint on a fresh label): {out1}"
|
||||
);
|
||||
|
||||
// Different bytes (a bound sma.length), same label -> a repoint.
|
||||
let doc2 = OPEN_PATTERN_DOC.replace(
|
||||
r#"{"op":"add","type":"SMA","name":"sma"}"#,
|
||||
r#"{"op":"add","type":"SMA","name":"sma","bind":{"length":{"I64":7}}}"#,
|
||||
);
|
||||
let bp2 = build_envelope_in(&dir, &doc2, "pattern2");
|
||||
let (out2, err2, code2) =
|
||||
run_in(&dir, &["graph", "register", bp2.to_str().unwrap(), "--name", "smooth"]);
|
||||
assert_eq!(code2, Some(0), "second register --name: {out2} {err2}");
|
||||
let id2 = registered_id(&out2);
|
||||
assert_ne!(id1, id2, "the two registrations are distinct content");
|
||||
assert!(
|
||||
out2.lines().any(|l| l == format!("label \"smooth\" -> {id2} (was {id1})")),
|
||||
"the repoint echo names both ids: {out2}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `use` resolves a registry label (#317): the resolution echo lands on
|
||||
/// STDERR (`aura: note: use "<instance>": <label> -> <full id>`), stdout
|
||||
/// stays clean payload — the existing e2e convention this cycle extends.
|
||||
#[test]
|
||||
fn graph_build_use_resolves_a_label_and_echoes_the_id() {
|
||||
let dir = temp_cwd("use-resolves-label");
|
||||
let bp = build_envelope_in(&dir, OPEN_PATTERN_DOC, "pattern");
|
||||
let (reg_out, reg_err, reg_code) =
|
||||
run_in(&dir, &["graph", "register", bp.to_str().unwrap(), "--name", "smooth"]);
|
||||
assert_eq!(reg_code, Some(0), "register --name: {reg_out} {reg_err}");
|
||||
let id = registered_id(®_out);
|
||||
|
||||
let consumer = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"use","ref":{"name":"smooth"},"name":"trend"},
|
||||
{"op":"feed","role":"price","into":["trend.x"]},
|
||||
{"op":"expose","from":"trend.out","as":"bias"}
|
||||
]"#;
|
||||
let mut child = Command::new(BIN)
|
||||
.args(["graph", "build"])
|
||||
.current_dir(&dir)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn aura graph build");
|
||||
child.stdin.take().unwrap().write_all(consumer.as_bytes()).unwrap();
|
||||
let out = child.wait_with_output().expect("wait aura graph build");
|
||||
assert!(out.status.success(), "build succeeds: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(
|
||||
stderr.trim(),
|
||||
format!("aura: note: use \"trend\": smooth -> {id}"),
|
||||
"the resolution echo names the instance, the label, and the full id: {stderr}"
|
||||
);
|
||||
assert!(stdout.starts_with('{'), "stdout stays clean blueprint payload: {stdout}");
|
||||
assert!(!stdout.contains("aura: note:"), "the note never leaks onto stdout: {stdout}");
|
||||
assert!(
|
||||
stdout.contains(&format!("\"doc\":\"{}\"", "a reusable smoothing pattern")),
|
||||
"the spliced instance's own doc survives inline: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
/// An unknown `use` label enumerates every registered label (C29's "name the
|
||||
/// closed set" idiom) and refuses at exit 1 (op-list content fault, #175).
|
||||
#[test]
|
||||
fn graph_build_use_unknown_label_enumerates_labels_exit_1() {
|
||||
let dir = temp_cwd("use-unknown-label");
|
||||
let bp = build_envelope_in(&dir, OPEN_PATTERN_DOC, "pattern");
|
||||
let (reg_out, reg_err, reg_code) =
|
||||
run_in(&dir, &["graph", "register", bp.to_str().unwrap(), "--name", "smooth"]);
|
||||
assert_eq!(reg_code, Some(0), "register --name: {reg_out} {reg_err}");
|
||||
|
||||
let consumer = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"use","ref":{"name":"nope"},"name":"trend"},
|
||||
{"op":"feed","role":"price","into":["trend.x"]},
|
||||
{"op":"expose","from":"trend.out","as":"bias"}
|
||||
]"#;
|
||||
let out = std::process::Command::new(BIN)
|
||||
.args(["graph", "build"])
|
||||
.current_dir(&dir)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.and_then(|mut child| {
|
||||
use std::io::Write;
|
||||
child.stdin.take().unwrap().write_all(consumer.as_bytes())?;
|
||||
child.wait_with_output()
|
||||
})
|
||||
.expect("run aura graph build");
|
||||
assert_eq!(out.status.code(), Some(1), "unknown label is a content fault -> exit 1");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stdout.is_empty(), "no blueprint emitted on refusal: {stdout}");
|
||||
assert!(stderr.contains("op 1 (use \"trend\")"), "names the op by index and instance: {stderr}");
|
||||
assert!(stderr.contains("no registered blueprint labeled \"nope\""), "names the miss: {stderr}");
|
||||
assert!(stderr.contains("registered labels: smooth"), "enumerates the closed set: {stderr}");
|
||||
}
|
||||
|
||||
/// The empty-store form of the unknown-label refusal: no labels to
|
||||
/// enumerate reads as prose, not a bare empty list.
|
||||
#[test]
|
||||
fn graph_build_use_unknown_label_empty_store_reads_as_prose() {
|
||||
let dir = temp_cwd("use-unknown-label-empty-store");
|
||||
let consumer = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"use","ref":{"name":"nope"},"name":"trend"},
|
||||
{"op":"feed","role":"price","into":["trend.x"]},
|
||||
{"op":"expose","from":"trend.out","as":"bias"}
|
||||
]"#;
|
||||
let (stderr, code) = run_code_in(&dir, &["graph", "build"], consumer);
|
||||
assert_eq!(code, Some(1), "still a content fault -> exit 1");
|
||||
assert!(
|
||||
stderr.contains("no registered blueprint labeled \"nope\" — no labels registered"),
|
||||
"the empty-store form reads as prose: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// C29 at the `use` seam (#317): a doc-less registered blueprint refuses
|
||||
/// entering a NEW composition, naming the ref's id-prefix, the failing
|
||||
/// (here root, "nested" from the consumer's view) composite, and the rule —
|
||||
/// exit 1. The doc-less entry reaches the store via the RAW file write
|
||||
/// existing pre-C29 fixtures use (`register_refuses_undescribed_composites_
|
||||
/// end_to_end`'s technique): `graph register` itself always C29-gates, so a
|
||||
/// doc-less entry can only reach the store by writing the file directly.
|
||||
#[test]
|
||||
fn graph_build_use_docless_source_refuses_with_the_c29_shape_exit_1() {
|
||||
let dir = temp_cwd("use-docless-source");
|
||||
// A doc-less pattern: same shape as OPEN_PATTERN_DOC minus the `doc` op.
|
||||
let docless = r#"[
|
||||
{"op":"input","role":"x"},
|
||||
{"op":"add","type":"SMA","name":"sma","bind":{"length":{"I64":3}}},
|
||||
{"op":"feed","role":"x","into":["sma.series"]},
|
||||
{"op":"expose","from":"sma.value","as":"out"}
|
||||
]"#;
|
||||
let (envelope, _e, built) = run(&["graph", "build"], docless);
|
||||
assert!(built, "the doc-less pattern still builds (C29 gates register/use, not build)");
|
||||
let (id_out, _e2, id_ok) = run(&["graph", "introspect", "--content-id"], docless);
|
||||
assert!(id_ok, "content-id computes without a doc");
|
||||
let id = id_out.trim().to_string();
|
||||
let store_dir = dir.join("runs").join("blueprints");
|
||||
std::fs::create_dir_all(&store_dir).expect("create store dir");
|
||||
std::fs::write(store_dir.join(format!("{id}.json")), &envelope).expect("raw store write");
|
||||
|
||||
let consumer = format!(
|
||||
r#"[
|
||||
{{"op":"source","role":"price","kind":"F64"}},
|
||||
{{"op":"use","ref":{{"content_id":"{id}"}},"name":"gate"}},
|
||||
{{"op":"feed","role":"price","into":["gate.x"]}},
|
||||
{{"op":"expose","from":"gate.out","as":"bias"}}
|
||||
]"#
|
||||
);
|
||||
let (stderr, code) = run_code_in(&dir, &["graph", "build"], &consumer);
|
||||
assert_eq!(code, Some(1), "a doc-less fetched composite refuses -> exit 1: {stderr}");
|
||||
assert!(stderr.contains("op 1 (use \"gate\")"), "names the op by index and instance: {stderr}");
|
||||
assert!(stderr.contains(&id[..12]), "names the ref's id-prefix: {stderr}");
|
||||
assert!(stderr.contains("fails the description gate"), "names the rule: {stderr}");
|
||||
assert!(
|
||||
stderr.contains("has no description") && stderr.contains("re-register it with a \"doc\" op"),
|
||||
"carries the re-register hint: {stderr}"
|
||||
);
|
||||
assert!(stderr.contains("C29"), "cites the contract: {stderr}");
|
||||
}
|
||||
|
||||
/// The worked acceptance flow's last leg (spec §Concrete code shapes): a
|
||||
/// pattern registered with an open param splices under an instance, and
|
||||
/// `aura sweep --list-axes` on the CONSUMER shows the path-qualified bare
|
||||
/// (unbound) axis `graph.<instance>.<node>.<param>:<kind>` — the existing
|
||||
/// nested-composite param-prefix discipline, reached through `use` this
|
||||
/// time, not a Rust-authored nested composite.
|
||||
#[test]
|
||||
fn graph_build_use_end_to_end_axes() {
|
||||
let dir = temp_cwd("use-end-to-end-axes");
|
||||
let bp = build_envelope_in(&dir, OPEN_PATTERN_DOC, "pattern");
|
||||
let (reg_out, reg_err, reg_code) =
|
||||
run_in(&dir, &["graph", "register", bp.to_str().unwrap(), "--name", "smooth"]);
|
||||
assert_eq!(reg_code, Some(0), "register --name: {reg_out} {reg_err}");
|
||||
|
||||
let consumer = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"use","ref":{"name":"smooth"},"name":"trend"},
|
||||
{"op":"feed","role":"price","into":["trend.x"]},
|
||||
{"op":"expose","from":"trend.out","as":"bias"}
|
||||
]"#;
|
||||
let consumer_path = dir.join("consumer.ops.json");
|
||||
std::fs::write(&consumer_path, consumer).expect("write consumer fixture");
|
||||
let build = std::process::Command::new(BIN)
|
||||
.args(["graph", "build"])
|
||||
.current_dir(&dir)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.and_then(|mut child| {
|
||||
use std::io::Write;
|
||||
child.stdin.take().unwrap().write_all(consumer.as_bytes())?;
|
||||
child.wait_with_output()
|
||||
})
|
||||
.expect("run aura graph build");
|
||||
assert!(build.status.success(), "consumer builds: {}", String::from_utf8_lossy(&build.stderr));
|
||||
let consumer_bp = dir.join("consumer.bp.json");
|
||||
std::fs::write(&consumer_bp, &build.stdout).expect("write consumer envelope");
|
||||
|
||||
let (axes_out, axes_err, axes_code) =
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
/// Open patterns (#317 §"Open patterns", acceptance criterion 6): an
|
||||
/// `input`-role op-script — no bound root role at all — builds cleanly:
|
||||
/// `finish()` no longer gates root-role boundness, only `compile`/bootstrap
|
||||
/// do. The agree flow's first half (register is exercised by the other
|
||||
/// `use`-seam tests above; this pins the build step alone).
|
||||
#[test]
|
||||
fn graph_build_accepts_an_open_input_pattern() {
|
||||
let (stdout, stderr, ok) = run(&["graph", "build"], OPEN_PATTERN_DOC);
|
||||
assert!(ok, "an input-role pattern builds: {stderr}");
|
||||
assert!(stdout.contains("\"input_roles\""), "carries the open root role: {stdout}");
|
||||
assert!(!stdout.contains("\"source\""), "the role carries no bound kind (open, #317): {stdout}");
|
||||
}
|
||||
|
||||
/// Open patterns, the OTHER half: running an open blueprint standalone
|
||||
/// still refuses — the runnability gate moved nowhere, only `finish()`
|
||||
/// stopped pre-empting it (#317). `aura run`'s `bias`-output arm re-roots
|
||||
/// through `wrap_r` (an existing, unrelated nesting mechanism unaffected by
|
||||
/// this cycle), so a bare-tap (no-`bias`) pattern is used here: its
|
||||
/// `run_measurement` path compiles the signal directly, hitting
|
||||
/// `CompileError::UnboundRootRole` at bootstrap — via the EXISTING `{e:?}`
|
||||
/// Debug rendering (a raw index, not a role name); a name mapping there is
|
||||
/// left for a follow-up (out of this cycle's scope, per the plan's own
|
||||
/// escape hatch), so this pins the existing form.
|
||||
#[test]
|
||||
fn running_an_open_blueprint_refuses_at_bootstrap() {
|
||||
let doc = r#"[
|
||||
{"op":"input","role":"price"},
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
||||
{"op":"feed","role":"price","into":["fast.series"]},
|
||||
{"op":"tap","from":"fast.value","as":"fast_ma"}
|
||||
]"#;
|
||||
let dir = temp_cwd("open-pattern-run-refuses");
|
||||
let (build_out, _e, built) = run(&["graph", "build"], doc);
|
||||
assert!(built, "an open (Input) root role finishes without root-role boundness (#317)");
|
||||
let bp = dir.join("open.bp.json");
|
||||
std::fs::write(&bp, &build_out).expect("write built blueprint");
|
||||
let (stdout, stderr, code) = run_in(&dir, &["run", bp.to_str().unwrap()]);
|
||||
assert_eq!(code, Some(1), "an open blueprint refuses standalone at bootstrap, not finish: {stdout} {stderr}");
|
||||
assert!(stdout.is_empty(), "no report emitted on a bootstrap refusal: {stdout}");
|
||||
assert!(
|
||||
stderr.contains("UnboundRootRole"),
|
||||
"the runnability gate (compile), unchanged, names the fault: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
//! End-to-end pins for the self-description surfaces (#315, #323): the help
|
||||
//! opens with the two-layer concept, the sugar verbs name the document shape
|
||||
//! they desugar to, every introspection roster carries per-entry meanings,
|
||||
//! and `graph build --help` carries the op-list reference. Driven over the
|
||||
//! built binary — the zero-setup surface a reader without repo access gets.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
||||
|
||||
/// Run `aura <args>` (no stdin); return (stdout, stderr, success).
|
||||
fn run(args: &[&str]) -> (String, String, bool) {
|
||||
let out = Command::new(BIN).args(args).output().expect("spawn aura");
|
||||
(
|
||||
String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||
String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
out.status.success(),
|
||||
)
|
||||
}
|
||||
|
||||
/// #315: `aura --help` opens with the concepts paragraph — the two layers,
|
||||
/// the bias-as-target-position + protective-stop execution model, and how
|
||||
/// traces come to exist. Each pin sits on one authored line of the explicit
|
||||
/// `long_about` string, so clap's wrapping cannot split it.
|
||||
#[test]
|
||||
fn top_level_help_opens_with_the_two_layer_concept() {
|
||||
let (out, err, ok) = run(&["--help"]);
|
||||
assert!(ok, "aura --help failed: {err}");
|
||||
assert!(out.contains("research verbs"), "names the sugar layer: {out}");
|
||||
assert!(out.contains("directly authorable"), "names the document data plane: {out}");
|
||||
assert!(out.contains("bias in [-1,+1]"), "names the bias output: {out}");
|
||||
assert!(out.contains("defines the risk unit"), "names the protective stop / R: {out}");
|
||||
assert!(out.contains("introspect --unwired"), "names the bare-{{}} ramp: {out}");
|
||||
assert!(out.contains("aura chart"), "names the trace consumers: {out}");
|
||||
}
|
||||
|
||||
/// #315: each document-bridged sugar verb's long help names the process
|
||||
/// shape it desugars to and points at the document layer. `run` (not
|
||||
/// document-bridged) points at the canonical document-first form instead.
|
||||
#[test]
|
||||
fn sugar_verbs_name_their_document_shape() {
|
||||
for (verb, needle) in [
|
||||
("sweep", "std::sweep"),
|
||||
("walkforward", "std::walk_forward"),
|
||||
("mc", "std::monte_carlo"),
|
||||
("generalize", "std::generalize"),
|
||||
] {
|
||||
let (out, err, ok) = run(&[verb, "--help"]);
|
||||
assert!(ok, "aura {verb} --help failed: {err}");
|
||||
assert!(out.contains("Sugar"), "{verb} --help names the sugar relation: {out}");
|
||||
assert!(out.contains(needle), "{verb} --help names {needle}: {out}");
|
||||
assert!(out.contains("aura campaign"), "{verb} --help points at the documents: {out}");
|
||||
}
|
||||
let (out, err, ok) = run(&["run", "--help"]);
|
||||
assert!(ok, "aura run --help failed: {err}");
|
||||
assert!(out.contains("document-first"), "run --help names the canonical form: {out}");
|
||||
}
|
||||
|
||||
/// #323: `graph build --help` carries the op-list reference — the op kinds
|
||||
/// and fields are learnable from the binary, not only from serde refusals.
|
||||
/// #317: the `use` op joins the roster (nine -> ten).
|
||||
#[test]
|
||||
fn graph_build_help_carries_the_op_reference() {
|
||||
let (out, err, ok) = run(&["graph", "build", "--help"]);
|
||||
assert!(ok, "aura graph build --help failed: {err}");
|
||||
for op in [
|
||||
r#"{"op":"source""#,
|
||||
r#"{"op":"input""#,
|
||||
r#"{"op":"add""#,
|
||||
r#"{"op":"feed""#,
|
||||
r#"{"op":"connect""#,
|
||||
r#"{"op":"expose""#,
|
||||
r#"{"op":"tap""#,
|
||||
r#"{"op":"gang""#,
|
||||
r#"{"op":"doc""#,
|
||||
r#"{"op":"use""#,
|
||||
] {
|
||||
assert!(out.contains(op), "op reference carries {op}: {out}");
|
||||
}
|
||||
assert!(out.contains("graph introspect --vocabulary"), "points at the node roster: {out}");
|
||||
}
|
||||
|
||||
/// #317: `graph introspect --registered` lists every registry label — row
|
||||
/// shape `<label> <id prefix> <root doc>` — and the empty-store form reads
|
||||
/// as prose, not a blank/garbled listing.
|
||||
#[test]
|
||||
fn graph_introspect_registered_lists_labels() {
|
||||
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-help-registered");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||
|
||||
// Empty store: prose, not a blank listing, exit 0.
|
||||
let out_empty = Command::new(BIN)
|
||||
.args(["graph", "introspect", "--registered"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura");
|
||||
assert!(out_empty.status.success(), "empty store still exits 0");
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&out_empty.stdout),
|
||||
"no labels registered\n",
|
||||
"the empty-store form reads as prose"
|
||||
);
|
||||
|
||||
// Register + label a described open pattern, then list it.
|
||||
let pattern = r#"[
|
||||
{"op":"doc","text":"a reusable smoothing pattern"},
|
||||
{"op":"input","role":"x"},
|
||||
{"op":"add","type":"SMA","name":"sma"},
|
||||
{"op":"feed","role":"x","into":["sma.series"]},
|
||||
{"op":"expose","from":"sma.value","as":"out"}
|
||||
]"#;
|
||||
let bp_path = dir.join("pattern.ops.json");
|
||||
std::fs::write(&bp_path, pattern).expect("write pattern fixture");
|
||||
let built = Command::new(BIN)
|
||||
.args(["graph", "build"])
|
||||
.current_dir(&dir)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.and_then(|mut child| {
|
||||
use std::io::Write;
|
||||
child.stdin.take().unwrap().write_all(pattern.as_bytes())?;
|
||||
child.wait_with_output()
|
||||
})
|
||||
.expect("build the pattern");
|
||||
assert!(built.status.success(), "the pattern builds");
|
||||
let envelope = dir.join("pattern.bp.json");
|
||||
std::fs::write(&envelope, &built.stdout).expect("write built envelope");
|
||||
|
||||
let reg = Command::new(BIN)
|
||||
.args(["graph", "register", envelope.to_str().unwrap(), "--name", "smooth"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura register");
|
||||
assert!(reg.status.success(), "register --name succeeds: {}", String::from_utf8_lossy(®.stderr));
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["graph", "introspect", "--registered"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura");
|
||||
assert!(out.status.success(), "listing succeeds");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(stdout.contains("smooth"), "lists the label: {stdout}");
|
||||
assert!(stdout.contains("a reusable smoothing pattern"), "lists the root doc: {stdout}");
|
||||
let row = stdout.lines().find(|l| l.starts_with("smooth")).expect("smooth row");
|
||||
let fields: Vec<&str> = row.split_whitespace().collect();
|
||||
assert_eq!(fields[1].len(), 12, "the id prefix is 12 hex chars: {row}");
|
||||
assert!(fields[1].chars().all(|c| c.is_ascii_hexdigit()), "the prefix is hex: {row}");
|
||||
}
|
||||
|
||||
/// #315: the node vocabulary listing carries each type's one-line meaning —
|
||||
/// no more bare names that teach nothing.
|
||||
#[test]
|
||||
fn graph_introspect_vocabulary_carries_meanings() {
|
||||
let (out, err, ok) = run(&["graph", "introspect", "--vocabulary"]);
|
||||
assert!(ok, "vocabulary listing failed: {err}");
|
||||
let sma = out
|
||||
.lines()
|
||||
.find(|l| l.split_whitespace().next() == Some("SMA"))
|
||||
.unwrap_or_else(|| panic!("no SMA line: {out}"));
|
||||
assert!(sma.contains("moving average"), "SMA line carries its meaning: {sma}");
|
||||
for l in out.lines().filter(|l| !l.trim().is_empty()) {
|
||||
assert!(l.split_whitespace().count() >= 2, "bare name without meaning: {l}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #315: `--node <T>`'s head line carries the type's meaning beside its label.
|
||||
#[test]
|
||||
fn graph_introspect_node_head_line_carries_the_meaning() {
|
||||
let (out, err, ok) = run(&["graph", "introspect", "--node", "SMA"]);
|
||||
assert!(ok, "node introspect failed: {err}");
|
||||
let head = out.lines().next().unwrap_or("");
|
||||
assert!(head.contains("moving average"), "head line carries the meaning: {out}");
|
||||
}
|
||||
|
||||
/// #332: `--folds` renders the fold-REGISTRY roster (the same roster
|
||||
/// `aura run --tap TAP=FOLD` resolves labels against), not the aura-std
|
||||
/// `FoldKind` table — lowercase, parseable labels, including the `record`
|
||||
/// entry that has no `FoldKind` counterpart. Previously this surface printed
|
||||
/// capitalized `FoldKind` ids (`Mean`) and omitted `record`, so the help
|
||||
/// text's own discovery surface described input `--tap` refused.
|
||||
#[test]
|
||||
fn graph_introspect_folds_lists_the_fold_registry_roster() {
|
||||
let (out, err, ok) = run(&["graph", "introspect", "--folds"]);
|
||||
assert!(ok, "folds listing failed: {err}");
|
||||
let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
assert_eq!(lines.len(), 8, "one line per registry entry (record + 7 folds): {out}");
|
||||
assert!(
|
||||
!out.split_whitespace().any(|w| w == "Mean"),
|
||||
"no capitalized FoldKind id leaks through: {out}"
|
||||
);
|
||||
let record = lines.iter().find(|l| l.starts_with("record ")).expect("record row");
|
||||
assert!(record.contains("series"), "record row meaning: {record}");
|
||||
let mean = lines.iter().find(|l| l.starts_with("mean ")).expect("mean row");
|
||||
assert!(mean.contains("f64") && mean.contains("mean"), "mean row rules + meaning: {mean}");
|
||||
let count = lines.iter().find(|l| l.starts_with("count ")).expect("count row");
|
||||
assert!(count.contains("i64") && count.contains("any"), "count row rules: {count}");
|
||||
}
|
||||
|
||||
/// #315: the metric roster carries each metric's one-line meaning beside its
|
||||
/// applicability tags.
|
||||
#[test]
|
||||
fn process_introspect_metrics_carries_meanings() {
|
||||
let (out, err, ok) = run(&["process", "introspect", "--metrics"]);
|
||||
assert!(ok, "metric roster failed: {err}");
|
||||
let er = out
|
||||
.lines()
|
||||
.find(|l| l.split_whitespace().next() == Some("expectancy_r"))
|
||||
.unwrap_or_else(|| panic!("no expectancy_r line: {out}"));
|
||||
assert!(er.contains("mean realized R"), "meaning text on the roster line: {er}");
|
||||
for l in out.lines().filter(|l| !l.trim().is_empty()) {
|
||||
assert!(l.contains(" — "), "roster line without meaning: {l}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #315: the persisted-tap vocabulary's meanings are readable where the taps
|
||||
/// are advertised — under the `std::presentation` block's slot listing.
|
||||
#[test]
|
||||
fn campaign_introspect_block_presentation_lists_tap_meanings() {
|
||||
let (out, err, ok) = run(&["campaign", "introspect", "--block", "std::presentation"]);
|
||||
assert!(ok, "presentation describe failed: {err}");
|
||||
assert!(out.contains("cumulative pip equity"), "equity tap meaning: {out}");
|
||||
assert!(out.contains("after the cost model"), "net_r_equity tap meaning: {out}");
|
||||
}
|
||||
|
||||
/// #323: `introspect --unwired` enumerates the optional C29 `description`
|
||||
/// slot for both document kinds — the authoring side of the gate is
|
||||
/// advertised where every other slot is.
|
||||
#[test]
|
||||
fn introspect_unwired_lists_the_description_slot() {
|
||||
let dir = std::env::temp_dir().join(format!("aura-selfdesc-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let bare = dir.join("bare.json");
|
||||
std::fs::write(&bare, "{}").unwrap();
|
||||
for family in ["process", "campaign"] {
|
||||
let (out, err, ok) = run(&[family, "introspect", "--unwired", bare.to_str().unwrap()]);
|
||||
assert!(ok, "{family} --unwired failed: {err}");
|
||||
assert!(
|
||||
out.contains("open slot: description"),
|
||||
"{family} --unwired lists the description slot: {out}"
|
||||
);
|
||||
assert!(out.contains("C29-gated"), "{family} hint names the gate: {out}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -871,13 +871,14 @@ fn campaign_introspect_unwired_lists_the_optional_risk_slot_on_bare_envelope() {
|
||||
}
|
||||
|
||||
/// #216/#234: bound (non-empty) risk AND cost lists close both optional
|
||||
/// slots — a complete document with both reports no open slots at all.
|
||||
/// slots — with the optional C29 description also present (#323), a complete
|
||||
/// document reports no open slots at all.
|
||||
#[test]
|
||||
fn campaign_introspect_unwired_omits_bound_risk_and_cost_slots() {
|
||||
let dir = temp_cwd("campaign-introspect-unwired-risk-bound");
|
||||
let with_both = CAMPAIGN_DOC.replacen(
|
||||
"\"seed\": 1,",
|
||||
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 2.0 } } ],",
|
||||
"\"seed\": 1,\n \"description\": \"a screen with bound risk and cost\",\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 2.0 } } ],",
|
||||
1,
|
||||
);
|
||||
assert_ne!(with_both, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
|
||||
@@ -995,7 +996,7 @@ fn campaign_introspect_unwired_reports_the_spec_example_slots() {
|
||||
write_doc(&dir, "draft.campaign.json", draft);
|
||||
let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "draft.campaign.json"]);
|
||||
assert_eq!(code, Some(0));
|
||||
assert!(out.contains("open slot: process.ref (required, content id of a process document)"));
|
||||
assert!(out.contains("open slot: process.ref (required, { content_id }: content id of a process document)"));
|
||||
assert!(out.contains("open slot: strategies[0].axes.slow (axis declared empty — a P1 axis is a non-empty finite set)"));
|
||||
}
|
||||
|
||||
@@ -1021,7 +1022,7 @@ fn campaign_introspect_unwired_reports_placeholder_refs() {
|
||||
"strategy `ref: null` placeholder not enumerated: {out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("open slot: process.ref (required, content id of a process document)"),
|
||||
out.contains("open slot: process.ref (required, { content_id }: content id of a process document)"),
|
||||
"process `ref: {{}}` placeholder not enumerated: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,3 +56,28 @@ fn measurement_blueprint_runs_bare_and_emits_its_tap() {
|
||||
assert_eq!(trace["tap"], "fast_tap");
|
||||
assert_eq!(trace["kinds"], serde_json::json!(["F64"]));
|
||||
}
|
||||
|
||||
/// #310: the measurement arm honours the `--tap` selector — a `last`
|
||||
/// fold lands one summary row instead of the full series.
|
||||
#[test]
|
||||
fn measurement_run_honours_the_tap_selector() {
|
||||
let cwd = temp_cwd("selector-last");
|
||||
let bp_path = cwd.join("measurement.json");
|
||||
std::fs::write(&bp_path, measurement_blueprint_json()).expect("write measurement blueprint");
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=last"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
|
||||
.expect("read fold trace");
|
||||
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
|
||||
let rows = trace["columns"][0].as_array().expect("columns[0] array");
|
||||
assert_eq!(rows.len(), 1, "a fold lands exactly one summary row");
|
||||
// last warm SMA(2) value over the synthetic stream = (1.0097 + 1.0092) / 2
|
||||
let got = rows[0].as_f64().expect("f64 row");
|
||||
let expected = (1.0097_f64 + 1.0092) / 2.0;
|
||||
assert!((got - expected).abs() < 1e-9, "last row: got {got}, expected {expected}");
|
||||
}
|
||||
|
||||
@@ -124,6 +124,21 @@ fn duplicate_tap_blueprint_json() -> String {
|
||||
serde_json::to_string(&v).expect("re-serialize duplicate-tap blueprint")
|
||||
}
|
||||
|
||||
/// The shipped `examples/r_sma.json` blueprint with two distinctly named
|
||||
/// declared taps: `fast_tap` on node 0 ("fast", SMA(2)) and `slow_tap` on
|
||||
/// node 1 ("slow") — the smallest fixture on which an explicit `--tap`
|
||||
/// plan and the record-all default can be compared file-by-file (#310).
|
||||
fn two_tap_blueprint_json() -> String {
|
||||
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
|
||||
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
|
||||
v["blueprint"]["taps"] = serde_json::json!([
|
||||
{"name": "fast_tap", "from": {"node": 0, "field": 0}},
|
||||
{"name": "slow_tap", "from": {"node": 1, "field": 0}},
|
||||
]);
|
||||
serde_json::to_string(&v).expect("re-serialize two-tap blueprint")
|
||||
}
|
||||
|
||||
/// Property: **a blueprint declaring two taps under the same name is refused
|
||||
/// on the single-run path with a named error and exit code 1, before any
|
||||
/// trace is persisted** — the CLI-owned `TapBindError::DuplicateBind` dedup
|
||||
@@ -212,3 +227,196 @@ fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
|
||||
);
|
||||
assert!(!run_dir.join("index.json").exists(), "no index — treat-as-absent crash shape");
|
||||
}
|
||||
|
||||
/// #310: `--tap fast_tap=mean` subscribes the declared tap to the `mean`
|
||||
/// fold instead of the record-all default — the trace store holds ONE
|
||||
/// summary row whose value is the mean of the full SMA(2) series, and
|
||||
/// `index.json` lists exactly the subscribed tap.
|
||||
#[test]
|
||||
fn run_tap_selector_persists_a_fold_summary_row() {
|
||||
let cwd = temp_cwd("tap-selector-mean");
|
||||
let bp_path = cwd.join("tapped_r_sma.json");
|
||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let index_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
|
||||
.expect("read index.json");
|
||||
let index: serde_json::Value = serde_json::from_str(&index_text).expect("parse index.json");
|
||||
assert_eq!(index["taps"], serde_json::json!(["fast_tap"]));
|
||||
|
||||
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
|
||||
.expect("read fold trace");
|
||||
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
|
||||
assert_eq!(trace["tap"], "fast_tap");
|
||||
let rows = trace["columns"][0].as_array().expect("columns[0] array");
|
||||
assert_eq!(rows.len(), 1, "a fold lands exactly one summary row");
|
||||
let sma: Vec<f64> = (1..R_SMA_PRICES.len())
|
||||
.map(|i| (R_SMA_PRICES[i - 1] + R_SMA_PRICES[i]) / 2.0)
|
||||
.collect();
|
||||
let expected = sma.iter().sum::<f64>() / sma.len() as f64;
|
||||
let got = rows[0].as_f64().expect("f64 row");
|
||||
assert!((got - expected).abs() < 1e-9, "mean row: got {got}, expected {expected}");
|
||||
}
|
||||
|
||||
/// #310: an all-`record` explicit plan is byte-identical to the no-flag
|
||||
/// record-all default — the selector replaces the default without
|
||||
/// changing what `record` itself writes (C1 determinism across runs).
|
||||
#[test]
|
||||
fn run_explicit_record_plan_matches_the_record_all_default() {
|
||||
let cwd_a = temp_cwd("record-default");
|
||||
let cwd_b = temp_cwd("record-explicit");
|
||||
for cwd in [&cwd_a, &cwd_b] {
|
||||
std::fs::write(cwd.join("two_tap.json"), two_tap_blueprint_json())
|
||||
.expect("write two-tap blueprint");
|
||||
}
|
||||
let run = |cwd: &std::path::Path, extra: &[&str]| {
|
||||
let mut args = vec!["run", "two_tap.json"];
|
||||
args.extend_from_slice(extra);
|
||||
let out = Command::new(BIN)
|
||||
.args(&args)
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
};
|
||||
run(&cwd_a, &[]);
|
||||
run(&cwd_b, &["--tap", "fast_tap=record", "--tap", "slow_tap=record"]);
|
||||
|
||||
for file in ["fast_tap.json", "slow_tap.json"] {
|
||||
let a = std::fs::read(cwd_a.join("runs/traces/sma_signal").join(file)).expect("default trace");
|
||||
let b = std::fs::read(cwd_b.join("runs/traces/sma_signal").join(file)).expect("explicit trace");
|
||||
assert_eq!(a, b, "{file} must be byte-identical across default and explicit record plans");
|
||||
}
|
||||
let taps_of = |cwd: &std::path::Path| -> serde_json::Value {
|
||||
let text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
|
||||
.expect("read index.json");
|
||||
serde_json::from_str::<serde_json::Value>(&text).expect("parse index.json")["taps"].clone()
|
||||
};
|
||||
assert_eq!(taps_of(&cwd_a), taps_of(&cwd_b));
|
||||
}
|
||||
|
||||
/// #310: an unknown fold label is refused through the registry's
|
||||
/// roster-enumerating refusal, before any store write.
|
||||
#[test]
|
||||
fn run_tap_selector_refuses_an_unknown_label_with_the_roster() {
|
||||
let cwd = temp_cwd("tap-selector-unknown-label");
|
||||
let bp_path = cwd.join("tapped_r_sma.json");
|
||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=medain"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(!out.status.success(), "an unknown fold label must refuse");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("medain") && stderr.contains("mean"),
|
||||
"refusal must name the label and enumerate the roster: {stderr}"
|
||||
);
|
||||
assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
|
||||
}
|
||||
|
||||
/// #310/#333: a selection naming a tap the blueprint does not declare is
|
||||
/// refused typed (UndeclaredTap), before any store write, and the refusal
|
||||
/// enumerates the blueprint's declared taps (the same way the unknown-fold
|
||||
/// refusal enumerates the fold-registry roster) — on the two-tap fixture,
|
||||
/// both `fast_tap` and `slow_tap` must be named so the author does not have
|
||||
/// to reopen the blueprint JSON.
|
||||
#[test]
|
||||
fn run_tap_selector_refuses_an_undeclared_tap() {
|
||||
let cwd = temp_cwd("tap-selector-undeclared");
|
||||
let bp_path = cwd.join("two_tap.json");
|
||||
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "nope=mean"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(!out.status.success(), "an undeclared tap must refuse");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("nope"), "refusal must name the offending tap: {stderr}");
|
||||
assert!(
|
||||
stderr.contains("fast_tap") && stderr.contains("slow_tap"),
|
||||
"refusal must enumerate the blueprint's declared taps: {stderr}"
|
||||
);
|
||||
assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
|
||||
}
|
||||
|
||||
/// #334: an explicit `--tap` plan that leaves a declared tap unbound emits
|
||||
/// the C14 benign skipped-tap note on stderr, per unbound tap, naming it —
|
||||
/// exit code stays 0 (this is a note, not a refusal). `fast_tap` (subscribed)
|
||||
/// gets no such note; `slow_tap` (left unlisted) does.
|
||||
#[test]
|
||||
fn run_tap_selector_notes_unbound_declared_taps_on_stderr() {
|
||||
let cwd = temp_cwd("tap-selector-unbound-note");
|
||||
let bp_path = cwd.join("two_tap.json");
|
||||
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("aura: note: declared tap \"slow_tap\" unbound this run"),
|
||||
"stderr must note the unbound declared tap: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("\"fast_tap\" unbound"),
|
||||
"the subscribed tap must not be noted as unbound: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #334: the record-all default (no `--tap` flag) leaves no declared tap
|
||||
/// unbound — nothing is skipped, so the note must never appear.
|
||||
#[test]
|
||||
fn run_with_no_tap_flag_emits_no_unbound_note() {
|
||||
let cwd = temp_cwd("tap-selector-default-no-note");
|
||||
let bp_path = cwd.join("two_tap.json");
|
||||
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
!stderr.contains("unbound this run"),
|
||||
"the record-all default must never note a skipped tap: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #310: a malformed or duplicate `--tap` is a usage error (exit 2)
|
||||
/// before anything runs.
|
||||
#[test]
|
||||
fn run_tap_selector_refuses_malformed_and_duplicate_pairs() {
|
||||
let cwd = temp_cwd("tap-selector-usage");
|
||||
let bp_path = cwd.join("tapped_r_sma.json");
|
||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||
for args in [
|
||||
vec!["--tap", "fast_tapmean"],
|
||||
vec!["--tap", "fast_tap=mean", "--tap", "fast_tap=last"],
|
||||
] {
|
||||
let mut full = vec!["run", bp_path.to_str().expect("utf-8 path")];
|
||||
full.extend(args);
|
||||
let out = Command::new(BIN)
|
||||
.args(&full)
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert_eq!(out.status.code(), Some(2), "usage errors exit 2");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("--tap"), "usage message names the flag: {stderr}");
|
||||
assert!(!cwd.join("runs").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -324,7 +426,10 @@ impl PrimitiveBuilder {
|
||||
/// A fallible-bind fault — the `Result` twin of `bind`'s panics, for the
|
||||
/// data-level construction surface (`GraphSession`, #157). `bind` keeps its
|
||||
/// panic contract (and its pinned messages); `try_bind` reports the same three
|
||||
/// conditions as values.
|
||||
/// conditions as values. `AlreadyGanged` is a fourth condition `try_bind`
|
||||
/// itself never raises — only `Composite::bind_path`'s gang guard (#317
|
||||
/// follow-up) does, refusing a ganged member's raw path at the use seam
|
||||
/// before it silently de-fuses the gang.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum BindOpError {
|
||||
/// No still-open param has this name.
|
||||
@@ -333,6 +438,141 @@ pub enum BindOpError {
|
||||
AmbiguousParam(String),
|
||||
/// The value's kind does not equal the param's declared kind.
|
||||
KindMismatch { param: String, expected: ScalarKind, got: ScalarKind },
|
||||
/// `param` (the full qualified path) is a member of the gang named
|
||||
/// `gang` — binding it directly would freeze that one member while the
|
||||
/// gang's public knob keeps driving its siblings. Bind the gang's own
|
||||
/// public knob instead (accepted residue: unbindable at the use seam,
|
||||
/// #317).
|
||||
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
|
||||
@@ -835,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)]
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
//! C23) and no external dependency (C16).
|
||||
|
||||
use aura_core::{
|
||||
Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar, ScalarKind,
|
||||
BindOpError, Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar,
|
||||
ScalarKind,
|
||||
};
|
||||
|
||||
use crate::harness::{BootstrapError, Edge, FlatGraph, FlatTap, Harness, SourceSpec, Target};
|
||||
@@ -240,6 +241,37 @@ impl Composite {
|
||||
pub fn doc(&self) -> Option<&str> {
|
||||
self.doc.as_deref()
|
||||
}
|
||||
/// Rename this composite's render symbol in place (#317, `Op::Use`): the
|
||||
/// interior — nodes, edges, roles, output — is untouched; only `name()`
|
||||
/// changes, so a fetched, registered subgraph renders (and path-prefixes
|
||||
/// its `param_space()`, via `collect_params`'s composite arm) under the
|
||||
/// caller-chosen instance identifier instead of its own authored name.
|
||||
pub(crate) fn renamed(mut self, name: impl Into<String>) -> Composite {
|
||||
self.name = name.into();
|
||||
self
|
||||
}
|
||||
/// Apply one path-qualified bind after `Op::Use`'s splice (#317): the
|
||||
/// same underlying mechanic every bind goes through
|
||||
/// (`PrimitiveBuilder::try_bind`), reached by walking `path` down through
|
||||
/// nested composite frames by node/composite-name prefix — the segmentation
|
||||
/// `reopen_in` (#246) also walks, but binding an open param instead of
|
||||
/// unbinding a bound one. A path matching no node at any level is
|
||||
/// `BindOpError::UnknownParam(path)` (the WHOLE qualified path — no open
|
||||
/// param lives there); a path that reaches a primitive but whose leaf
|
||||
/// param name itself is unknown/ambiguous/ill-kinded rewrites `try_bind`'s
|
||||
/// own fault to carry the full qualified path in place of the bare leaf
|
||||
/// name, so every fault out of this walk names the same thing: the path
|
||||
/// the caller wrote. A path landing on a GANGED member's own param is
|
||||
/// `BindOpError::AlreadyGanged` (review finding, #317 follow-up) — binding
|
||||
/// it directly would silently de-fuse the gang (the member frozen while
|
||||
/// the gang's public knob keeps driving its siblings); the gang's own
|
||||
/// public knob stays unbindable at the use seam (accepted residue). On
|
||||
/// `Err` `self` is dropped (consumed) — the same discard-on-ambiguity
|
||||
/// posture `reopen` uses.
|
||||
pub(crate) fn bind_path(mut self, path: &str, value: Scalar) -> Result<Composite, BindOpError> {
|
||||
bind_path_in(&mut self.nodes, &self.gangs, path, path, value)?;
|
||||
Ok(self)
|
||||
}
|
||||
/// Install declared measurement taps (the output-side twin of `input_roles`).
|
||||
/// Fluent, mirroring `with_doc`. Empty by default.
|
||||
pub fn with_taps(mut self, taps: Vec<Tap>) -> Self {
|
||||
@@ -1097,6 +1129,92 @@ fn reopen_in(items: &mut [BlueprintNode], path: &str) -> usize {
|
||||
hits
|
||||
}
|
||||
|
||||
/// Recursive mutable walk for `Composite::bind_path` (#317): mirrors
|
||||
/// `reopen_in`'s node/composite-name prefix segmentation, but BINDS an open
|
||||
/// param (`PrimitiveBuilder::try_bind`) instead of unbinding a bound one.
|
||||
/// `full_path` is the whole original path (named in every fault this
|
||||
/// produces); `rest` is the remaining suffix at this recursion depth. `gangs`
|
||||
/// is the CURRENT frame's gang table (mirrors `collect_params`'s `gangs`
|
||||
/// parameter) — before a matched primitive's leaf param reaches `try_bind`,
|
||||
/// its (node, original-pos) is checked against every gang member; a hit
|
||||
/// refuses with `BindOpError::AlreadyGanged` rather than silently freezing
|
||||
/// the member out from under its gang's public knob (review finding,
|
||||
/// #317 follow-up). Uses `Vec::remove`/`insert` (not `&mut` in place) because
|
||||
/// `try_bind` consumes its receiver — the same reason `lower_items` moves
|
||||
/// `BlueprintNode`s by value rather than mutating through a reference.
|
||||
fn bind_path_in(
|
||||
items: &mut Vec<BlueprintNode>,
|
||||
gangs: &[Gang],
|
||||
full_path: &str,
|
||||
rest: &str,
|
||||
value: Scalar,
|
||||
) -> Result<(), BindOpError> {
|
||||
// The prefix this frame has already consumed off `full_path` (composite
|
||||
// names and up) — used to qualify a gang's public name the same way
|
||||
// `collect_params` does, so the refusal below names the SAME address
|
||||
// `param_space()` would show for that gang.
|
||||
let prefix = &full_path[..full_path.len() - rest.len()];
|
||||
for i in 0..items.len() {
|
||||
let child_rest = match &items[i] {
|
||||
BlueprintNode::Primitive(b) => rest.strip_prefix(&format!("{}.", b.node_name())),
|
||||
BlueprintNode::Composite(c) => rest.strip_prefix(&format!("{}.", c.name())),
|
||||
};
|
||||
let Some(child_rest) = child_rest else { continue };
|
||||
let child_rest = child_rest.to_string();
|
||||
// Gang guard (#317 follow-up review finding): a raw path landing on a
|
||||
// GANGED member's own param must not silently freeze it while the
|
||||
// gang's public knob keeps driving its siblings — refuse by
|
||||
// identifier before ever reaching `try_bind`. The gang's own public
|
||||
// knob staying unbindable at the use seam is accepted residue.
|
||||
if let BlueprintNode::Primitive(b) = &items[i]
|
||||
&& let Some(pos) = b.params().iter().position(|p| p.name == child_rest).map(|idx| b.original_pos(idx))
|
||||
&& let Some(g) = gangs.iter().find(|g| g.members.iter().any(|m| m.node == i && m.pos == pos))
|
||||
{
|
||||
return Err(BindOpError::AlreadyGanged {
|
||||
param: full_path.to_string(),
|
||||
gang: format!("{prefix}{}", g.name),
|
||||
});
|
||||
}
|
||||
let item = items.remove(i);
|
||||
return match item {
|
||||
BlueprintNode::Primitive(b) => match b.try_bind(&child_rest, value) {
|
||||
Ok(b2) => {
|
||||
items.insert(i, BlueprintNode::Primitive(b2));
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(qualify_bind_error(e, full_path)),
|
||||
},
|
||||
BlueprintNode::Composite(mut c) => {
|
||||
let r = bind_path_in(&mut c.nodes, &c.gangs, full_path, &child_rest, value);
|
||||
items.insert(i, BlueprintNode::Composite(c));
|
||||
r
|
||||
}
|
||||
};
|
||||
}
|
||||
// no node at any level matched `rest`'s leading segment: no open param
|
||||
// lives at this path.
|
||||
Err(BindOpError::UnknownParam(full_path.to_string()))
|
||||
}
|
||||
|
||||
/// Rewrite a leaf `try_bind` fault to carry the FULL qualified path (#317)
|
||||
/// in place of the bare leaf param name `try_bind` only ever sees (it has no
|
||||
/// visibility past the one primitive it was called on).
|
||||
fn qualify_bind_error(e: BindOpError, full_path: &str) -> BindOpError {
|
||||
match e {
|
||||
BindOpError::UnknownParam(_) => BindOpError::UnknownParam(full_path.to_string()),
|
||||
BindOpError::AmbiguousParam(_) => BindOpError::AmbiguousParam(full_path.to_string()),
|
||||
BindOpError::KindMismatch { expected, got, .. } => {
|
||||
BindOpError::KindMismatch { param: full_path.to_string(), expected, got }
|
||||
}
|
||||
// `try_bind` never raises this one (it has no gang awareness) — the
|
||||
// gang guard above constructs it directly, already fully qualified.
|
||||
// Kept here only so this match stays total over `BindOpError`.
|
||||
BindOpError::AlreadyGanged { gang, .. } => {
|
||||
BindOpError::AlreadyGanged { param: full_path.to_string(), gang }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only twin of `collect_params` over the BOUND surface (#246): same
|
||||
/// recursion shape and prefix rules, but enumerating `bound_params()` (with
|
||||
/// values) instead of the open `params()`. Gangs are irrelevant here — a gang
|
||||
@@ -1703,7 +1821,7 @@ mod tests {
|
||||
Composite::new(
|
||||
"sig3",
|
||||
vec![
|
||||
LinComb::builder(2).into(),
|
||||
LinComb::configured(2).into(),
|
||||
Bias::builder().into(),
|
||||
Sma::builder().named("c").into(),
|
||||
],
|
||||
@@ -1742,7 +1860,7 @@ mod tests {
|
||||
Composite::new(
|
||||
"sig3",
|
||||
vec![
|
||||
LinComb::builder(2).into(),
|
||||
LinComb::configured(2).into(),
|
||||
Bias::builder().into(),
|
||||
Sma::builder().named("c").into(),
|
||||
],
|
||||
@@ -3115,7 +3233,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() }],
|
||||
@@ -3323,7 +3441,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![
|
||||
@@ -3383,7 +3501,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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,8 +56,8 @@ pub use blueprint::{
|
||||
GangMember, 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 +88,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() },
|
||||
@@ -58,7 +58,7 @@ fn signal_ops() -> Vec<Op> {
|
||||
#[test]
|
||||
fn replayed_construction_compiles_identically_to_graphbuilder() {
|
||||
// (a) op-script replay through the public construction surface.
|
||||
let replay_flat = replay("root", signal_ops(), &std_vocabulary)
|
||||
let replay_flat = replay("root", signal_ops(), &std_vocabulary, &|_: &str| None)
|
||||
.expect("op-script resolves")
|
||||
.compile_with_params(¶ms())
|
||||
.expect("replay compiles");
|
||||
@@ -96,14 +96,14 @@ 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.
|
||||
let err = replay("g", ops, &std_vocabulary).err().unwrap();
|
||||
let err = replay("g", ops, &std_vocabulary, &|_: &str| None).err().unwrap();
|
||||
assert_eq!(err, (2, OpError::UnknownNodeType("Nope".into())));
|
||||
}
|
||||
|
||||
@@ -120,15 +120,15 @@ 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.
|
||||
];
|
||||
let finalize_index = ops.len(); // 6 — the implicit finalize step
|
||||
let err = replay("g", ops, &std_vocabulary).err().unwrap();
|
||||
let err = replay("g", ops, &std_vocabulary, &|_: &str| None).err().unwrap();
|
||||
assert!(matches!(&err, (i, OpError::UnconnectedPort { .. }) if *i == finalize_index),
|
||||
"the holistic fault is still attributed to the finalize step, got {err:?}");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +173,95 @@ impl Registry {
|
||||
}
|
||||
}
|
||||
|
||||
/// The label sidecar path (#317) — a sibling of `blueprint_identity_index.jsonl`,
|
||||
/// the same "fixed-name sidecar, per-directory isolation" discipline (#191).
|
||||
fn blueprint_names_path(&self) -> PathBuf {
|
||||
self.path.with_file_name("blueprint_names.jsonl")
|
||||
}
|
||||
|
||||
/// Raw, ungated append: one label line, unconditionally, under the
|
||||
/// registry's write mutex (#276). No shape or content-id check —
|
||||
/// [`Registry::put_blueprint_label`] is the gated public entry; this
|
||||
/// exists so tests can hand-append a stale line, mirroring
|
||||
/// `append_identity_index`.
|
||||
fn append_blueprint_label_line(&self, name: &str, content_id: &str) -> Result<(), RegistryError> {
|
||||
let _guard = self.append_write_lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let path = self.blueprint_names_path();
|
||||
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let line = serde_json::to_string(&BlueprintNameLine {
|
||||
name: name.to_string(),
|
||||
content_id: content_id.to_string(),
|
||||
})
|
||||
.expect("two plain strings serialize");
|
||||
let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?;
|
||||
writeln!(file, "{line}")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Label a stored blueprint (#317): appends `{"name":..,"content_id":..}`
|
||||
/// to `blueprint_names.jsonl`, a sidecar beside the identity index. Gated
|
||||
/// on a deterministic label shape (nonempty after trim, no whitespace, no
|
||||
/// control characters — `RegistryError::BadLabel`, C29 discipline: shape
|
||||
/// only, never content judgement) and on `content_id` resolving via
|
||||
/// [`Registry::get_blueprint`] (`RegistryError::UnknownBlueprint`
|
||||
/// otherwise) — both checked BEFORE the write lock is taken. Re-labelling
|
||||
/// an existing name appends a new line (a repoint); resolution is
|
||||
/// latest-wins (see [`Registry::resolve_blueprint_label`]).
|
||||
pub fn put_blueprint_label(&self, name: &str, content_id: &str) -> Result<(), RegistryError> {
|
||||
gate_label_shape(name)?;
|
||||
if self.get_blueprint(content_id)?.is_none() {
|
||||
return Err(RegistryError::UnknownBlueprint(content_id.to_string()));
|
||||
}
|
||||
self.append_blueprint_label_line(name, content_id)
|
||||
}
|
||||
|
||||
/// Resolve a label to the content id of the LATEST line naming it, skipping
|
||||
/// any line whose content id no longer resolves via `get_blueprint`
|
||||
/// (self-repair spirit, #191) — so "latest" always means the latest VALID
|
||||
/// entry, even when a later append points at a since-vanished id. Missing
|
||||
/// sidecar file -> `None` (treat-as-empty, like `load_identity_index`).
|
||||
pub fn resolve_blueprint_label(&self, name: &str) -> Option<String> {
|
||||
self.latest_blueprint_labels().remove(name)
|
||||
}
|
||||
|
||||
/// Every registered label, latest-wins-deduped and name-sorted — the
|
||||
/// discovery surface's data source (`graph introspect --registered`).
|
||||
/// Dangling entries (content id no longer resolves) are skipped.
|
||||
pub fn blueprint_labels(&self) -> Vec<(String, String)> {
|
||||
self.latest_blueprint_labels().into_iter().collect()
|
||||
}
|
||||
|
||||
/// Every content id currently stored in the blueprint store — the
|
||||
/// candidate list a `use` ref's content-id-PREFIX resolution (#302
|
||||
/// semantics, #317) filters against, mirroring `list_process_ids`/
|
||||
/// `list_campaign_ids`. `blueprints_dir()` and `doc_dir("blueprints")`
|
||||
/// name the same directory, so `list_doc_ids`'s filename-stem walk
|
||||
/// applies unchanged.
|
||||
pub fn list_blueprint_ids(&self) -> Result<Vec<String>, RegistryError> {
|
||||
self.list_doc_ids("blueprints")
|
||||
}
|
||||
|
||||
/// Shared latest-wins-and-dangling-skipping read of `blueprint_names.jsonl`
|
||||
/// into a name -> content_id map (`BTreeMap` gives the name-sorted order
|
||||
/// `blueprint_labels` promises for free). A garbage line (torn write,
|
||||
/// manual edit) is skipped, like every other JSONL sidecar read in this
|
||||
/// module.
|
||||
fn latest_blueprint_labels(&self) -> std::collections::BTreeMap<String, String> {
|
||||
let Ok(text) = fs::read_to_string(self.blueprint_names_path()) else {
|
||||
return std::collections::BTreeMap::new();
|
||||
};
|
||||
let mut map = std::collections::BTreeMap::new();
|
||||
for raw in text.lines() {
|
||||
let Ok(line) = serde_json::from_str::<BlueprintNameLine>(raw) else { continue };
|
||||
if self.get_blueprint(&line.content_id).ok().flatten().is_some() {
|
||||
map.insert(line.name, line.content_id);
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// The content-addressed document-store dir for one document kind —
|
||||
/// a sibling of the runs store, like `blueprints/`.
|
||||
fn doc_dir(&self, kind: &str) -> PathBuf {
|
||||
@@ -273,10 +362,42 @@ impl Registry {
|
||||
}
|
||||
}
|
||||
|
||||
/// One label->content mapping, one JSON object per line in
|
||||
/// `blueprint_names.jsonl` (#317) — the `IdentityIndexLine` sidecar-line
|
||||
/// shape, mirrored for the label store.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct BlueprintNameLine {
|
||||
name: String,
|
||||
content_id: String,
|
||||
}
|
||||
|
||||
/// Deterministic label-shape gate for `put_blueprint_label` (#317): nonempty
|
||||
/// after trim, no whitespace, no control characters. String shape only —
|
||||
/// never content judgement (the C29 discipline `doc_gate` also follows).
|
||||
fn gate_label_shape(name: &str) -> Result<(), RegistryError> {
|
||||
if name.trim().is_empty() {
|
||||
return Err(RegistryError::BadLabel { name: name.to_string(), rule: "must be nonempty" });
|
||||
}
|
||||
if name.chars().any(char::is_whitespace) {
|
||||
return Err(RegistryError::BadLabel { name: name.to_string(), rule: "must not contain whitespace" });
|
||||
}
|
||||
if name.chars().any(|c| c.is_control()) {
|
||||
return Err(RegistryError::BadLabel {
|
||||
name: name.to_string(),
|
||||
rule: "must not contain control characters",
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// C29 walk (#316): the root and every named nested composite must carry a
|
||||
/// gate-passing doc. `doc: None` refuses exactly like `Some("")` — both are
|
||||
/// the Empty fault ("carries no doc").
|
||||
fn gate_composite_docs(c: &CompositeData) -> Result<(), RegistryError> {
|
||||
/// the Empty fault ("carries no doc"). `pub` (#317): the `use` construction
|
||||
/// seam (`aura-cli`) re-runs this SAME walk over a freshly-fetched composite
|
||||
/// before it enters a new composition (fail fast on a doc-less fetched
|
||||
/// entry) — the one walk, two call sites (register's write-path gate, use's
|
||||
/// read-path gate), never a second implementation.
|
||||
pub fn gate_composite_docs(c: &CompositeData) -> Result<(), RegistryError> {
|
||||
let fault = match c.doc.as_deref() {
|
||||
None => Some(aura_core::DocGateFault::Empty),
|
||||
Some(d) => aura_core::doc_gate(&c.name, d).err(),
|
||||
@@ -938,6 +1059,14 @@ pub enum RegistryError {
|
||||
/// the store without a gate-passing doc. Registered artifacts are never
|
||||
/// retroactively invalidated — the gate guards the write path only.
|
||||
UndescribedComposite { name: String, fault: aura_core::DocGateFault },
|
||||
/// Label sidecar (#317): a `put_blueprint_label` name failing the
|
||||
/// deterministic label-shape gate (nonempty after trim, no whitespace, no
|
||||
/// control characters). By-identifier, naming the violated rule.
|
||||
BadLabel { name: String, rule: &'static str },
|
||||
/// Label sidecar (#317): a `put_blueprint_label` content id that does not
|
||||
/// resolve via [`Registry::get_blueprint`] — the label sidecar never
|
||||
/// labels a blueprint the store does not have.
|
||||
UnknownBlueprint(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for RegistryError {
|
||||
@@ -978,6 +1107,12 @@ impl fmt::Display for RegistryError {
|
||||
its name — a registered composite describes itself (C29)"
|
||||
),
|
||||
},
|
||||
RegistryError::BadLabel { name, rule } => {
|
||||
write!(f, "blueprint label {name:?}: {rule}")
|
||||
}
|
||||
RegistryError::UnknownBlueprint(id) => {
|
||||
write!(f, "blueprint: no stored blueprint with content id \"{id}\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1172,6 +1307,134 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A described fixture blueprint, ready for `put_blueprint`'s C29 gate —
|
||||
/// `put_blueprint_label`'s content-id verification routes through
|
||||
/// `get_blueprint`, so its target must first be a gate-passing entry.
|
||||
fn described_fixture_blueprint(name: &str) -> String {
|
||||
format!(
|
||||
r#"{{"format_version":1,"blueprint":{{"name":"{name}","doc":"a described fixture blueprint","nodes":[]}}}}"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Property (#317): a label round-trips to the blueprint it names, and
|
||||
/// re-labelling the SAME name repoints it — resolution and the discovery
|
||||
/// listing both answer with the latest target, never the first.
|
||||
#[test]
|
||||
fn blueprint_label_round_trips_latest_wins() {
|
||||
let reg = Registry::open(temp_family_dir("label_round_trip"));
|
||||
let a = described_fixture_blueprint("a");
|
||||
let b = described_fixture_blueprint("b");
|
||||
reg.put_blueprint("ha", &a).expect("put a");
|
||||
reg.put_blueprint("hb", &b).expect("put b");
|
||||
|
||||
reg.put_blueprint_label("x", "ha").expect("label x -> a");
|
||||
assert_eq!(reg.resolve_blueprint_label("x"), Some("ha".to_string()));
|
||||
|
||||
reg.put_blueprint_label("x", "hb").expect("re-label x -> b");
|
||||
assert_eq!(reg.resolve_blueprint_label("x"), Some("hb".to_string()), "latest label wins");
|
||||
assert_eq!(
|
||||
reg.blueprint_labels(),
|
||||
vec![("x".to_string(), "hb".to_string())],
|
||||
"the discovery listing dedups to the latest target",
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#317): the label-shape gate is deterministic string shape
|
||||
/// only (nonempty after trim, no whitespace, no control characters) —
|
||||
/// each bad shape refuses with `BadLabel` naming a (non-empty) rule.
|
||||
#[test]
|
||||
fn blueprint_label_refuses_bad_shapes() {
|
||||
let reg = Registry::open(temp_family_dir("label_bad_shapes"));
|
||||
let bp = described_fixture_blueprint("s");
|
||||
reg.put_blueprint("h1", &bp).expect("put");
|
||||
|
||||
for bad in ["", " ", "a b", "a\tb"] {
|
||||
match reg.put_blueprint_label(bad, "h1") {
|
||||
Err(RegistryError::BadLabel { name, rule }) => {
|
||||
assert_eq!(name, bad);
|
||||
assert!(!rule.is_empty(), "BadLabel must name the violated rule for {bad:?}");
|
||||
}
|
||||
other => panic!("expected BadLabel for {bad:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Property (#317): a label may only ever point at a blueprint the store
|
||||
/// actually has — labelling an unregistered content id refuses
|
||||
/// by-identifier, never silently writing a dangling line.
|
||||
#[test]
|
||||
fn blueprint_label_refuses_unknown_content_id() {
|
||||
let reg = Registry::open(temp_family_dir("label_unknown_id"));
|
||||
match reg.put_blueprint_label("x", "never-written") {
|
||||
Err(RegistryError::UnknownBlueprint(id)) => assert_eq!(id, "never-written"),
|
||||
other => panic!("expected UnknownBlueprint, got {other:?}"),
|
||||
}
|
||||
assert_eq!(reg.resolve_blueprint_label("x"), None, "the refused label must not have written");
|
||||
}
|
||||
|
||||
/// Property (#317, #191 spirit): a dangling LATEST line (content id no
|
||||
/// longer resolves) is skipped by resolution, which falls back to the
|
||||
/// earlier still-valid line for the same name — latest-wins means
|
||||
/// latest-VALID-wins.
|
||||
#[test]
|
||||
fn blueprint_label_resolution_skips_a_dangling_entry() {
|
||||
let reg = Registry::open(temp_family_dir("label_dangling"));
|
||||
let bp = described_fixture_blueprint("s");
|
||||
reg.put_blueprint("h1", &bp).expect("put");
|
||||
reg.put_blueprint_label("x", "h1").expect("label x -> h1");
|
||||
|
||||
// hand-append a dangling line after the valid one, mirroring
|
||||
// `append_identity_index`'s stale-line test shape: the content id it
|
||||
// names was never registered.
|
||||
reg.append_blueprint_label_line("x", "never-written").expect("append stale line");
|
||||
|
||||
assert_eq!(
|
||||
reg.resolve_blueprint_label("x"),
|
||||
Some("h1".to_string()),
|
||||
"the dangling latest line is skipped; the earlier valid line for \"x\" wins",
|
||||
);
|
||||
assert_eq!(reg.blueprint_labels(), vec![("x".to_string(), "h1".to_string())]);
|
||||
}
|
||||
|
||||
/// Property (#317, #276 discipline): N threads appending labels through
|
||||
/// one shared `&Registry` must leave `blueprint_names.jsonl` well-formed —
|
||||
/// every successful append surviving as its own intact, resolvable line,
|
||||
/// none torn or merged (mirrors `concurrent_appends_keep_the_family_
|
||||
/// store_well_formed`).
|
||||
#[test]
|
||||
fn concurrent_label_appends_keep_the_sidecar_well_formed() {
|
||||
let reg = Registry::open(temp_family_dir("concurrent_label"));
|
||||
let bp = described_fixture_blueprint("s");
|
||||
reg.put_blueprint("h1", &bp).expect("put");
|
||||
let n_threads = 8usize;
|
||||
let iters = 50usize;
|
||||
|
||||
let total_ok: usize = std::thread::scope(|scope| {
|
||||
let handles: Vec<_> = (0..n_threads)
|
||||
.map(|tid| {
|
||||
let reg = ®
|
||||
scope.spawn(move || {
|
||||
let mut ok = 0usize;
|
||||
for iter in 0..iters {
|
||||
let name = format!("t{tid}-l{iter}");
|
||||
if reg.put_blueprint_label(&name, "h1").is_ok() {
|
||||
ok += 1;
|
||||
}
|
||||
}
|
||||
ok
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles.into_iter().map(|h| h.join().expect("worker thread joined")).sum()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
reg.blueprint_labels().len(),
|
||||
total_ok,
|
||||
"every successful label append must survive as its own intact, resolvable entry",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_stores_round_trip_by_content_id() {
|
||||
let reg = Registry::open(temp_family_dir("document_store_round_trip"));
|
||||
@@ -1891,14 +2154,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()],
|
||||
|
||||
@@ -1102,7 +1102,7 @@ pub fn slot_kind_label(kind: SlotKind) -> &'static str {
|
||||
SlotKind::Strings => "list of strings",
|
||||
SlotKind::Windows => "list of { from_ms, to_ms }",
|
||||
SlotKind::Ref => "one of { content_id } | { identity_id }",
|
||||
SlotKind::ContentRef => "content id of a process document",
|
||||
SlotKind::ContentRef => "{ content_id }: content id of a process document",
|
||||
SlotKind::Axes => "map: param name -> { kind, values }",
|
||||
SlotKind::EmitKinds => "list of: family_table | selection_report",
|
||||
SlotKind::TapKinds => "list of: equity | exposure | r_equity | net_r_equity",
|
||||
@@ -1301,8 +1301,10 @@ pub fn open_slots_campaign(text: &str) -> Result<Vec<OpenSlot>, DocError> {
|
||||
}
|
||||
if ref_slot_is_open(v.get("process").and_then(|p| p.get("ref"))) {
|
||||
// deliberately narrower than the strategy-ref hint: validate_campaign
|
||||
// refuses an identity_id process ref, so the guide must not offer it
|
||||
slots.push(open("process.ref", "required, content id of a process document"));
|
||||
// refuses an identity_id process ref, so the guide must not offer it.
|
||||
// The tagged { content_id } shape is stated explicitly (#329) so the
|
||||
// hint cannot be misread as accepting a bare id string.
|
||||
slots.push(open("process.ref", "required, { content_id }: content id of a process document"));
|
||||
}
|
||||
if v.get("seed").and_then(|s| s.as_u64()).is_none() {
|
||||
slots.push(open("seed", "required, non-negative integer"));
|
||||
@@ -1326,6 +1328,14 @@ fn envelope_open_slots(v: &serde_json::Value, kind: &str, slots: &mut Vec<OpenSl
|
||||
if v.get("kind").and_then(|k| k.as_str()) != Some(kind) {
|
||||
slots.push(open("kind", format!("required, must be \"{kind}\"")));
|
||||
}
|
||||
// C29 (#323): the description slot is enumerable like every other
|
||||
// envelope slot — optional, so only listed while absent.
|
||||
if v.get("description").is_none() {
|
||||
slots.push(open(
|
||||
"description",
|
||||
"optional, string — a one-line meaning (C29-gated when present)",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1470,6 +1480,7 @@ mod tests {
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "ger40-momentum-screen",
|
||||
"description": "GER40 momentum screen over the fast/slow SMA grid.",
|
||||
"data": {
|
||||
"instruments": ["GER40"],
|
||||
"windows": [ { "from_ms": 1704067200000, "to_ms": 1719792000000 } ]
|
||||
@@ -2365,6 +2376,14 @@ mod tests {
|
||||
label.contains("content"),
|
||||
"process_ref describe must still name the content-id form: {label}"
|
||||
);
|
||||
// #329: the label must state the TAGGED shape ({ content_id: ... }) the
|
||||
// parser actually requires, not read like a bare id string — mirroring
|
||||
// the `{ content_id } | { identity_id }` bracket notation std::strategy
|
||||
// already uses, narrowed to the one key process.ref accepts.
|
||||
assert!(
|
||||
label.contains("{ content_id }"),
|
||||
"process_ref describe must state the tagged {{ content_id }} shape, not a bare id: {label}"
|
||||
);
|
||||
|
||||
let strategy = describe_block("std::strategy").expect("strategy describable");
|
||||
let strat_ref =
|
||||
@@ -2421,7 +2440,7 @@ mod tests {
|
||||
"presentation": { "persist_taps": [], "emit": [] } }"#;
|
||||
let slots = open_slots_campaign(draft).unwrap();
|
||||
assert!(slots.iter().any(|s| s.path == "process.ref"
|
||||
&& s.hint == "required, content id of a process document"));
|
||||
&& s.hint == "required, { content_id }: content id of a process document"));
|
||||
assert!(slots.iter().any(|s| s.path == "strategies[0].axes.slow"
|
||||
&& s.hint == "axis declared empty — a P1 axis is a non-empty finite set"));
|
||||
|
||||
@@ -2457,7 +2476,7 @@ mod tests {
|
||||
// ...and the `{}` process ref reports the narrower content-id-only hint.
|
||||
assert!(
|
||||
slots.iter().any(|s| s.path == "process.ref"
|
||||
&& s.hint == "required, content id of a process document"),
|
||||
&& s.hint == "required, { content_id }: content id of a process document"),
|
||||
"process `ref: {{}}` placeholder not reported as an open slot: {slots:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -327,7 +327,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 +411,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))
|
||||
@@ -1728,9 +1728,14 @@ mod tests {
|
||||
Ok(_) => panic!("unknown tap must be refused"),
|
||||
};
|
||||
assert!(
|
||||
matches!(err, TapPlanError::UnknownTap { ref name } if name == "no_such_tap"),
|
||||
matches!(err, TapPlanError::UnknownTap { ref name, .. } if name == "no_such_tap"),
|
||||
"{err:?}"
|
||||
);
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("fast_tap"),
|
||||
"unknown-tap refusal enumerates the declared taps (fixture declares only fast_tap): {msg}"
|
||||
);
|
||||
|
||||
// Unknown label — the refusal enumerates the roster.
|
||||
let mut flat2 = tapped_r_sma().compile_with_params(&[]).expect("tapped r_sma compiles");
|
||||
|
||||
@@ -161,17 +161,21 @@ impl FoldRegistry {
|
||||
}),
|
||||
});
|
||||
for (label, doc, fold) in [
|
||||
("count", "number of warm rows (any kind; i64 row)", FoldKind::Count),
|
||||
("sum", "sum of the series (f64 taps; f64 row)", FoldKind::Sum),
|
||||
("mean", "arithmetic mean of the series (f64 taps; f64 row)", FoldKind::Mean),
|
||||
("min", "minimum of the series (f64 taps; f64 row)", FoldKind::Min),
|
||||
("max", "maximum of the series (f64 taps; f64 row)", FoldKind::Max),
|
||||
("count", "number of warm rows (any kind; i64 row); one row at the last warm ts", FoldKind::Count),
|
||||
("sum", "sum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Sum),
|
||||
(
|
||||
"mean",
|
||||
"arithmetic mean of the series (f64 taps; f64 row); one row at the last warm ts",
|
||||
FoldKind::Mean,
|
||||
),
|
||||
("min", "minimum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Min),
|
||||
("max", "maximum of the series (f64 taps; f64 row); one row at the last warm ts", FoldKind::Max),
|
||||
(
|
||||
"first",
|
||||
"first warm value, at its own timestamp (any kind; kind-preserving row)",
|
||||
FoldKind::First,
|
||||
),
|
||||
("last", "last warm value (any kind; kind-preserving row)", FoldKind::Last),
|
||||
("last", "last warm value, at its own timestamp (any kind; kind-preserving row)", FoldKind::Last),
|
||||
] {
|
||||
r.register(FoldEntry {
|
||||
label,
|
||||
@@ -217,7 +221,7 @@ impl FoldRegistry {
|
||||
/// `aura: ` + exit-1 refusal register via `Display`.
|
||||
pub enum TapPlanError {
|
||||
/// The plan names a tap the blueprint does not declare.
|
||||
UnknownTap { name: String },
|
||||
UnknownTap { name: String, declared: Vec<String> },
|
||||
/// The plan names a label the registry does not carry.
|
||||
UnknownLabel { label: String, roster: Vec<&'static str> },
|
||||
/// The entry's bind rule rejects the tap's column kind.
|
||||
@@ -237,8 +241,12 @@ pub enum TapPlanError {
|
||||
impl fmt::Display for TapPlanError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
TapPlanError::UnknownTap { name } => {
|
||||
write!(f, "the tap plan names '{name}', but the blueprint declares no such tap")
|
||||
TapPlanError::UnknownTap { name, declared } => {
|
||||
write!(
|
||||
f,
|
||||
"the tap plan names '{name}', but the blueprint declares no such tap — declared taps: {}",
|
||||
declared.join(", ")
|
||||
)
|
||||
}
|
||||
TapPlanError::UnknownLabel { label, roster } => {
|
||||
write!(f, "unknown fold '{label}' — available: {}", roster.join(", "))
|
||||
@@ -402,7 +410,10 @@ pub fn bind_tap_plan(
|
||||
// Unknown-tap guard: every plan name must be declared.
|
||||
for name in plan.by_name.keys() {
|
||||
if !declared_taps.iter().any(|t| &t.name == name) {
|
||||
return Err(TapPlanError::UnknownTap { name: name.clone() });
|
||||
return Err(TapPlanError::UnknownTap {
|
||||
name: name.clone(),
|
||||
declared: declared_taps.iter().map(|t| t.name.clone()).collect(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,7 +433,18 @@ pub fn bind_tap_plan(
|
||||
Some((label, params)) => {
|
||||
Resolved::Named { label: label.clone(), params: params.clone() }
|
||||
}
|
||||
None => continue, // unbound, inert (C27)
|
||||
// Unbound, inert (C27) — but only reachable when `plan` carries
|
||||
// no default (an EXPLICIT plan, e.g. from `--tap`): record-all
|
||||
// (`default_named` = `Some(("record", …))`) always resolves the
|
||||
// Some arm above, so this arm never fires under record-all and
|
||||
// the note is exactly the C14 benign skipped-tap class (#334).
|
||||
// Emitted here (aura-runner), beside the pre-existing
|
||||
// runner-side `eprintln!` registers in this module/`member.rs`/
|
||||
// `measure.rs` — the runner→CLI print migration is #297.
|
||||
None => {
|
||||
eprintln!("aura: note: declared tap \"{}\" unbound this run", tap.name);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
if let Resolved::Named { label, params } = &sub {
|
||||
@@ -504,6 +526,64 @@ mod tests {
|
||||
assert!(r.roster().iter().all(|(_, doc)| !doc.is_empty()), "every entry documents itself");
|
||||
}
|
||||
|
||||
/// C29 entry seam for the registry roster: every entry ships a gate-clean
|
||||
/// one-line meaning, and the bind/output prose inside it matches the
|
||||
/// entry's executable rules — the drift-pin the retired aura-std
|
||||
/// `fold_vocabulary` table carried, moved here with the surface (#332).
|
||||
#[test]
|
||||
fn roster_docs_pass_the_doc_gate_and_match_the_executable_rules() {
|
||||
let r = FoldRegistry::core();
|
||||
for entry in r.entries.values() {
|
||||
aura_core::doc_gate(entry.label, entry.doc)
|
||||
.unwrap_or_else(|f| panic!("fold {} doc fails the gate: {f:?}", entry.label));
|
||||
if entry.doc.contains("f64 taps") {
|
||||
assert!(
|
||||
(entry.binds_at)(ScalarKind::F64) && !(entry.binds_at)(ScalarKind::I64),
|
||||
"{} claims f64-only but binds wider",
|
||||
entry.label
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
entry.doc.contains("any kind"),
|
||||
"{}: bind prose must be 'f64 taps' or 'any kind': {}",
|
||||
entry.label,
|
||||
entry.doc
|
||||
);
|
||||
assert!(
|
||||
(entry.binds_at)(ScalarKind::I64) && (entry.binds_at)(ScalarKind::Bool),
|
||||
"{} claims any-kind but refuses a kind",
|
||||
entry.label
|
||||
);
|
||||
}
|
||||
if entry.doc.contains("i64 row") {
|
||||
assert!(
|
||||
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::I64)),
|
||||
"{} claims an i64 row but outputs otherwise",
|
||||
entry.label
|
||||
);
|
||||
} else if entry.doc.contains("f64 row") {
|
||||
assert!(
|
||||
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::F64)),
|
||||
"{} claims an f64 row but outputs otherwise",
|
||||
entry.label
|
||||
);
|
||||
} else if entry.doc.contains("kind-preserving row") {
|
||||
assert!(
|
||||
matches!((entry.output)(ScalarKind::F64), FoldOutput::Row(ScalarKind::F64))
|
||||
&& matches!((entry.output)(ScalarKind::I64), FoldOutput::Row(ScalarKind::I64)),
|
||||
"{} claims kind-preserving but outputs otherwise",
|
||||
entry.label
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
matches!((entry.output)(ScalarKind::F64), FoldOutput::Series),
|
||||
"{}: doc names no row kind, so it must be the series entry",
|
||||
entry.label
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_label_refusal_enumerates_the_roster() {
|
||||
let r = FoldRegistry::core();
|
||||
|
||||
@@ -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]");
|
||||
|
||||
@@ -29,6 +29,7 @@ impl CarryCost {
|
||||
"CarryCost",
|
||||
Vec::new(),
|
||||
vec![ParamSpec { name: "carry_per_cycle".into(), kind: ScalarKind::F64 }],
|
||||
"cost-model node: cost accrued per held cycle (param carry_per_cycle)",
|
||||
|p| Box::new(CarryCost::new(p[0].f64())),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ impl ConstantCost {
|
||||
"ConstantCost",
|
||||
Vec::new(),
|
||||
vec![ParamSpec { name: "cost_per_trade".into(), kind: ScalarKind::F64 }],
|
||||
"cost-model node: fixed cost per trade (param cost_per_trade), charged at close",
|
||||
|p| Box::new(ConstantCost::new(p[0].f64())),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -214,26 +214,20 @@ impl<F: CostNode> Node for CostRunner<F> {
|
||||
}
|
||||
|
||||
/// Assemble a cost-node `PrimitiveBuilder`: the 4 geometry inputs ++ the factor's
|
||||
/// extra inputs, the standard 3-field cost output, the given params, and a build
|
||||
/// closure. The single home for the cost-node schema shape.
|
||||
/// extra inputs, the standard 3-field cost output, the given params, a
|
||||
/// factor-specific one-line `doc` (C29 — each cost node names its own charge
|
||||
/// basis rather than sharing one generic sentence, #330), and a build closure.
|
||||
/// The single home for the cost-node schema shape.
|
||||
pub fn cost_node_builder(
|
||||
name: &'static str,
|
||||
extra_inputs: Vec<PortSpec>,
|
||||
params: Vec<ParamSpec>,
|
||||
doc: &'static str,
|
||||
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
|
||||
) -> PrimitiveBuilder {
|
||||
let mut inputs = geometry_input_ports();
|
||||
inputs.extend(extra_inputs);
|
||||
PrimitiveBuilder::new(
|
||||
name,
|
||||
NodeSchema {
|
||||
inputs,
|
||||
output: cost_output_fields(),
|
||||
params,
|
||||
doc: "cost-model node: charges its cost in R from position geometry, at close or accrued per held cycle",
|
||||
},
|
||||
build,
|
||||
)
|
||||
PrimitiveBuilder::new(name, NodeSchema { inputs, output: cost_output_fields(), params, doc }, build)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -501,6 +495,7 @@ mod tests {
|
||||
"Probe",
|
||||
extra.clone(),
|
||||
params.clone(),
|
||||
"cost-model node: probe factor for the builder-assembly test",
|
||||
|_p| -> Box<dyn Node> { Box::new(CostRunner::new(StubCost(0.0))) },
|
||||
);
|
||||
let schema = builder.schema();
|
||||
@@ -537,6 +532,21 @@ mod tests {
|
||||
assert_eq!(a, "volatility");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_node_descriptions_are_pairwise_distinct() {
|
||||
// C29: `aura graph introspect --vocabulary` must be able to tell the
|
||||
// three cost nodes apart by their charge basis (flat-per-trade vs
|
||||
// per-held-cycle-accrual vs volatility-scaled), not read one
|
||||
// identical generic sentence three times (#330).
|
||||
use crate::{CarryCost, ConstantCost, VolSlippageCost};
|
||||
let constant_doc = ConstantCost::builder().schema().doc;
|
||||
let carry_doc = CarryCost::builder().schema().doc;
|
||||
let vol_slippage_doc = VolSlippageCost::builder().schema().doc;
|
||||
assert_ne!(constant_doc, carry_doc, "ConstantCost vs CarryCost doc must differ");
|
||||
assert_ne!(constant_doc, vol_slippage_doc, "ConstantCost vs VolSlippageCost doc must differ");
|
||||
assert_ne!(carry_doc, vol_slippage_doc, "CarryCost vs VolSlippageCost doc must differ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn producer_and_aggregator_share_the_triple() {
|
||||
// The structural lockstep: producer output and aggregator output/inputs all
|
||||
@@ -545,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ impl VolSlippageCost {
|
||||
"VolSlippageCost",
|
||||
volatility_input_ports(),
|
||||
vec![ParamSpec { name: "slip_vol_mult".into(), kind: ScalarKind::F64 }],
|
||||
"cost-model node: slippage proportional to volatility (slip_vol_mult × volatility input), charged at close",
|
||||
|p| Box::new(VolSlippageCost::new(p[0].f64())),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+49
-10
@@ -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
|
||||
@@ -122,7 +128,8 @@ impl Scale {
|
||||
pub fn new(factor: f64) -> Self {
|
||||
Self { factor, out: [Cell::from_f64(0.0)] }
|
||||
}
|
||||
|
||||
// Replace `doc` below when renaming this sample node — it must keep
|
||||
// describing the node's own behaviour, not this scaffolding step.
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::new(
|
||||
"my_lab::Scale",
|
||||
@@ -220,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 nine ops
|
||||
### The ten ops
|
||||
|
||||
| op | JSON shape | does |
|
||||
|---|---|---|
|
||||
| `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) — for a fragment meant to be wired by an *enclosing* graph. A standalone document built with `aura graph build` finalizes as a closed root, so an `input` role that is never bound refuses at the end: `finalize: root input role <name> is unbound`. |
|
||||
| `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. |
|
||||
| `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>?,"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). |
|
||||
| `expose` | `{"op":"expose","from":<port>,"as":<str>}` | promote an interior output field to a boundary output under the alias `as` — a real *alias* (a terminal boundary name, not a referenceable identifier like `add`'s `name`). Together with `tap`, one of the two ops whose `as` key is a terminal name rather than an identifier. |
|
||||
@@ -340,6 +348,30 @@ 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
|
||||
|
||||
```
|
||||
@@ -404,6 +436,13 @@ registered blueprint 597d719b7ac607158cda3e68cd497387620397a5e93087e23da512876da
|
||||
The printed content id is the address a campaign document's `strategies[].ref`
|
||||
points at (§3).
|
||||
|
||||
`graph register <file> --name <label>` additionally records a **blueprint
|
||||
label** — a legible, re-pointable handle for the `use` op's `{"name":…}`
|
||||
ref form (re-registering under the same label repoints it; the echo names
|
||||
the previous target). `aura graph introspect --registered` lists the
|
||||
labels with their id prefix and root doc line — the discovery surface for
|
||||
what `use` can reference.
|
||||
|
||||
## 2. Process documents — a validation methodology (role 5)
|
||||
|
||||
A process document is a named, versionable pipeline of **std stage blocks**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -144,17 +146,40 @@ per-op checks — name resolution, edge kind-match (`edge_kind_check`), the doub
|
||||
arm, param bind, and acyclicity (a `connect` that would close a cycle is rejected
|
||||
eagerly at the closing op, `GraphSession::closes_cycle`, #161) — and **holistic**
|
||||
finalize checks — wiring totality (`validate_wiring`), param-namespace injectivity
|
||||
(`check_param_namespace_injective`), root-role boundness (`check_root_roles_bound`).
|
||||
Both cadences call the **same extracted predicates** — no second validator. The
|
||||
holistic faults read **by-identifier** (#162): `finish()` translates the
|
||||
index-carrying `CompileError`s (`UnconnectedPort` / `RoleKindMismatch` /
|
||||
`UnboundRootRole`) into by-identifier `OpError` variants while still *calling* the
|
||||
unchanged holistic gates. Build-free introspection answers over the closed vocabulary:
|
||||
`graph introspect --vocabulary | --node <T> | --unwired`. The engine `Op` stays
|
||||
serde-free; the wire DTO (`OpDoc`) is CLI-side (`aura-cli::graph_construct`).
|
||||
Acceptance: a graph built purely through the ops compiles identical (C1) to its
|
||||
Rust-built twin, an invalid op is rejected at the op naming the cause, introspection
|
||||
answers without a build.
|
||||
(`check_param_namespace_injective`). Both cadences call the **same extracted
|
||||
predicates** — no second validator. The holistic faults read **by-identifier**
|
||||
(#162): `finish()` translates the index-carrying `CompileError`s (`UnconnectedPort` /
|
||||
`RoleKindMismatch`) into by-identifier `OpError` variants while still *calling* the
|
||||
unchanged holistic gates. **Root-role boundness moved off this list (#317, open
|
||||
patterns):** `check_root_roles_bound` no longer runs in `finish()` — a `finish()`ed
|
||||
op-script may still carry an open (unbound) root role, declaring a reusable
|
||||
pattern's formal parameter; only `compile`/bootstrap (the runnability gate, not a
|
||||
construction gate) still call it, unchanged. Build-free introspection answers over
|
||||
the closed vocabulary: `graph introspect --vocabulary | --node <T> | --unwired`. The
|
||||
engine `Op` stays serde-free; the wire DTO (`OpDoc`) is CLI-side
|
||||
(`aura-cli::graph_construct`). Acceptance: a graph built purely through the ops
|
||||
compiles identical (C1) to its Rust-built twin, an invalid op is rejected at the op
|
||||
naming the cause, introspection answers without a build.
|
||||
|
||||
**Registered-blueprint splice (#317).** The `use` op resolves a reference — a store
|
||||
content id, a unique content-id prefix (#302 semantics), or a registry **name
|
||||
label** (`graph register --name`, latest-wins, `aura-registry`'s
|
||||
`blueprint_names.jsonl` sidecar) — to a previously registered blueprint and
|
||||
splices it into the building graph as a nested `BlueprintNode::Composite` under an
|
||||
instance identifier. The **engine never resolves**: `GraphSession`/`replay` take a
|
||||
second injected closure, `subgraph: &dyn Fn(&str) -> Option<Composite>`, mirroring
|
||||
`vocab`'s closed-lookup posture exactly — this is the same enforcement-shift
|
||||
permission §"Enforcement shift" above already grants the node vocabulary, extended
|
||||
to registered-blueprint references. All store I/O — label/prefix resolution, the
|
||||
`get_blueprint` fetch, the C29 doc-gate re-walk over the fetched composite (before
|
||||
it ever reaches the session, so a doc-less fetched entry cannot enter a NEW
|
||||
composition — a **backstop**: the register verb already gates both input forms, so
|
||||
this fires only for store content written before C29 or through the raw in-crate
|
||||
path), and the resolution echo — happens CLI-side, at DTO conversion, before
|
||||
replay; build-free introspection paths pass `&|_| None`. The echo
|
||||
(`aura: note: use "<instance>": <label-or-prefix> -> <full id>`) is the existing
|
||||
`aura: note:` benign-diagnostic marker (C14) — a new instance of the existing
|
||||
class, not a new one, so the exit-code/marker taxonomy is unchanged.
|
||||
|
||||
**Invariant-5 lockstep.** The eager acyclicity gate is a *second* home of invariant 5
|
||||
alongside the bootstrap Kahn sort (`harness.rs`, `BootstrapError::Cycle`); the two
|
||||
@@ -166,16 +191,25 @@ 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 eight verbs:
|
||||
`<identifier>.<port>`. The ten verbs:
|
||||
|
||||
- `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).
|
||||
- `add` — `{"op":"add","type":<TypeId>,"name":<str>?,"bind":{<param>:<Scalar>}?}` —
|
||||
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>?,"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
|
||||
@@ -196,6 +230,19 @@ replayed in order; nodes are referenced **by identifier**, ports as dotted
|
||||
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).
|
||||
- `doc` — `{"op":"doc","text":<str>}` — declare the composite's one-line meaning
|
||||
(C29, #316) — the op-script twin of the builder's `.doc(...)`; at most one per
|
||||
script, a second is a fault (a declarative script carries no last-wins
|
||||
ambiguity).
|
||||
- `use` — `{"op":"use","ref":{"content_id":<str>}|{"name":<str>},"name":<str>?,"bind":{<path>:<Scalar>}?}`
|
||||
— splice a **registered** blueprint into the building graph as a nested
|
||||
composite under an instance identifier (`name`, defaulting to the fetched
|
||||
composite's own name); `bind` path-qualifies the spliced instance's params
|
||||
(e.g. `"sma.length"`), applied after the splice (#317). An instance's open
|
||||
input roles become its `<instance>.<role>` in-ports, its output boundary
|
||||
aliases its `<instance>.<field>` out-fields — the same `Composite` arm every
|
||||
other add/wiring path walks, not a new mechanism. See §"The construction
|
||||
service" below for the resolution split.
|
||||
|
||||
Value forms are the serialized representations: a `Scalar` bind value is the typed tag
|
||||
form `{"I64":2}` / `{"F64":0.5}` / `{"Bool":true}` / `{"Timestamp":<i64>}`, and `kind`
|
||||
@@ -224,12 +271,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
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# C27 — Declared taps: named measurement points bind sinks run-mode-aware: history
|
||||
|
||||
> FROZEN HISTORICAL RECORD. Each block below was true as of its cycle/date stamp
|
||||
> and may be superseded; this file is NOT current truth and NOT a grounding
|
||||
> surface. Current contract: [c27-declared-taps.md](c27-declared-taps.md).
|
||||
|
||||
**Current-state tap-plan sentence (2026-07-21, #283; superseded 2026-07-24 by
|
||||
the #310 `--tap` selector):** "…and the shared `bind_tap_plan`/`BoundTaps`
|
||||
pair called by both declared-tap entry points, `run_signal_r`
|
||||
(`aura-runner::member`) and `run_measurement` (`aura-runner::measure`); both
|
||||
CLI verbs pass a record-all plan."
|
||||
@@ -68,12 +68,28 @@ and the roster-enumerating refusal — plus a scalar-typed param schema; all cor
|
||||
entries are param-less today, the seam ships in every entry's build signature),
|
||||
and the shared `bind_tap_plan`/`BoundTaps` pair called by both declared-tap entry
|
||||
points, `run_signal_r` (`aura-runner::member`) and `run_measurement`
|
||||
(`aura-runner::measure`); both CLI verbs pass a record-all plan. The `record`
|
||||
(`aura-runner::measure`) — both arms of the single CLI verb `aura run`, whose
|
||||
repeatable `--tap TAP=FOLD` selector (#310) makes the `Named` selection
|
||||
data-reachable: no flag keeps the record-all default, any flag replaces the
|
||||
plan entirely (unlisted taps stay unbound/inert). The boundary is thereby
|
||||
fixed in place: *selecting* a subscription is run-mode authority, exercised
|
||||
by the run-mode owner — on the one-shot path the CLI invocation itself, a
|
||||
projection exercising this contract's authority, not a second home for
|
||||
intent under [C25](c25-role-model.md) — while *adding* a fold stays a Rust
|
||||
entry (role 2). The campaign/document carrier, and the reconciliation of the
|
||||
presentation-tap namespace (`persist_taps`) with declared taps it requires,
|
||||
is deferred to the Measurement-reachable milestone (#312/#327, minuted on
|
||||
#312). The `record`
|
||||
consumer (`aura-runner::tap_recorder::TapRecorder`) holds the trace store's
|
||||
streaming writer in-graph — `initialize` opens (deferred acquisition), `eval`
|
||||
appends `(Timestamp, Cell)` (zero per-cycle heap, #77), `finalize` reports
|
||||
exactly one terminal outcome; fold consumers (`aura-std::TapFold`) land one
|
||||
summary row; live closures run inline (`aura-std::TapLive`). The sweep/reduce
|
||||
summary row, emitted at finalize and stamped with the instant of the last
|
||||
contributing (warm) value — `first` alone pins the first contributing
|
||||
instant; `min`/`max` deliberately do not carry the extremum's timestamp (a
|
||||
whole-window row privileges no interior instant) — ratified as-is, #335;
|
||||
live closures run inline
|
||||
(`aura-std::TapLive`). The sweep/reduce
|
||||
path never calls `bind_tap`.
|
||||
|
||||
The chain-pruning benefit — a sweep paying zero for the study wires behind an
|
||||
|
||||
@@ -27,13 +27,23 @@ cannot pass its short-name alibi
|
||||
restatement.
|
||||
3. **Register seam (data plane).** The registry's public `put_blueprint`
|
||||
parses the canonical bytes and requires a gate-passing doc on the root
|
||||
composite and on every named nested composite — the gate guards the
|
||||
store boundary itself, so the register verb, every register-then-run
|
||||
composite and on **every** nested composite (the walk recurses
|
||||
unconditionally — it does not stop at an unnamed one) — the gate guards
|
||||
the store boundary itself, so the register verb, every register-then-run
|
||||
sugar path, and any future caller are gated by construction. The
|
||||
op-script vocabulary carries the `doc` op as the builder `.doc(...)`'s
|
||||
twin ([C25](c25-role-model.md): a closed, typed metadata construct).
|
||||
Process/campaign documents take an additive-optional top-level
|
||||
`description`, gated only when present (`DocFault::BadDescription`).
|
||||
4. **Use seam (data plane, construction-time, #317).** The `use`
|
||||
construction op re-runs the SAME `gate_composite_docs` walk over a
|
||||
blueprint FETCHED from the store, before it ever splices into a new
|
||||
composition — a build-time gate on already-registered content, not a
|
||||
second write-path check. A doc-less entry (reachable only through the
|
||||
raw in-crate write survivor class, since `put_blueprint`'s own gate keeps
|
||||
the store clean going forward) cannot enter a NEW composition this way,
|
||||
but stays readable and runnable on its own — no retroactive invalidation,
|
||||
the same discipline the Forbids clause states for the register seam.
|
||||
|
||||
**Scope at this record's writing (#321).** The gate walks five
|
||||
vocabularies — process/campaign blocks, metrics, tap slots, tap folds, and
|
||||
@@ -67,6 +77,8 @@ for whom the binary is the only always-present teacher. Field evidence
|
||||
execution semantics and document schemas by CAS forensics — the removed
|
||||
failure class is exactly that forensic recovery.
|
||||
|
||||
Rendering of the carried texts on the help/introspection surfaces is #315;
|
||||
the generated agent bootstrap card is #267; a fold introspection surface
|
||||
is blocked on #310.
|
||||
Rendering of the carried texts on the help/introspection surfaces shipped
|
||||
with #315; the agent bootstrap card was retired as superseded (#267 — the
|
||||
C29 surfaces themselves carry it); the fold introspection surface shipped
|
||||
with #310 and renders the fold-registry roster — labels exactly as the
|
||||
`--tap` selector accepts them, doc lines from the registry entries (#332).
|
||||
|
||||
+18
-2
@@ -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.
|
||||
@@ -31,6 +35,10 @@ A strategy's primary, backtestable DAG output: one signed, bounded `f64 ∈ [-1,
|
||||
**Avoid:** —
|
||||
The param-generic, input-role-generic graph-as-data produced by running a Rust builder; it carries free numeric params and free input roles before bootstrap. Bootstrapped into a frozen instance by binding params + data + seed. Registered and inspected headless (`aura graph register`, `aura graph introspect --params` — the raw `param_space` namespace campaign axes bind against; cycle 0107/#196); a *bound* param is an overridable **default** — a sweep axis naming it re-opens it per family (#246), while `run` uses it as-is.
|
||||
|
||||
### 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.
|
||||
|
||||
### bootstrap
|
||||
**Avoid:** —
|
||||
The distinct, recursive construction phase that binds `(blueprint + param-set + data bindings + seed)` into a frozen instance — buffers sized, topology fixed. The explicit name for the "wiring / graph build" that C7/C12 reference — the construction/compilation sense. Disambiguation: the *statistical* moving-block bootstrap of a trade-R series (`r_bootstrap`, `RBootstrap`) is a different thing that keeps its statistics name — it appears as the deflation null, the `aura mc` R path, and since 0108 the `std::monte_carlo` stage's `stage bootstrap` annotation; context (construction vs annotation) disambiguates.
|
||||
@@ -63,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`.
|
||||
@@ -259,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
|
||||
@@ -327,12 +339,16 @@ A named recorded stream produced by a recording `sink` — the addressable label
|
||||
|
||||
Since C27 (#282) the word also names a second, author-facing sense: a **declared tap** — a `Composite.taps` entry `Tap { name, from: {node, field} }` a hand-authored blueprint declares on an interior output wire, the output-side twin of an `input_role`. It is a pure declaration (no channel endpoint); the harness binds it run-mode-aware (a single `aura run` constructs a recorder at each and persists the series through the trace store; a sweep leaves it unbound and inert). This is an OPEN, per-blueprint author-declared name — distinct from the CLOSED campaign `persist_taps` vocabulary above, which selects among fixed observables of the standard R-harness. Both senses land as `ColumnarTrace`s in the same trace store; the closed vocabulary is what a `campaign document` selects, the declared tap is what a blueprint author names.
|
||||
|
||||
Since #283 what CONSUMES a declared tap is itself declared per run by a **tap plan**: a subscription is either `Named { label, params }` — resolved against a layered **fold registry** whose core vocabulary is `record | count | sum | mean | min | max | first | last`, each entry carrying a doc line (the help surface and the roster-enumerating refusal) and a scalar-typed param schema (all core entries param-less; growth is a new Rust entry per C25, and higher layers register entries without touching the core) — or `Live(closure)`, the single non-data variant (an in-process consumer with consumer-owned loss policy). `record` streams the full series to the trace store at constant memory (no buffer-then-drain); folds keep an O(1) accumulator and land one summary row at finalize. Both CLI verbs (`aura run`, `aura measure`) pass a record-all plan, so their semantics are unchanged.
|
||||
Since #283 what CONSUMES a declared tap is itself declared per run by a **tap plan**: a subscription is either `Named { label, params }` — resolved against a layered **fold registry** whose core vocabulary is `record | count | sum | mean | min | max | first | last`, each entry carrying a doc line (the help surface and the roster-enumerating refusal) and a scalar-typed param schema (all core entries param-less; growth is a new Rust entry per C25, and higher layers register entries without touching the core) — or `Live(closure)`, the single non-data variant (an in-process consumer with consumer-owned loss policy). `record` streams the full series to the trace store at constant memory (no buffer-then-drain); folds keep an O(1) accumulator and land one summary row at finalize. Both declared-tap entry points are arms of the single verb `aura run` (`aura measure` is the post-hoc IC analysis over already-persisted traces and constructs no tap plan); `aura run` subscribes every declared tap to `record` by default, and its repeatable `--tap TAP=FOLD` selector (#310) replaces that default with an explicit plan — only listed taps are bound, unlisted taps stay unbound/inert. A fold's one summary row is emitted at finalize and stamped with the instant of the last contributing (warm) value — `first` alone pins the first contributing instant, and `min`/`max` deliberately do not carry the extremum's instant (ratified #335).
|
||||
|
||||
### topology hash
|
||||
**Avoid:** —
|
||||
The `content id` of a run's signal blueprint in its run-record role: stamped into the manifest as `topology_hash`, keying the reproduction store and anchoring `aura reproduce` — the same SHA-256 over the same canonical bytes. Distinct from the debug-name-blind `identity id`.
|
||||
|
||||
### 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.
|
||||
|
||||
### veto
|
||||
**Avoid:** risk-manager
|
||||
The **optional** documented pre-trade-gate seam in the execution chain (`stop-rule → [Veto] → position-management`): position / exposure / notional caps that may reject or scale a trade. A pass-through identity DCE'd away when absent (C19/C23); kept as a named seam — it is what LEAN calls "Risk Management" and nautilus the RiskEngine.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# Fieldtest transcript — cycle #317 (composites, the `use` op)
|
||||
|
||||
Verbatim command/output capture for the `fieldtests/composites-use/` fixtures.
|
||||
Binary: `target/release/aura` built from HEAD `4a30222` (release profile).
|
||||
Project: `./lab` (scaffolded with `aura new lab`; `/runs` gitignored).
|
||||
Commands using the store were run from inside `lab/`.
|
||||
|
||||
## Example 1 — register → --name → discover → use by name → splice
|
||||
|
||||
```
|
||||
$ aura graph build < cu_1_pattern_spread.ops.json
|
||||
{... "input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}]}] ...}
|
||||
# open pattern: input role "price" carries no "source":<kind> (contrast a source role)
|
||||
|
||||
$ aura graph introspect --params cu_1_pattern_spread.ops.json
|
||||
fast.length:I64
|
||||
slow.length:I64
|
||||
|
||||
$ aura graph introspect --unwired < cu_1_pattern_spread.ops.json
|
||||
# (no output — the price input role feeds fast.series/slow.series, so no open interior slot)
|
||||
|
||||
# --- inside lab/ ---
|
||||
$ aura graph register .../cu_1_pattern_spread.ops.json --name spread_xover
|
||||
registered blueprint f61659aaa8dd1933c9e42f437f06be3ae346dd61569b5e013f07285e6fda03b1 (.../runs/blueprints/f61659....json)
|
||||
label "spread_xover" -> f61659aaa8dd1933c9e42f437f06be3ae346dd61569b5e013f07285e6fda03b1
|
||||
|
||||
$ aura graph introspect --registered
|
||||
spread_xover f61659aaa8dd open SMA-crossover spread pattern: fast minus slow over an injected price input role
|
||||
|
||||
$ aura graph build < cu_1_consumer.ops.json > payload 2> echo
|
||||
# stderr (echo): aura: note: use "xover": spread_xover -> f61659aaa8dd1933c9e42f437f06be3ae346dd61569b5e013f07285e6fda03b1
|
||||
# stdout (payload): {... "nodes":[{"composite":{"name":"xover", ... spliced inline ...}}, {"primitive":{"type":"Bias"...}}] ...}
|
||||
|
||||
$ aura graph introspect --params cu_1_consumer.ops.json
|
||||
xover.fast.length:I64
|
||||
xover.slow.length:I64
|
||||
bias.scale:F64
|
||||
```
|
||||
|
||||
## Example 2 — ref-form variety + refusal UX
|
||||
|
||||
Store contains labels `ema_smoother` (1c8edf24a1eb) and `spread_xover` (f61659aaa8dd),
|
||||
plus two unlabelled spread variants `e6982f7d...` and `e8f04011...` (both start with `e`).
|
||||
|
||||
```
|
||||
$ aura graph register .../cu_2_docless.ops.json --name docless
|
||||
aura: blueprint: composite `graph` carries no doc — a registered composite describes itself (C29); add a doc line (...) before register
|
||||
# exit 1 (register gates doc-less at register; a doc-less entry never enters the store)
|
||||
|
||||
# by full content id -> splices, echoes id
|
||||
$ aura graph build < cu_2_ref_full_id.ops.json
|
||||
aura: note: use "sp": e6982f7d...d55d82 -> e6982f7d...d55d82 (exit 0)
|
||||
|
||||
# by unique prefix "e69" -> splices, echoes resolved id
|
||||
$ aura graph build < cu_2_ref_prefix.ops.json
|
||||
aura: note: use "sp": e69 -> e6982f7d...d55d82 (exit 0)
|
||||
|
||||
# by ambiguous prefix "e" -> refuses, enumerates candidates
|
||||
$ aura graph build < cu_2_ref_ambiguous.ops.json
|
||||
aura: op 2 (use "sp"): ambiguous id "e": candidates e6982f7d...d55d82, e8f04011...07fcf8 (exit 1)
|
||||
|
||||
# by unknown prefix "deadbeef" -> refuses
|
||||
$ aura graph build < cu_2_ref_unknown_prefix.ops.json
|
||||
aura: op 2 (use "sp"): no registered blueprint matches content id "deadbeef" (exit 1)
|
||||
|
||||
# by unknown label "nope" -> refuses, enumerates labels
|
||||
$ aura graph build < cu_2_ref_unknown_label.ops.json
|
||||
aura: op 2 (use "sp"): no registered blueprint labeled "nope" — registered labels: ema_smoother, spread_xover (exit 1)
|
||||
```
|
||||
|
||||
## Example 3 — running an open pattern standalone
|
||||
|
||||
Docs (authoring guide `input` row; glossary `use (op)`) say: "only running it
|
||||
standalone refuses (bootstrap's unbound-root-role gate)."
|
||||
|
||||
```
|
||||
# (a) spread-exposing open pattern -> refuses on the run-SHAPE gate (not the role gate)
|
||||
$ aura run spread_open.bp.json
|
||||
aura: `aura run` needs either a `bias` output (a strategy) or ≥1 declared tap (a measurement); this blueprint exposes neither (exit 1)
|
||||
|
||||
# (b) bias-exposing open pattern, params OPEN -> refuses on the OPEN-PARAMS gate
|
||||
$ aura run open_bias.bp.json
|
||||
aura: run requires a closed blueprint (no free parameters); 3 free knob(s) — bind them or use `aura sweep --axis` (exit 2)
|
||||
$ aura run open_bias.bp.json --params '[{"I64":2},{"I64":4},{"F64":0.5}]'
|
||||
aura: run requires a closed blueprint (no free parameters); 3 free knob(s) — ... (exit 2) # --params did not close the knobs
|
||||
|
||||
# (c) bias-exposing open pattern, params BOUND, input role "price" OPEN -> RUNS CLEAN (docs say refuse)
|
||||
$ aura run open_bias_bound.bp.json # from cu_3_open_bias_bound.ops.json
|
||||
{"manifest":{...,"params":[],"defaults":[["graph.fast.length",{"I64":2}],...]},"metrics":{"total_pips":0.3418...}} (exit 0)
|
||||
|
||||
# (d) same, but input role named "quote" (not a column name) -> refuses on the ROLE-COLUMN gate
|
||||
$ aura run altrole.bp.json
|
||||
aura: input role "quote" of strategy "graph" binds to no archive column — bindable columns: open, high, low, close (alias: price), spread, volume; or bind it explicitly in the campaign data.bindings block (exit 1)
|
||||
```
|
||||
|
||||
## Example 4 — sweep integration + gang refusal + happy-path run
|
||||
|
||||
```
|
||||
$ aura sweep cu_1_consumer.bp.json --list-axes
|
||||
graph.xover.fast.length:I64
|
||||
graph.xover.slow.length:I64
|
||||
graph.bias.scale:F64
|
||||
|
||||
$ aura sweep cu_1_consumer.bp.json --axis graph.xover.fast.length=2,3 \
|
||||
--axis graph.xover.slow.length=6 --axis graph.bias.scale=0.5 --name cu4_sweep
|
||||
{"family_id":"cu4_sweep-0","report":{"manifest":{...,"params":[["graph.xover.fast.length",{"I64":2}],["graph.xover.slow.length",{"I64":6}],...]}}}
|
||||
{"family_id":"cu4_sweep-0","report":{"manifest":{...,"params":[["graph.xover.fast.length",{"I64":3}],...]}}}
|
||||
# exit 0, 2 members — spliced-instance params sweep through the nesting
|
||||
|
||||
# gang a spliced instance's member path -> refuses (documented), but message frames it as a missing param
|
||||
$ aura graph build < cu_4_gang_instance_member.ops.json
|
||||
aura: note: use "xover": spread_xover -> f61659...
|
||||
aura: op 6 (gang): node xover has no param "fast.length" (exit 1)
|
||||
|
||||
# happy path: use + splice-time bind -> closed blueprint -> run
|
||||
$ aura graph build < cu_1_consumer_bound.ops.json > closed.bp.json
|
||||
$ aura graph introspect --params cu_1_consumer_bound.ops.json # (empty = closed)
|
||||
$ aura run closed.bp.json
|
||||
{"manifest":{...,"params":[],"defaults":[["graph.xover.fast.length",{"I64":2}],...]},"metrics":{...}} (exit 0)
|
||||
$ aura run closed.bp.json --real GER40
|
||||
{"manifest":{...}} (exit 0)
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{"op": "doc", "text": "consumer: splice the spread pattern by name, feed price, clamp the spread into a bias"},
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "use", "ref": {"name": "spread_xover"}, "name": "xover"},
|
||||
{"op": "feed", "role": "price", "into": ["xover.price"]},
|
||||
{"op": "add", "type": "Bias", "name": "bias"},
|
||||
{"op": "connect", "from": "xover.spread", "to": "bias.signal"},
|
||||
{"op": "expose", "from": "bias.bias", "as": "bias"}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{"op": "doc", "text": "consumer that splices the spread pattern, binds its params at the use seam, and clamps to a bias — a closed, runnable blueprint"},
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "use", "ref": {"name": "spread_xover"}, "name": "xover", "bind": {"fast.length": {"I64": 2}, "slow.length": {"I64": 6}}},
|
||||
{"op": "feed", "role": "price", "into": ["xover.price"]},
|
||||
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 0.5}}},
|
||||
{"op": "connect", "from": "xover.spread", "to": "bias.signal"},
|
||||
{"op": "expose", "from": "bias.bias", "as": "bias"}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{"op": "doc", "text": "open SMA-crossover spread pattern: fast minus slow over an injected price input role"},
|
||||
{"op": "input", "role": "price"},
|
||||
{"op": "add", "type": "SMA", "name": "fast"},
|
||||
{"op": "add", "type": "SMA", "name": "slow"},
|
||||
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
|
||||
{"op": "add", "type": "Sub", "name": "sub"},
|
||||
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
|
||||
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
|
||||
{"op": "expose", "from": "sub.value", "as": "spread"}
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{"op": "input", "role": "price"},
|
||||
{"op": "add", "type": "EMA", "name": "e"},
|
||||
{"op": "feed", "role": "price", "into": ["e.series"]},
|
||||
{"op": "expose", "from": "e.value", "as": "smoothed"}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"op": "doc", "text": "open EMA smoother pattern: exponential moving average over an injected price input role"},
|
||||
{"op": "input", "role": "price"},
|
||||
{"op": "add", "type": "EMA", "name": "e"},
|
||||
{"op": "feed", "role": "price", "into": ["e.series"]},
|
||||
{"op": "expose", "from": "e.value", "as": "smoothed"}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"op": "doc", "text": "ref by an ambiguous one-char prefix (two ids start with e)"},
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "use", "ref": {"content_id": "e"}, "name": "sp"},
|
||||
{"op": "feed", "role": "price", "into": ["sp.price"]},
|
||||
{"op": "expose", "from": "sp.spread", "as": "spread"}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"op": "doc", "text": "ref a registered spread by full content id"},
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "use", "ref": {"content_id": "e6982f7d4ad35dd59f1d9835e10436c193735d94501b7d10518eae0d9cd55d82"}, "name": "sp"},
|
||||
{"op": "feed", "role": "price", "into": ["sp.price"]},
|
||||
{"op": "expose", "from": "sp.spread", "as": "spread"}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"op": "doc", "text": "ref a registered spread by unique content-id prefix"},
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "use", "ref": {"content_id": "e69"}, "name": "sp"},
|
||||
{"op": "feed", "role": "price", "into": ["sp.price"]},
|
||||
{"op": "expose", "from": "sp.spread", "as": "spread"}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"op": "doc", "text": "ref by a label that was never registered"},
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "use", "ref": {"name": "nope"}, "name": "sp"},
|
||||
{"op": "feed", "role": "price", "into": ["sp.price"]},
|
||||
{"op": "expose", "from": "sp.spread", "as": "spread"}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"op": "doc", "text": "ref by a prefix that matches nothing in the store"},
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "use", "ref": {"content_id": "deadbeef"}, "name": "sp"},
|
||||
{"op": "feed", "role": "price", "into": ["sp.price"]},
|
||||
{"op": "expose", "from": "sp.spread", "as": "spread"}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{"op": "doc", "text": "spread pattern variant 2 for prefix ambiguity probe"},
|
||||
{"op": "input", "role": "price"},
|
||||
{"op": "add", "type": "SMA", "name": "fast"},
|
||||
{"op": "add", "type": "SMA", "name": "slow"},
|
||||
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
|
||||
{"op": "add", "type": "Sub", "name": "sub"},
|
||||
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
|
||||
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
|
||||
{"op": "expose", "from": "sub.value", "as": "spread"}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{"op": "doc", "text": "spread pattern variant 5 for prefix ambiguity probe"},
|
||||
{"op": "input", "role": "price"},
|
||||
{"op": "add", "type": "SMA", "name": "fast"},
|
||||
{"op": "add", "type": "SMA", "name": "slow"},
|
||||
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
|
||||
{"op": "add", "type": "Sub", "name": "sub"},
|
||||
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
|
||||
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
|
||||
{"op": "expose", "from": "sub.value", "as": "spread"}
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{"op": "doc", "text": "open bias pattern: SMA-crossover spread clamped to a bias, over an injected price input role"},
|
||||
{"op": "input", "role": "price"},
|
||||
{"op": "add", "type": "SMA", "name": "fast"},
|
||||
{"op": "add", "type": "SMA", "name": "slow"},
|
||||
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
|
||||
{"op": "add", "type": "Sub", "name": "sub"},
|
||||
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
|
||||
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
|
||||
{"op": "add", "type": "Bias", "name": "bias"},
|
||||
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
|
||||
{"op": "expose", "from": "bias.bias", "as": "bias"}
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{"op": "doc", "text": "open bias pattern with params bound but the price input role still open — isolates the standalone-run input-role refusal"},
|
||||
{"op": "input", "role": "price"},
|
||||
{"op": "add", "type": "SMA", "name": "fast", "bind": {"length": {"I64": 2}}},
|
||||
{"op": "add", "type": "SMA", "name": "slow", "bind": {"length": {"I64": 4}}},
|
||||
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
|
||||
{"op": "add", "type": "Sub", "name": "sub"},
|
||||
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
|
||||
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
|
||||
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 0.5}}},
|
||||
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
|
||||
{"op": "expose", "from": "bias.bias", "as": "bias"}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{"op": "doc", "text": "consumer that tries to gang two params of a spliced instance (documented as unsupported)"},
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "use", "ref": {"name": "spread_xover"}, "name": "xover"},
|
||||
{"op": "feed", "role": "price", "into": ["xover.price"]},
|
||||
{"op": "add", "type": "Bias", "name": "bias"},
|
||||
{"op": "connect", "from": "xover.spread", "to": "bias.signal"},
|
||||
{"op": "gang", "as": "xover_length", "into": ["xover.fast.length", "xover.slow.length"]},
|
||||
{"op": "expose", "from": "bias.bias", "as": "bias"}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
/runs
|
||||
@@ -0,0 +1,4 @@
|
||||
# Static project context only (C17); paths only.
|
||||
[paths]
|
||||
runs = "runs"
|
||||
# data = "/path/to/archive" # the recorded-data root; defaults to the built-in path
|
||||
@@ -0,0 +1,22 @@
|
||||
# lab — an aura research project (data-only)
|
||||
|
||||
This directory is an aura project: blueprints + research documents over the
|
||||
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 lab_signal.fast.length=2,4,8`
|
||||
(`aura sweep <bp> --list-axes` lists the open + bound-overridable axes)
|
||||
- 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`).
|
||||
- Topology is data (`blueprints/*.json`); results land in `runs/`.
|
||||
- Execution model: a strategy emits a bias in [-1,+1] per cycle, held as the
|
||||
continuously-tracked target position; a protective stop defines the risk
|
||||
unit R, and quality metrics are R-based. Entry signals become held state
|
||||
via the signal-side latch/edge-pulse idiom (see
|
||||
`aura graph introspect --vocabulary`).
|
||||
- Data plane: the research verbs are sugar over registered process/campaign
|
||||
documents — author them directly with `aura process` / `aura campaign`,
|
||||
growing one from a bare `{}` via `aura campaign introspect --unwired`.
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"lab_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
|
||||
@@ -0,0 +1,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,15 @@
|
||||
[
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "add", "type": "SMA", "name": "fast", "bind": {"length": {"I64": 3}}},
|
||||
{"op": "add", "type": "SMA", "name": "slow", "bind": {"length": {"I64": 6}}},
|
||||
{"op": "add", "type": "SMA", "name": "px", "bind": {"length": {"I64": 1}}},
|
||||
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series", "px.series"]},
|
||||
{"op": "add", "type": "Sub", "name": "sub"},
|
||||
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
|
||||
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
|
||||
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 0.5}}},
|
||||
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
|
||||
{"op": "tap", "from": "sub.value", "as": "signal"},
|
||||
{"op": "tap", "from": "px.value", "as": "price"},
|
||||
{"op": "expose", "from": "bias.bias", "as": "bias"}
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
[
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "add", "type": "SMA", "name": "fast", "bind": {"length": {"I64": 3}}},
|
||||
{"op": "add", "type": "SMA", "name": "slow", "bind": {"length": {"I64": 6}}},
|
||||
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
|
||||
{"op": "add", "type": "Gt", "name": "cross"},
|
||||
{"op": "connect", "from": "fast.value", "to": "cross.a"},
|
||||
{"op": "connect", "from": "slow.value", "to": "cross.b"},
|
||||
{"op": "add", "type": "Sign", "name": "sgn"},
|
||||
{"op": "add", "type": "Sub", "name": "sub"},
|
||||
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
|
||||
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
|
||||
{"op": "connect", "from": "sub.value", "to": "sgn.value"},
|
||||
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 1.0}}},
|
||||
{"op": "connect", "from": "sgn.value", "to": "bias.signal"},
|
||||
{"op": "tap", "from": "cross.value", "as": "above"},
|
||||
{"op": "expose", "from": "bias.bias", "as": "bias"}
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{"op": "doc", "text": "SMA crossover bias with two measurement taps for fold discovery"},
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "add", "type": "SMA", "name": "fast", "bind": {"length": {"I64": 3}}},
|
||||
{"op": "add", "type": "SMA", "name": "slow", "bind": {"length": {"I64": 6}}},
|
||||
{"op": "add", "type": "SMA", "name": "px", "bind": {"length": {"I64": 1}}},
|
||||
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series", "px.series"]},
|
||||
{"op": "add", "type": "Sub", "name": "sub"},
|
||||
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
|
||||
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
|
||||
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 0.5}}},
|
||||
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
|
||||
{"op": "tap", "from": "sub.value", "as": "spread"},
|
||||
{"op": "tap", "from": "px.value", "as": "price"},
|
||||
{"op": "expose", "from": "bias.bias", "as": "bias"}
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "add", "type": "SMA", "name": "fast", "params": {"length": {"I64": 3}}},
|
||||
{"op": "add", "type": "SMA", "name": "slow", "bind": {"length": {"I64": 6}}},
|
||||
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
|
||||
{"op": "add", "type": "Sub", "name": "sub"},
|
||||
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
|
||||
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
|
||||
{"op": "add", "type": "Bias", "name": "bias"},
|
||||
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
|
||||
{"op": "expose", "from": "bias.bias", "as": "bias"}
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{"op": "doc", "text": "SMA crossover exposing three measurement taps to probe the skipped-tap note"},
|
||||
{"op": "source", "role": "price", "kind": "F64"},
|
||||
{"op": "add", "type": "SMA", "name": "fast", "bind": {"length": {"I64": 3}}},
|
||||
{"op": "add", "type": "SMA", "name": "slow", "bind": {"length": {"I64": 6}}},
|
||||
{"op": "feed", "role": "price", "into": ["fast.series", "slow.series"]},
|
||||
{"op": "add", "type": "Sub", "name": "sub"},
|
||||
{"op": "connect", "from": "fast.value", "to": "sub.lhs"},
|
||||
{"op": "connect", "from": "slow.value", "to": "sub.rhs"},
|
||||
{"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 1.0}}},
|
||||
{"op": "connect", "from": "sub.value", "to": "bias.signal"},
|
||||
{"op": "tap", "from": "fast.value", "as": "fast_ma"},
|
||||
{"op": "tap", "from": "slow.value", "as": "slow_ma"},
|
||||
{"op": "tap", "from": "sub.value", "as": "spread"},
|
||||
{"op": "expose", "from": "bias.bias", "as": "bias"}
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Milestone 36 fieldtest — "Self-description: every surface explains itself"
|
||||
|
||||
Empirical, binary-only check of the milestone promise: that a deployment-posture
|
||||
agent with **only** the `aura` binary (no engine sources, no repo docs, no
|
||||
hand-written bootstrap card) can bootstrap from the surfaces alone.
|
||||
|
||||
Every artefact here was produced touching nothing but the release binary
|
||||
(`d26f0c8`) and what it emits (help text, `aura new` scaffold, introspection
|
||||
output, error messages). One child dir per scenario; each README states the
|
||||
property it protects. Full findings + verdict: `docs/specs/fieldtest-m1-self-description.md`.
|
||||
|
||||
| dir | axis | outcome |
|
||||
|-----|------|---------|
|
||||
| s1_bootstrap_author_validate | cold bootstrap → author → validate | green |
|
||||
| s2_execution_pulse_idiom | execution semantics + pulse idiom | green (1 friction) |
|
||||
| s3_vocabulary_breadth | vocabulary self-description breadth| pass (1 friction) |
|
||||
| s4_document_ramp | document-first ramp from `{}` | green (2 gaps) |
|
||||
| s5_trace_measurement | trace / measurement path | dead-end (1 bug, 1 gap) |
|
||||
@@ -0,0 +1,33 @@
|
||||
# s1 — cold bootstrap → author → validate
|
||||
|
||||
**Protected property:** starting from `aura --help` alone (no repo, no docs), a
|
||||
downstream agent can scaffold a project, author a new strategy over the
|
||||
discovered op-grammar vocabulary, and drive it to a green backtest — using only
|
||||
what the binary emits.
|
||||
|
||||
## Trail (binary-only)
|
||||
1. `aura --help` — states what aura is, the two-layer model, and the execution
|
||||
model (bias in [-1,+1], held as target position, stop = R).
|
||||
2. `aura new demo-strat` — scaffolds `Aura.toml`, `blueprints/signal.json`, and a
|
||||
machine-written `CLAUDE.md` bootstrap card.
|
||||
3. `aura graph build --help` — the op-list authoring grammar (one line of meaning
|
||||
per op).
|
||||
4. `aura graph introspect --vocabulary` / `--node <T>` — node types and port
|
||||
schemas.
|
||||
5. Author `ema_cross.oplist.json` (this dir); build it:
|
||||
`aura graph build < ema_cross.oplist.json > blueprints/ema_cross.json`
|
||||
6. Validate green:
|
||||
- synthetic: `aura run blueprints/ema_cross.json` → exit 0 (18-cycle synthetic
|
||||
stream → 0 trades; all-zero metrics, no note that the stream is tiny).
|
||||
- real: `aura run blueprints/ema_cross.json --real EURUSD --from 1577836800000
|
||||
--to 1583020800000` → 3944 trades, valid metrics JSON, exit 0.
|
||||
|
||||
`ema_cross.blueprint.json` is the built artefact `graph build` emitted.
|
||||
|
||||
## Notes
|
||||
- The harness auto-wraps the bias with `sim-optimal+risk-executor` and reports R
|
||||
metrics even though the blueprint emits only a bias — confirming the documented
|
||||
execution model without any extra authoring.
|
||||
- Minor friction: `graph build` has no op to set the blueprint name; it defaults
|
||||
to `graph`, and that name leaks into the sweep axis namespace
|
||||
(`graph.fast.length`).
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"graph","doc":"EMA(5)/EMA(20) spread clamped to a directional bias","nodes":[{"primitive":{"type":"EMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":5}}]}},{"primitive":{"type":"EMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":20}}]}},{"primitive":{"type":"Sub","name":"spread"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.25}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"EMA","name":"fast","bind":{"length":{"I64":5}}},
|
||||
{"op":"add","type":"EMA","name":"slow","bind":{"length":{"I64":20}}},
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"add","type":"Sub","name":"spread"},
|
||||
{"op":"connect","from":"fast.value","to":"spread.lhs"},
|
||||
{"op":"connect","from":"slow.value","to":"spread.rhs"},
|
||||
{"op":"add","type":"Bias","name":"bias","bind":{"scale":{"F64":0.25}}},
|
||||
{"op":"connect","from":"spread.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"},
|
||||
{"op":"doc","text":"EMA(5)/EMA(20) spread clamped to a directional bias"}
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
# s2 — execution semantics + one-shot/pulse idiom
|
||||
|
||||
**Protected property:** from the surfaces alone a consumer can learn (a) what the
|
||||
strategy's output stream means, (b) how it is executed against a protective stop,
|
||||
and (c) the signal-side idiom the surfaces recommend when a held one-shot / pulse
|
||||
behaviour is wanted — and can author that idiom to a green run.
|
||||
|
||||
## What the surfaces say
|
||||
- `aura --help`: "a strategy emits a bias in [-1,+1] per cycle, held as the
|
||||
continuously-tracked target position; a protective stop defines the risk unit
|
||||
R, and signal quality is measured in R."
|
||||
- Node schemas make the execution layer concrete: `Bias` (clamp to [-1,+1]),
|
||||
`FixedStop` (`price → stop_distance`, defines R), `PositionManagement`
|
||||
(`bias + price + stop_distance + size → managed position in R`). The built-in
|
||||
`run`/`sweep` harness supplies this risk executor automatically.
|
||||
- The scaffolded `CLAUDE.md`: "Entry signals become held state via the signal-side
|
||||
latch/edge-pulse idiom (see `aura graph introspect --vocabulary`)."
|
||||
|
||||
## Authored idioms (both build + validate green)
|
||||
- `latch_hold.oplist.json` — momentary crossover events → held state via `Latch`
|
||||
(`set = fast>slow`, `reset = slow>fast`), held level → `Bias`. Runs green
|
||||
(2543 trades over EURUSD 2020-01..03).
|
||||
- `edge_pulse.oplist.json` — the complementary one-shot pulse, built as the
|
||||
first-difference of the latched level (`Delay(lag=1)` + `Sub`) fed to `Bias`
|
||||
(+1 on entry edge, −1 on exit edge, 0 held). Builds + validates green.
|
||||
|
||||
## Friction recorded
|
||||
The `CLAUDE.md` names the "latch/edge-pulse idiom" and points to
|
||||
`graph introspect --vocabulary`, but that surface only lists node one-liners —
|
||||
it lists `Latch` but no `edge`/`pulse` primitive and no composition recipe. The
|
||||
pulse *is* constructible (Latch→Delay→Sub→Bias), but the consumer must infer the
|
||||
first-difference recipe; the pointer resolves to a list, not a recipe.
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"graph","doc":"edge-pulse: first-difference of the latched level, +1 on entry edge -1 on exit edge","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":5}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":20}}]}},{"primitive":{"type":"Gt","name":"cross_up"}},{"primitive":{"type":"Gt","name":"cross_dn"}},{"primitive":{"type":"Latch","name":"hold"}},{"primitive":{"type":"Delay","name":"prev","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Sub","name":"edge"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":1.0}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":0,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":4,"slot":1,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":6,"to":7,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}]}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user