Compare commits
7 Commits
26b3d689df
...
eb2b0a132c
| Author | SHA1 | Date | |
|---|---|---|---|
| eb2b0a132c | |||
| 14c43474ab | |||
| 6ca359ae00 | |||
| bc8fb46110 | |||
| a8b1ba45c5 | |||
| e84ad6d0d2 | |||
| f449cb06f2 |
Generated
+3
-2
@@ -160,6 +160,7 @@ dependencies = [
|
|||||||
"aura-core",
|
"aura-core",
|
||||||
"aura-engine",
|
"aura-engine",
|
||||||
"aura-ingest",
|
"aura-ingest",
|
||||||
|
"aura-market",
|
||||||
"aura-measurement",
|
"aura-measurement",
|
||||||
"aura-registry",
|
"aura-registry",
|
||||||
"aura-research",
|
"aura-research",
|
||||||
@@ -167,6 +168,7 @@ dependencies = [
|
|||||||
"aura-std",
|
"aura-std",
|
||||||
"aura-strategy",
|
"aura-strategy",
|
||||||
"aura-vocabulary",
|
"aura-vocabulary",
|
||||||
|
"chrono-tz",
|
||||||
"clap",
|
"clap",
|
||||||
"data-server",
|
"data-server",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -192,6 +194,7 @@ dependencies = [
|
|||||||
name = "aura-core"
|
name = "aura-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono-tz",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
@@ -307,8 +310,6 @@ dependencies = [
|
|||||||
"aura-backtest",
|
"aura-backtest",
|
||||||
"aura-core",
|
"aura-core",
|
||||||
"aura-strategy",
|
"aura-strategy",
|
||||||
"chrono",
|
|
||||||
"chrono-tz",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -122,7 +122,9 @@ The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, `tap`,
|
|||||||
`gang`, `doc`, and `use` (`tap` declares a recorded measurement point on an
|
`gang`, `doc`, and `use` (`tap` declares a recorded measurement point on an
|
||||||
interior wire; `doc` declares the composite's one-line meaning, required at
|
interior wire; `doc` declares the composite's one-line meaning, required at
|
||||||
register — C29; `use` splices a registered blueprint in as a nested composite,
|
register — C29; `use` splices a registered blueprint in as a nested composite,
|
||||||
by content id or label; see the authoring guide). See
|
by content id or label; `add` additionally takes an `args` object for
|
||||||
|
**arg-bearing** types (`Session`, `LinComb`, `CostSum`) — structural,
|
||||||
|
non-scalar construction consumed before `bind`; see the authoring guide). See
|
||||||
`aura graph introspect --node <T>` for a type's exact ports and the op-script
|
`aura graph introspect --node <T>` for a type's exact ports and the op-script
|
||||||
grammar in the design ledger for the full semantics.
|
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),
|
# (#[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.
|
# so this rides the same dev-only-edge idiom as `aura-analysis`/`sha2` below.
|
||||||
aura-composites = { path = "../aura-composites" }
|
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
|
# 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.
|
# (mod ic_tests in main.rs) — a test-only edge, not a production one.
|
||||||
aura-analysis = { path = "../aura-analysis" }
|
aura-analysis = { path = "../aura-analysis" }
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ use std::collections::BTreeMap;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use aura_engine::{
|
use aura_engine::{
|
||||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, BindOpError,
|
blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, ArgOpError,
|
||||||
BlueprintDoc, CompileError, Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind,
|
BindOpError, BlueprintDoc, CompileError, Composite, GraphSession, LoadError, Op, OpError,
|
||||||
|
Scalar, ScalarKind,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
@@ -30,6 +31,9 @@ pub const OP_REFERENCE: &str = r#"Op-list reference (stdin: a JSON array of op o
|
|||||||
declare an open input role (wired by an enclosing graph)
|
declare an open input role (wired by an enclosing graph)
|
||||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}}
|
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}}
|
||||||
instantiate a node ("name" optional; "bind" maps param -> typed scalar)
|
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"]}
|
{"op":"feed","role":"price","into":["fast.series","slow.series"]}
|
||||||
wire a role into one or more input slots
|
wire a role into one or more input slots
|
||||||
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
||||||
@@ -64,6 +68,12 @@ enum OpDoc {
|
|||||||
// naming, not an aliasing (contrast `expose`'s `as`, which is a real alias).
|
// naming, not an aliasing (contrast `expose`'s `as`, which is a real alias).
|
||||||
#[serde(rename = "name", default)]
|
#[serde(rename = "name", default)]
|
||||||
as_name: Option<String>,
|
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)]
|
#[serde(default)]
|
||||||
bind: BTreeMap<String, Scalar>,
|
bind: BTreeMap<String, Scalar>,
|
||||||
},
|
},
|
||||||
@@ -151,9 +161,12 @@ impl From<OpDoc> for Op {
|
|||||||
match d {
|
match d {
|
||||||
OpDoc::Source { role, kind } => Op::Source { role, kind },
|
OpDoc::Source { role, kind } => Op::Source { role, kind },
|
||||||
OpDoc::Input { role } => Op::Input { role },
|
OpDoc::Input { role } => Op::Input { role },
|
||||||
OpDoc::Add { type_id, as_name, bind } => {
|
OpDoc::Add { type_id, as_name, args, bind } => Op::Add {
|
||||||
Op::Add { type_id, as_name, bind: bind.into_iter().collect() }
|
type_id,
|
||||||
}
|
as_name,
|
||||||
|
args: args.into_iter().collect(),
|
||||||
|
bind: bind.into_iter().collect(),
|
||||||
|
},
|
||||||
OpDoc::Feed { role, into } => Op::Feed { role, into },
|
OpDoc::Feed { role, into } => Op::Feed { role, into },
|
||||||
OpDoc::Connect { from, to } => Op::Connect { from, to },
|
OpDoc::Connect { from, to } => Op::Connect { from, to },
|
||||||
OpDoc::Expose { from, as_name } => Op::Expose { from, as_name },
|
OpDoc::Expose { from, as_name } => Op::Expose { from, as_name },
|
||||||
@@ -172,6 +185,24 @@ impl From<OpDoc> for Op {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Phrase one `OpError` as a human cause (the engine error types are
|
/// Phrase one `OpError` as a human cause (the engine error types are
|
||||||
/// `Display`-free by convention; this is the CLI's presentation layer). The
|
/// `Display`-free by convention; this is the CLI's presentation layer). The
|
||||||
/// exhaustive match means a new `OpError` variant forces an update here.
|
/// exhaustive match means a new `OpError` variant forces an update here.
|
||||||
@@ -204,6 +235,7 @@ fn format_op_error(e: &OpError) -> String {
|
|||||||
format!("cannot bind {node}.{param} — member of gang {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::UnconnectedPort { node, slot } => format!("slot {node}.{slot} is unconnected"),
|
||||||
OpError::RoleKindMismatch { role } => format!("input role {role} fans into slots of differing kinds"),
|
OpError::RoleKindMismatch { role } => format!("input role {role} fans into slots of differing kinds"),
|
||||||
OpError::GangKindMismatch { member, expected, got } => {
|
OpError::GangKindMismatch { member, expected, got } => {
|
||||||
@@ -439,6 +471,17 @@ pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result
|
|||||||
let schema = builder.schema();
|
let schema = builder.schema();
|
||||||
// C29 (#315): the head line carries the node's one-line meaning.
|
// C29 (#315): the head line carries the node's one-line meaning.
|
||||||
let mut out = format!("{} — {}\n", builder.label(), schema.doc);
|
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 {
|
for port in &schema.inputs {
|
||||||
out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind));
|
out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind));
|
||||||
}
|
}
|
||||||
@@ -662,6 +705,7 @@ pub(crate) fn blueprint_load_prose(e: &LoadError) -> String {
|
|||||||
}
|
}
|
||||||
LoadError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
|
LoadError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
|
||||||
LoadError::Gang(e) => format!("gang section invalid: {}", gang_fault_prose(e)),
|
LoadError::Gang(e) => format!("gang section invalid: {}", gang_fault_prose(e)),
|
||||||
|
LoadError::BadArg(e) => format!("construction args invalid: {}", arg_op_error_prose(e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3809,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]
|
#[test]
|
||||||
fn mc_r_bootstrap_json_carries_every_bootstrap_field_under_the_mc_r_bootstrap_key() {
|
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 —
|
// Property: the `mc_r_bootstrap` output line is the full RBootstrap shape —
|
||||||
|
|||||||
@@ -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();
|
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
lines.len(),
|
lines.len(),
|
||||||
33,
|
36,
|
||||||
"the std-only (no project) vocabulary has exactly the roster's 33 entries: {stdout}"
|
"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}");
|
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]
|
#[test]
|
||||||
fn graph_introspect_unwired_reports_open_slots() {
|
fn graph_introspect_unwired_reports_open_slots() {
|
||||||
let doc = r#"[
|
let doc = r#"[
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite {
|
|||||||
let sqrt = g.add(Sqrt::builder()); // → σ (price units)
|
let sqrt = g.add(Sqrt::builder()); // → σ (price units)
|
||||||
// k·σ: weights[0] bound (Some) or open and named `stop_k` (None).
|
// k·σ: weights[0] bound (Some) or open and named `stop_k` (None).
|
||||||
let scale = g.add(match knobs {
|
let scale = g.add(match knobs {
|
||||||
Some((_, k)) => LinComb::builder(1).bind("weights[0]", Scalar::f64(k)),
|
Some((_, k)) => LinComb::configured(1).bind("weights[0]", Scalar::f64(k)),
|
||||||
None => LinComb::builder(1).named("stop_k"),
|
None => LinComb::configured(1).named("stop_k"),
|
||||||
});
|
});
|
||||||
g.feed(price, [delay.input("series"), sub.input("lhs")]);
|
g.feed(price, [delay.input("series"), sub.input("lhs")]);
|
||||||
g.connect(delay.output("value"), sub.input("rhs"));
|
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 entry = g.input_role("entry_price");
|
||||||
let stop = g.input_role("stop_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.
|
// Per-role geometry fan targets, collected across all nodes, fed once per role.
|
||||||
let mut closed_t = Vec::with_capacity(n);
|
let mut closed_t = Vec::with_capacity(n);
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
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]
|
[dev-dependencies]
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ pub use column::{Column, Window};
|
|||||||
pub use ctx::Ctx;
|
pub use ctx::Ctx;
|
||||||
pub use error::KindMismatch;
|
pub use error::KindMismatch;
|
||||||
pub use node::{
|
pub use node::{
|
||||||
doc_gate, zip_params, BindOpError, BoundParam, DocGateFault, FieldSpec, Firing, Node,
|
doc_gate, zip_params, ArgKind, ArgOpError, ArgSpec, ArgValue, BindOpError, BoundParam,
|
||||||
NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
ConstructionArg, DocGateFault, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
|
||||||
|
PrimitiveBuilder,
|
||||||
};
|
};
|
||||||
pub use scalar::{Scalar, ScalarKind, Timestamp};
|
pub use scalar::{Scalar, ScalarKind, Timestamp};
|
||||||
pub use series_fold::SeriesFold;
|
pub use series_fold::SeriesFold;
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ pub struct PrimitiveBuilder {
|
|||||||
instance_name: Option<String>,
|
instance_name: Option<String>,
|
||||||
schema: NodeSchema,
|
schema: NodeSchema,
|
||||||
bound: Vec<BoundParam>,
|
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
|
// 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.
|
// boxed node out); a type alias would not clarify it.
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
@@ -127,7 +128,108 @@ impl PrimitiveBuilder {
|
|||||||
schema: NodeSchema,
|
schema: NodeSchema,
|
||||||
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
|
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
|
||||||
) -> Self {
|
) -> 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
|
/// 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
|
/// a knob-address segment (`<composite>.<name>.<param>`, via `node_name()` in
|
||||||
@@ -344,6 +446,135 @@ pub enum BindOpError {
|
|||||||
AlreadyGanged { param: String, gang: String },
|
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
|
/// A node's declared interface: its inputs (in order) and its output record — an
|
||||||
/// ordered list of named base columns; length 1 is a scalar (the degenerate
|
/// ordered list of named base columns; length 1 is a scalar (the degenerate
|
||||||
/// case), and an **empty** `output` (`vec![]`) declares a **pure consumer**
|
/// case), and an **empty** `output` (`vec![]`) declares a **pure consumer**
|
||||||
@@ -844,6 +1075,132 @@ mod tests {
|
|||||||
fn doc_gate_accepts_a_meaning_line() {
|
fn doc_gate_accepts_a_meaning_line() {
|
||||||
assert_eq!(doc_gate("EMA", "exponential moving average over the input series"), Ok(()));
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1821,7 +1821,7 @@ mod tests {
|
|||||||
Composite::new(
|
Composite::new(
|
||||||
"sig3",
|
"sig3",
|
||||||
vec![
|
vec![
|
||||||
LinComb::builder(2).into(),
|
LinComb::configured(2).into(),
|
||||||
Bias::builder().into(),
|
Bias::builder().into(),
|
||||||
Sma::builder().named("c").into(),
|
Sma::builder().named("c").into(),
|
||||||
],
|
],
|
||||||
@@ -1860,7 +1860,7 @@ mod tests {
|
|||||||
Composite::new(
|
Composite::new(
|
||||||
"sig3",
|
"sig3",
|
||||||
vec![
|
vec![
|
||||||
LinComb::builder(2).into(),
|
LinComb::configured(2).into(),
|
||||||
Bias::builder().into(),
|
Bias::builder().into(),
|
||||||
Sma::builder().named("c").into(),
|
Sma::builder().named("c").into(),
|
||||||
],
|
],
|
||||||
@@ -3233,7 +3233,7 @@ mod tests {
|
|||||||
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
||||||
let strategy = Composite::new(
|
let strategy = Composite::new(
|
||||||
"strategy",
|
"strategy",
|
||||||
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
|
vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
|
||||||
vec![],
|
vec![],
|
||||||
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
|
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
|
||||||
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
||||||
@@ -3441,7 +3441,7 @@ mod tests {
|
|||||||
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
||||||
let strategy = Composite::new(
|
let strategy = Composite::new(
|
||||||
"strategy",
|
"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
|
// fan fast_slow's output into both LinComb terms so every interior slot
|
||||||
// is wired (the totality check, cycle 0040); param order is unaffected.
|
// is wired (the totality check, cycle 0040); param order is unaffected.
|
||||||
vec![
|
vec![
|
||||||
@@ -3501,7 +3501,7 @@ mod tests {
|
|||||||
use aura_std::{LinComb, Sma};
|
use aura_std::{LinComb, Sma};
|
||||||
let bp = Composite::new(
|
let bp = Composite::new(
|
||||||
"root",
|
"root",
|
||||||
vec![Sma::builder().into(), LinComb::builder(2).into()],
|
vec![Sma::builder().into(), LinComb::configured(2).into()],
|
||||||
vec![],
|
vec![],
|
||||||
vec![],
|
vec![],
|
||||||
vec![], // output
|
vec![], // output
|
||||||
|
|||||||
@@ -7,18 +7,29 @@
|
|||||||
//! closure and the declared schema are re-derived on load from the injected
|
//! 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
|
//! 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).
|
//! 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::blueprint::{BlueprintNode, Composite, Gang, OutField, Role};
|
||||||
use crate::harness::Edge;
|
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
|
/// The CEILING format version this build's loader understands (and the
|
||||||
/// (Tier-2) change; additive optional fields do not bump it (#156). Pre-ship
|
/// writer may ever emit) — bumped only by a load-bearing (Tier-2) change;
|
||||||
/// dormancy (#61, 2026-07-10): while the project is unshipped every document
|
/// additive optional fields do not bump it (#156). Pre-ship dormancy (#61,
|
||||||
/// lives in-repo and reader/writer change atomically, so the Tier-2 bump
|
/// 2026-07-10): while the project is unshipped every document lives in-repo
|
||||||
/// discipline activates at the first external ship — which consciously
|
/// and reader/writer change atomically, so the Tier-2 bump discipline
|
||||||
/// freezes v1 (gangs included).
|
/// activates at the first external ship.
|
||||||
pub const BLUEPRINT_FORMAT_VERSION: u32 = 1;
|
///
|
||||||
|
/// #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.
|
/// Top-level envelope: the version is read before the payload is interpreted.
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
@@ -61,17 +72,33 @@ pub enum NodeData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A primitive node as data: its compiled-in type identity, optional instance
|
/// 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)]
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
pub struct PrimitiveData {
|
pub struct PrimitiveData {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
pub type_id: String,
|
pub type_id: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub name: Option<String>,
|
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")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub bound: Vec<BoundParam>,
|
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).
|
/// Serializer failure (typed, named).
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum SerializeError {
|
pub enum SerializeError {
|
||||||
@@ -111,9 +138,19 @@ fn project_node(n: &BlueprintNode) -> NodeData {
|
|||||||
// construction once a multi-param node enters the vocabulary.
|
// construction once a multi-param node enters the vocabulary.
|
||||||
let mut bound = b.bound_params().to_vec();
|
let mut bound = b.bound_params().to_vec();
|
||||||
bound.sort_by_key(|bp| bp.pos);
|
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 {
|
NodeData::Primitive(PrimitiveData {
|
||||||
type_id: b.label(),
|
type_id: b.label(),
|
||||||
name: b.instance_name().map(str::to_string),
|
name: b.instance_name().map(str::to_string),
|
||||||
|
args,
|
||||||
bound,
|
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 {
|
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> {
|
fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
|
||||||
@@ -200,11 +261,16 @@ pub enum LoadError {
|
|||||||
/// addition needs finer granularity than a version bump.
|
/// addition needs finer granularity than a version bump.
|
||||||
UnsupportedVersion { found: u32, supported: u32 },
|
UnsupportedVersion { found: u32, supported: u32 },
|
||||||
/// `resolve` returned `None` for a serialized `type_id` (outside the injected
|
/// `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),
|
UnknownNodeType(String),
|
||||||
/// The document's gangs section fails structural validation against its
|
/// The document's gangs section fails structural validation against its
|
||||||
/// own nodes (a hand-edited or corrupted document).
|
/// own nodes (a hand-edited or corrupted document).
|
||||||
Gang(crate::blueprint::CompileError),
|
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(
|
fn reconstruct(
|
||||||
@@ -220,6 +286,15 @@ fn reconstruct(
|
|||||||
if let Some(n) = &p.name {
|
if let Some(n) = &p.name {
|
||||||
b = b.named(n);
|
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
|
// Re-apply bound params BY NAME. The effective param vector is
|
||||||
// order-independent (each `bind` computes its slot against the
|
// order-independent (each `bind` computes its slot against the
|
||||||
// shrunk schema); ascending original `pos` is the canonical order.
|
// shrunk schema); ascending original `pos` is the canonical order.
|
||||||
@@ -251,7 +326,9 @@ pub fn blueprint_from_json(
|
|||||||
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
|
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
|
||||||
) -> Result<Composite, LoadError> {
|
) -> Result<Composite, LoadError> {
|
||||||
let doc: BlueprintDoc = serde_json::from_str(data).map_err(LoadError::Json)?;
|
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 {
|
return Err(LoadError::UnsupportedVersion {
|
||||||
found: doc.format_version,
|
found: doc.format_version,
|
||||||
supported: BLUEPRINT_FORMAT_VERSION,
|
supported: BLUEPRINT_FORMAT_VERSION,
|
||||||
@@ -266,7 +343,8 @@ mod tests {
|
|||||||
use crate::blueprint::{Composite, GangMember, OutField, Role};
|
use crate::blueprint::{Composite, GangMember, OutField, Role};
|
||||||
use crate::harness::{Edge, Target};
|
use crate::harness::{Edge, Target};
|
||||||
use crate::VecSource; // crate-root re-export (blueprint.rs tests import it the same way, e.g. :923)
|
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_std::{Recorder, Sma, Sub};
|
||||||
use aura_strategy::Bias;
|
use aura_strategy::Bias;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
@@ -436,11 +514,14 @@ mod tests {
|
|||||||
assert!(matches!(err, LoadError::UnknownNodeType(t) if t == "NoSuchNode"));
|
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]
|
#[test]
|
||||||
fn unsupported_version_fails_named() {
|
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();
|
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]
|
#[test]
|
||||||
@@ -813,4 +894,127 @@ mod tests {
|
|||||||
"the gang itself is identity-bearing"
|
"the gang itself is identity-bearing"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- construction args (#271) --------------------------------------
|
||||||
|
|
||||||
|
// A single-node composite around an arg-configured `Session` (arity-free —
|
||||||
|
// a `trigger` input role feeds it, its `bars_since_open` output re-exports).
|
||||||
|
fn session_arg_fixture(tz: chrono_tz::Tz) -> Composite {
|
||||||
|
Composite::new(
|
||||||
|
"sess",
|
||||||
|
vec![Session::configured(9, 30, tz, 15).into()],
|
||||||
|
vec![],
|
||||||
|
vec![Role {
|
||||||
|
name: "trigger".into(),
|
||||||
|
targets: vec![Target { node: 0, slot: 0 }],
|
||||||
|
source: Some(ScalarKind::F64),
|
||||||
|
}],
|
||||||
|
vec![OutField { node: 0, field: 0, name: "bars".into() }],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #271 acceptance (engine layer): an args-bearing composite serializes its
|
||||||
|
/// construction-arg pairs, declares `format_version: 2` (the data-driven
|
||||||
|
/// must-understand tier, spec §Data-driven format version), and round-trips
|
||||||
|
/// byte-stably through load -> re-serialize.
|
||||||
|
#[test]
|
||||||
|
fn args_round_trip_preserves_pairs_and_version_2() {
|
||||||
|
let c = session_arg_fixture(chrono_tz::Europe::Berlin);
|
||||||
|
let json = blueprint_to_json(&c).expect("serializes");
|
||||||
|
assert!(json.contains(r#""format_version":2"#), "args-bearing document is version 2: {json}");
|
||||||
|
assert!(
|
||||||
|
json.contains(r#""args":[{"name":"tz","value":"Europe/Berlin"},{"name":"open","value":"09:30"}]"#),
|
||||||
|
"carries the verbatim accepted arg pairs: {json}"
|
||||||
|
);
|
||||||
|
let loaded = blueprint_from_json(&json, &fixture_resolver()).expect("loads");
|
||||||
|
assert_eq!(
|
||||||
|
blueprint_to_json(&loaded).expect("re-serializes"),
|
||||||
|
json,
|
||||||
|
"args round-trip byte-stably"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #271 acceptance (C18 stability): a document with NO args anywhere stays
|
||||||
|
/// version 1 and byte-identical to its pre-#271 canonical form — the exact
|
||||||
|
/// golden `signal_serializes_to_canonical_golden` already pins, re-asserted
|
||||||
|
/// here under the #271-specific test name the plan names.
|
||||||
|
#[test]
|
||||||
|
fn args_free_document_serializes_byte_identical_and_version_1() {
|
||||||
|
let signal = crate::test_fixtures::sink_free_sma_cross_signal();
|
||||||
|
let json = blueprint_to_json(&signal).expect("serializes");
|
||||||
|
assert!(json.starts_with(r#"{"format_version":1,"#), "args-free document stays version 1: {json}");
|
||||||
|
assert!(!json.contains("\"args\""), "an args-free document emits no args key: {json}");
|
||||||
|
let golden = r#"{"format_version":1,"blueprint":{"name":"sma_cross","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}]}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||||||
|
assert_eq!(json, golden, "byte-identical to the pre-#271 canonical form (content ids stable, C18)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #271 (nested propagation): an args-bearing primitive nested INSIDE an
|
||||||
|
/// outer composite still trips the outer document's version to 2 — the
|
||||||
|
/// version walk recurses through `NodeData::Composite`, not just the
|
||||||
|
/// top-level node list.
|
||||||
|
#[test]
|
||||||
|
fn nested_spliced_args_composite_is_version_2() {
|
||||||
|
let inner = session_arg_fixture(chrono_tz::Europe::Berlin);
|
||||||
|
let outer = Composite::new(
|
||||||
|
"outer",
|
||||||
|
vec![crate::blueprint::BlueprintNode::Composite(inner)],
|
||||||
|
vec![],
|
||||||
|
vec![Role {
|
||||||
|
name: "trigger".into(),
|
||||||
|
targets: vec![Target { node: 0, slot: 0 }],
|
||||||
|
source: Some(ScalarKind::F64),
|
||||||
|
}],
|
||||||
|
vec![OutField { node: 0, field: 0, name: "bars".into() }],
|
||||||
|
);
|
||||||
|
let json = blueprint_to_json(&outer).expect("serializes");
|
||||||
|
assert!(json.contains(r#""format_version":2"#), "a nested arg-bearing primitive still trips version 2: {json}");
|
||||||
|
assert!(json.contains("\"args\""), "the nested primitive's args survive: {json}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #271 (identity is arg-bearing): construction args are structural,
|
||||||
|
/// id-bearing data — like a bound value, unlike a debug-symbol name — so
|
||||||
|
/// two builds differing ONLY in one accepted arg value never share an
|
||||||
|
/// identity JSON, while renaming the composite (a pure debug symbol)
|
||||||
|
/// still does not affect it.
|
||||||
|
#[test]
|
||||||
|
fn identity_json_retains_args() {
|
||||||
|
let berlin = session_arg_fixture(chrono_tz::Europe::Berlin);
|
||||||
|
let paris = session_arg_fixture(chrono_tz::Europe::Paris);
|
||||||
|
assert_ne!(
|
||||||
|
blueprint_identity_json(&berlin).expect("identity-serializes"),
|
||||||
|
blueprint_identity_json(&paris).expect("identity-serializes"),
|
||||||
|
"a differing construction-arg value survives the identity projection"
|
||||||
|
);
|
||||||
|
// renaming the composite (a debug symbol) does not affect identity.
|
||||||
|
let renamed = Composite::new(
|
||||||
|
"sess_renamed",
|
||||||
|
vec![Session::configured(9, 30, chrono_tz::Europe::Berlin, 15).into()],
|
||||||
|
vec![],
|
||||||
|
vec![Role {
|
||||||
|
name: "trigger".into(),
|
||||||
|
targets: vec![Target { node: 0, slot: 0 }],
|
||||||
|
source: Some(ScalarKind::F64),
|
||||||
|
}],
|
||||||
|
vec![OutField { node: 0, field: 0, name: "bars".into() }],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
blueprint_identity_json(&berlin).expect("identity-serializes"),
|
||||||
|
blueprint_identity_json(&renamed).expect("identity-serializes"),
|
||||||
|
"the composite's own debug name does not affect identity"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #271 (refuse, don't guess): a hand-written document naming an
|
||||||
|
/// arg-bearing type (`Session`) with NO `args` key refuses on load as
|
||||||
|
/// `LoadError::BadArg(ArgOpError::MissingArg(..))` — a pending builder
|
||||||
|
/// must never silently enter the built graph.
|
||||||
|
#[test]
|
||||||
|
fn loading_arg_bearing_type_without_args_refuses() {
|
||||||
|
let json = r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"Session"}}]}}"#;
|
||||||
|
let err = blueprint_from_json(json, &fixture_resolver()).err().unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(&err, LoadError::BadArg(ArgOpError::MissingArg(a)) if a == "tz"),
|
||||||
|
"an arg-bearing type with no args refuses as MissingArg, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use aura_core::{BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind};
|
use aura_core::{ArgOpError, BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind};
|
||||||
|
|
||||||
use crate::blueprint::{
|
use crate::blueprint::{
|
||||||
check_param_namespace_injective, edge_kind_check, validate_wiring, BlueprintNode, Composite,
|
check_param_namespace_injective, edge_kind_check, validate_wiring, BlueprintNode, Composite,
|
||||||
@@ -32,8 +32,14 @@ pub enum Op {
|
|||||||
Source { role: String, kind: ScalarKind },
|
Source { role: String, kind: ScalarKind },
|
||||||
/// Reserve an open input role (wired by an enclosing graph).
|
/// Reserve an open input role (wired by an enclosing graph).
|
||||||
Input { role: String },
|
Input { role: String },
|
||||||
/// Add a node of `type_id`, optionally named `as_name`, with `bind` params.
|
/// Add a node of `type_id`, optionally named `as_name`, with construction
|
||||||
Add { type_id: String, as_name: Option<String>, bind: Vec<(String, Scalar)> },
|
/// `args` (the typed, closed channel — #271) applied BEFORE `bind` params.
|
||||||
|
Add {
|
||||||
|
type_id: String,
|
||||||
|
as_name: Option<String>,
|
||||||
|
args: Vec<(String, String)>,
|
||||||
|
bind: Vec<(String, Scalar)>,
|
||||||
|
},
|
||||||
/// Fan a role into one or more `"node.slot"` consumers.
|
/// Fan a role into one or more `"node.slot"` consumers.
|
||||||
Feed { role: String, into: Vec<String> },
|
Feed { role: String, into: Vec<String> },
|
||||||
/// Wire `"node.field"` -> `"node.slot"`.
|
/// Wire `"node.field"` -> `"node.slot"`.
|
||||||
@@ -91,6 +97,11 @@ pub enum OpError {
|
|||||||
/// blueprint non-acyclic (domain invariant 5 / ledger C9).
|
/// blueprint non-acyclic (domain invariant 5 / ledger C9).
|
||||||
WouldCycle { from: String, to: String },
|
WouldCycle { from: String, to: String },
|
||||||
BadParam { node: String, err: BindOpError },
|
BadParam { node: String, err: BindOpError },
|
||||||
|
/// A construction-arg fault (#271): an arg-bearing type's `args` failed
|
||||||
|
/// `try_args` (unknown/duplicate/missing arg, or a malformed value) — or a
|
||||||
|
/// `Plain` type was given args it does not declare. By-identifier, naming
|
||||||
|
/// the offending node.
|
||||||
|
BadArg { node: String, err: ArgOpError },
|
||||||
/// Holistic totality fault, by-identifier: an interior input slot covered by
|
/// Holistic totality fault, by-identifier: an interior input slot covered by
|
||||||
/// no edge or role target (the finalize 0-cover arm).
|
/// no edge or role target (the finalize 0-cover arm).
|
||||||
UnconnectedPort { node: String, slot: String },
|
UnconnectedPort { node: String, slot: String },
|
||||||
@@ -180,7 +191,7 @@ impl<'v> GraphSession<'v> {
|
|||||||
match op {
|
match op {
|
||||||
Op::Source { role, kind } => self.add_role(role, Some(kind)),
|
Op::Source { role, kind } => self.add_role(role, Some(kind)),
|
||||||
Op::Input { role } => self.add_role(role, None),
|
Op::Input { role } => self.add_role(role, None),
|
||||||
Op::Add { type_id, as_name, bind } => self.add_node(type_id, as_name, bind),
|
Op::Add { type_id, as_name, args, bind } => self.add_node(type_id, as_name, args, bind),
|
||||||
Op::Feed { role, into } => self.feed(role, into),
|
Op::Feed { role, into } => self.feed(role, into),
|
||||||
Op::Connect { from, to } => self.connect(from, to),
|
Op::Connect { from, to } => self.connect(from, to),
|
||||||
Op::Expose { from, as_name } => self.expose(from, as_name),
|
Op::Expose { from, as_name } => self.expose(from, as_name),
|
||||||
@@ -248,11 +259,17 @@ impl<'v> GraphSession<'v> {
|
|||||||
&mut self,
|
&mut self,
|
||||||
type_id: String,
|
type_id: String,
|
||||||
as_name: Option<String>,
|
as_name: Option<String>,
|
||||||
|
args: Vec<(String, String)>,
|
||||||
bind: Vec<(String, Scalar)>,
|
bind: Vec<(String, Scalar)>,
|
||||||
) -> Result<(), OpError> {
|
) -> Result<(), OpError> {
|
||||||
let mut builder =
|
let mut builder =
|
||||||
(self.vocab)(&type_id).ok_or_else(|| OpError::UnknownNodeType(type_id.clone()))?;
|
(self.vocab)(&type_id).ok_or_else(|| OpError::UnknownNodeType(type_id.clone()))?;
|
||||||
let id = as_name.unwrap_or_else(|| builder.node_name());
|
let id = as_name.unwrap_or_else(|| builder.node_name());
|
||||||
|
// Construction args apply BEFORE the bind loop (spec §aura-engine): a
|
||||||
|
// pending arg-bearing builder with no args therefore refuses here as
|
||||||
|
// `MissingArg` — no pending builder ever reaches `try_bind`/enters the
|
||||||
|
// graph.
|
||||||
|
builder = builder.try_args(&args).map_err(|err| OpError::BadArg { node: id.clone(), err })?;
|
||||||
for (slot, value) in bind {
|
for (slot, value) in bind {
|
||||||
builder = builder
|
builder = builder
|
||||||
.try_bind(&slot, value)
|
.try_bind(&slot, value)
|
||||||
@@ -564,7 +581,7 @@ pub fn replay(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{GraphSession, Op, OpError};
|
use super::{GraphSession, Op, OpError};
|
||||||
use crate::blueprint::{BlueprintNode, Composite, Role};
|
use crate::blueprint::{BlueprintNode, Composite, Role};
|
||||||
use aura_core::{BindOpError, Scalar, ScalarKind};
|
use aura_core::{ArgKind, ArgOpError, BindOpError, Scalar, ScalarKind};
|
||||||
use aura_vocabulary::std_vocabulary;
|
use aura_vocabulary::std_vocabulary;
|
||||||
|
|
||||||
fn session(name: &str) -> GraphSession<'static> {
|
fn session(name: &str) -> GraphSession<'static> {
|
||||||
@@ -575,7 +592,7 @@ mod tests {
|
|||||||
fn add_unknown_type_rejected_at_op() {
|
fn add_unknown_type_rejected_at_op() {
|
||||||
let mut s = session("g");
|
let mut s = session("g");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
s.apply(Op::Add { type_id: "Nope".into(), as_name: None, bind: vec![] }),
|
s.apply(Op::Add { type_id: "Nope".into(), as_name: None, args: vec![], bind: vec![] }),
|
||||||
Err(OpError::UnknownNodeType("Nope".into()))
|
Err(OpError::UnknownNodeType("Nope".into()))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -583,9 +600,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn add_duplicate_identifier_rejected() {
|
fn add_duplicate_identifier_rejected() {
|
||||||
let mut s = session("g");
|
let mut s = session("g");
|
||||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("x".into()), bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("x".into()), args: vec![], bind: vec![] }).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("x".into()), bind: vec![] }),
|
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("x".into()), args: vec![], bind: vec![] }),
|
||||||
Err(OpError::DuplicateIdentifier("x".into()))
|
Err(OpError::DuplicateIdentifier("x".into()))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -597,7 +614,7 @@ mod tests {
|
|||||||
s.apply(Op::Add {
|
s.apply(Op::Add {
|
||||||
type_id: "SMA".into(),
|
type_id: "SMA".into(),
|
||||||
as_name: Some("fast".into()),
|
as_name: Some("fast".into()),
|
||||||
bind: vec![("length".into(), Scalar::f64(2.0))], // wrong kind (i64 param)
|
args: vec![], bind: vec![("length".into(), Scalar::f64(2.0))], // wrong kind (i64 param)
|
||||||
}),
|
}),
|
||||||
Err(OpError::BadParam {
|
Err(OpError::BadParam {
|
||||||
node: "fast".into(),
|
node: "fast".into(),
|
||||||
@@ -624,9 +641,9 @@ mod tests {
|
|||||||
fn scaffold() -> GraphSession<'static> {
|
fn scaffold() -> GraphSession<'static> {
|
||||||
let mut s = session("g");
|
let mut s = session("g");
|
||||||
s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }).unwrap();
|
s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }).unwrap();
|
||||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] }).unwrap();
|
||||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] }).unwrap();
|
||||||
s.apply(Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] }).unwrap();
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,8 +670,8 @@ mod tests {
|
|||||||
fn connect_kind_mismatch_rejected_at_op() {
|
fn connect_kind_mismatch_rejected_at_op() {
|
||||||
// Gt produces bool; Bias.signal consumes f64 -> eager kind mismatch
|
// Gt produces bool; Bias.signal consumes f64 -> eager kind mismatch
|
||||||
let mut s = session("g");
|
let mut s = session("g");
|
||||||
s.apply(Op::Add { type_id: "Gt".into(), as_name: None, bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "Gt".into(), as_name: None, args: vec![], bind: vec![] }).unwrap();
|
||||||
s.apply(Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "Bias".into(), as_name: None, args: vec![], bind: vec![] }).unwrap();
|
||||||
let r = s.apply(Op::Connect { from: "gt.value".into(), to: "bias.signal".into() });
|
let r = s.apply(Op::Connect { from: "gt.value".into(), to: "bias.signal".into() });
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
r,
|
r,
|
||||||
@@ -820,9 +837,9 @@ mod tests {
|
|||||||
use crate::harness::FlatTap;
|
use crate::harness::FlatTap;
|
||||||
let ops = vec![
|
let ops = vec![
|
||||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
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("fast".into()), args: vec![], bind: vec![] },
|
||||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), 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, bind: vec![] },
|
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
|
||||||
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] },
|
Op::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: "fast.value".into(), to: "sub.lhs".into() },
|
||||||
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
|
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
|
||||||
@@ -842,10 +859,10 @@ mod tests {
|
|||||||
fn gang_op_projects_the_param_space() {
|
fn gang_op_projects_the_param_space() {
|
||||||
let ops = vec![
|
let ops = vec![
|
||||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
||||||
Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] },
|
Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] },
|
||||||
Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), bind: vec![] },
|
Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), args: vec![], bind: vec![] },
|
||||||
Op::Gang { as_name: "length".into(), into: vec!["a.length".into(), "b.length".into()] },
|
Op::Gang { as_name: "length".into(), into: vec!["a.length".into(), "b.length".into()] },
|
||||||
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
|
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
|
||||||
Op::Feed { role: "price".into(), into: vec!["a.series".into(), "b.series".into()] },
|
Op::Feed { role: "price".into(), into: vec!["a.series".into(), "b.series".into()] },
|
||||||
Op::Connect { from: "a.value".into(), to: "sub.lhs".into() },
|
Op::Connect { from: "a.value".into(), to: "sub.lhs".into() },
|
||||||
Op::Connect { from: "b.value".into(), to: "sub.rhs".into() },
|
Op::Connect { from: "b.value".into(), to: "sub.rhs".into() },
|
||||||
@@ -863,7 +880,7 @@ mod tests {
|
|||||||
/// i64` session-index output, wires its `trigger` input through the ordinary
|
/// i64` session-index output, wires its `trigger` input through the ordinary
|
||||||
/// edge convention, and builds to a finished `Composite`. Reachability is the
|
/// edge convention, and builds to a finished `Composite`. Reachability is the
|
||||||
/// whole ask — the node's tz-aware/DST session SEMANTICS are already pinned
|
/// whole ask — the node's tz-aware/DST session SEMANTICS are already pinned
|
||||||
/// in `aura-std/src/session.rs` and are not re-pinned here. Today the preset
|
/// in `aura-market/src/session.rs` and are not re-pinned here. Today the preset
|
||||||
/// id is absent from the roster (the base `Session` builder takes structural
|
/// id is absent from the roster (the base `Session` builder takes structural
|
||||||
/// construction args, so the zero-arg macro cannot host it), so the lookup
|
/// construction args, so the zero-arg macro cannot host it), so the lookup
|
||||||
/// returns `None` and the op-script's `Add` would fail `UnknownNodeType`.
|
/// returns `None` and the op-script's `Add` would fail `UnknownNodeType`.
|
||||||
@@ -892,7 +909,7 @@ mod tests {
|
|||||||
Op::Add {
|
Op::Add {
|
||||||
type_id: "SessionFrankfurt".into(),
|
type_id: "SessionFrankfurt".into(),
|
||||||
as_name: Some("session".into()),
|
as_name: Some("session".into()),
|
||||||
bind: vec![("period_minutes".into(), Scalar::i64(15))],
|
args: vec![], bind: vec![("period_minutes".into(), Scalar::i64(15))],
|
||||||
},
|
},
|
||||||
Op::Feed { role: "trigger".into(), into: vec!["session.trigger".into()] },
|
Op::Feed { role: "trigger".into(), into: vec!["session.trigger".into()] },
|
||||||
Op::Expose { from: "session.bars_since_open".into(), as_name: "bars".into() },
|
Op::Expose { from: "session.bars_since_open".into(), as_name: "bars".into() },
|
||||||
@@ -909,11 +926,11 @@ mod tests {
|
|||||||
// bound member: `b.length` was bound at Add, so it left the open param
|
// bound member: `b.length` was bound at Add, so it left the open param
|
||||||
// list and is unknown to the gang gate, exactly as `try_bind` would see it.
|
// list and is unknown to the gang gate, exactly as `try_bind` would see it.
|
||||||
let mut s = session("g");
|
let mut s = session("g");
|
||||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] }).unwrap();
|
||||||
s.apply(Op::Add {
|
s.apply(Op::Add {
|
||||||
type_id: "SMA".into(),
|
type_id: "SMA".into(),
|
||||||
as_name: Some("b".into()),
|
as_name: Some("b".into()),
|
||||||
bind: vec![("length".into(), Scalar::i64(5))],
|
args: vec![], bind: vec![("length".into(), Scalar::i64(5))],
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -923,8 +940,8 @@ mod tests {
|
|||||||
|
|
||||||
// kind mismatch: SMA.length is i64, Bias.scale is f64.
|
// kind mismatch: SMA.length is i64, Bias.scale is f64.
|
||||||
let mut s = session("g");
|
let mut s = session("g");
|
||||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] }).unwrap();
|
||||||
s.apply(Op::Add { type_id: "Bias".into(), as_name: Some("b".into()), bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "Bias".into(), as_name: Some("b".into()), args: vec![], bind: vec![] }).unwrap();
|
||||||
let r = s.apply(Op::Gang { as_name: "g".into(), into: vec!["a.length".into(), "b.scale".into()] });
|
let r = s.apply(Op::Gang { as_name: "g".into(), into: vec!["a.length".into(), "b.scale".into()] });
|
||||||
assert!(
|
assert!(
|
||||||
matches!(r, Err(OpError::GangKindMismatch { expected: ScalarKind::I64, got: ScalarKind::F64, .. })),
|
matches!(r, Err(OpError::GangKindMismatch { expected: ScalarKind::I64, got: ScalarKind::F64, .. })),
|
||||||
@@ -933,9 +950,9 @@ mod tests {
|
|||||||
|
|
||||||
// doubly-ganged member: a.length is already claimed by an earlier gang.
|
// doubly-ganged member: a.length is already claimed by an earlier gang.
|
||||||
let mut s = session("g");
|
let mut s = session("g");
|
||||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] }).unwrap();
|
||||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), args: vec![], bind: vec![] }).unwrap();
|
||||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("c".into()), bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("c".into()), args: vec![], bind: vec![] }).unwrap();
|
||||||
s.apply(Op::Gang { as_name: "lo".into(), into: vec!["a.length".into(), "b.length".into()] }).unwrap();
|
s.apply(Op::Gang { as_name: "lo".into(), into: vec!["a.length".into(), "b.length".into()] }).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
s.apply(Op::Gang { as_name: "hi".into(), into: vec!["a.length".into(), "c.length".into()] }),
|
s.apply(Op::Gang { as_name: "hi".into(), into: vec!["a.length".into(), "c.length".into()] }),
|
||||||
@@ -944,7 +961,7 @@ mod tests {
|
|||||||
|
|
||||||
// single member: a gang needs at least two.
|
// single member: a gang needs at least two.
|
||||||
let mut s = session("g");
|
let mut s = session("g");
|
||||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] }).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
s.apply(Op::Gang { as_name: "solo".into(), into: vec!["a.length".into()] }),
|
s.apply(Op::Gang { as_name: "solo".into(), into: vec!["a.length".into()] }),
|
||||||
Err(OpError::GangArity { gang: "solo".into() })
|
Err(OpError::GangArity { gang: "solo".into() })
|
||||||
@@ -952,7 +969,7 @@ mod tests {
|
|||||||
|
|
||||||
// unknown node: a member names a node that was never added.
|
// unknown node: a member names a node that was never added.
|
||||||
let mut s = session("g");
|
let mut s = session("g");
|
||||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] }).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
s.apply(Op::Gang { as_name: "g".into(), into: vec!["a.length".into(), "ghost.length".into()] }),
|
s.apply(Op::Gang { as_name: "g".into(), into: vec!["a.length".into(), "ghost.length".into()] }),
|
||||||
Err(OpError::UnknownIdentifier("ghost".into()))
|
Err(OpError::UnknownIdentifier("ghost".into()))
|
||||||
@@ -1010,7 +1027,7 @@ mod tests {
|
|||||||
fn finish_accepts_an_open_input_role_and_compile_refuses_it() {
|
fn finish_accepts_an_open_input_role_and_compile_refuses_it() {
|
||||||
let mut s = session("g");
|
let mut s = session("g");
|
||||||
s.apply(Op::Input { role: "ext".into() }).unwrap();
|
s.apply(Op::Input { role: "ext".into() }).unwrap();
|
||||||
s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), args: vec![], bind: vec![] }).unwrap();
|
||||||
s.apply(Op::Feed { role: "ext".into(), into: vec!["sub.lhs".into(), "sub.rhs".into()] }).unwrap();
|
s.apply(Op::Feed { role: "ext".into(), into: vec!["sub.lhs".into(), "sub.rhs".into()] }).unwrap();
|
||||||
s.apply(Op::Expose { from: "sub.value".into(), as_name: "out".into() }).unwrap();
|
s.apply(Op::Expose { from: "sub.value".into(), as_name: "out".into() }).unwrap();
|
||||||
let c = s.finish().expect("finish() no longer gates root-role boundness");
|
let c = s.finish().expect("finish() no longer gates root-role boundness");
|
||||||
@@ -1030,8 +1047,8 @@ mod tests {
|
|||||||
fn finish_reports_role_kind_mismatch_by_identifier() {
|
fn finish_reports_role_kind_mismatch_by_identifier() {
|
||||||
let mut s = session("g");
|
let mut s = session("g");
|
||||||
s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }).unwrap();
|
s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }).unwrap();
|
||||||
s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), args: vec![], bind: vec![] }).unwrap();
|
||||||
s.apply(Op::Add { type_id: "And".into(), as_name: Some("conj".into()), bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "And".into(), as_name: Some("conj".into()), args: vec![], bind: vec![] }).unwrap();
|
||||||
// price fans into sub.lhs (f64) AND conj.a (bool): differing slot kinds.
|
// price fans into sub.lhs (f64) AND conj.a (bool): differing slot kinds.
|
||||||
s.apply(Op::Feed { role: "price".into(), into: vec!["sub.lhs".into(), "conj.a".into()] }).unwrap();
|
s.apply(Op::Feed { role: "price".into(), into: vec!["sub.lhs".into(), "conj.a".into()] }).unwrap();
|
||||||
let err = s.finish().err().unwrap();
|
let err = s.finish().err().unwrap();
|
||||||
@@ -1065,8 +1082,8 @@ mod tests {
|
|||||||
// — only an acyclicity check can catch it.
|
// — only an acyclicity check can catch it.
|
||||||
let ops = vec![
|
let ops = vec![
|
||||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
||||||
Op::Add { type_id: "Sub".into(), as_name: Some("a".into()), bind: vec![] },
|
Op::Add { type_id: "Sub".into(), as_name: Some("a".into()), args: vec![], bind: vec![] },
|
||||||
Op::Add { type_id: "Sub".into(), as_name: Some("b".into()), bind: vec![] },
|
Op::Add { type_id: "Sub".into(), as_name: Some("b".into()), args: vec![], bind: vec![] },
|
||||||
Op::Feed { role: "price".into(), into: vec!["a.rhs".into(), "b.rhs".into()] },
|
Op::Feed { role: "price".into(), into: vec!["a.rhs".into(), "b.rhs".into()] },
|
||||||
Op::Connect { from: "a.value".into(), to: "b.lhs".into() }, // op 4: a -> b
|
Op::Connect { from: "a.value".into(), to: "b.lhs".into() }, // op 4: a -> b
|
||||||
Op::Connect { from: "b.value".into(), to: "a.lhs".into() }, // op 5: closes b -> a
|
Op::Connect { from: "b.value".into(), to: "a.lhs".into() }, // op 5: closes b -> a
|
||||||
@@ -1097,7 +1114,7 @@ mod tests {
|
|||||||
// loop. Both slots covered, role bound — only an acyclicity check catches it.
|
// loop. Both slots covered, role bound — only an acyclicity check catches it.
|
||||||
let ops = vec![
|
let ops = vec![
|
||||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
||||||
Op::Add { type_id: "Sub".into(), as_name: Some("s".into()), bind: vec![] },
|
Op::Add { type_id: "Sub".into(), as_name: Some("s".into()), args: vec![], bind: vec![] },
|
||||||
Op::Feed { role: "price".into(), into: vec!["s.rhs".into()] },
|
Op::Feed { role: "price".into(), into: vec!["s.rhs".into()] },
|
||||||
Op::Connect { from: "s.value".into(), to: "s.lhs".into() }, // self-edge
|
Op::Connect { from: "s.value".into(), to: "s.lhs".into() }, // self-edge
|
||||||
Op::Expose { from: "s.value".into(), as_name: "out".into() },
|
Op::Expose { from: "s.value".into(), as_name: "out".into() },
|
||||||
@@ -1111,9 +1128,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_stops_at_first_failing_op_naming_index() {
|
fn replay_stops_at_first_failing_op_naming_index() {
|
||||||
let ops = vec![
|
let ops = vec![
|
||||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] },
|
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), args: vec![], bind: vec![] },
|
||||||
Op::Add { type_id: "Nope".into(), as_name: None, bind: vec![] }, // op 1 fails
|
Op::Add { type_id: "Nope".into(), as_name: None, args: vec![], bind: vec![] }, // op 1 fails
|
||||||
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
|
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
|
||||||
];
|
];
|
||||||
// `.err().unwrap()` rather than `.unwrap_err()`: the Ok arm `Composite` is
|
// `.err().unwrap()` rather than `.unwrap_err()`: the Ok arm `Composite` is
|
||||||
// not `Debug` (it holds build closures), which `unwrap_err` would require.
|
// not `Debug` (it holds build closures), which `unwrap_err` would require.
|
||||||
@@ -1141,10 +1158,10 @@ mod tests {
|
|||||||
// (a) op-script replay
|
// (a) op-script replay
|
||||||
let ops = vec![
|
let ops = vec![
|
||||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
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("fast".into()), args: vec![], bind: vec![] },
|
||||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), 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, bind: vec![] },
|
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
|
||||||
Op::Add { type_id: "Bias".into(), as_name: None, 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::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: "fast.value".into(), to: "sub.lhs".into() },
|
||||||
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
|
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
|
||||||
@@ -1189,10 +1206,10 @@ mod tests {
|
|||||||
// (a) op-script: the Task-4 fixture (two SMA + gang + Sub + expose).
|
// (a) op-script: the Task-4 fixture (two SMA + gang + Sub + expose).
|
||||||
let ops = vec![
|
let ops = vec![
|
||||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
||||||
Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] },
|
Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), args: vec![], bind: vec![] },
|
||||||
Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), bind: vec![] },
|
Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), args: vec![], bind: vec![] },
|
||||||
Op::Gang { as_name: "length".into(), into: vec!["a.length".into(), "b.length".into()] },
|
Op::Gang { as_name: "length".into(), into: vec!["a.length".into(), "b.length".into()] },
|
||||||
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
|
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
|
||||||
Op::Feed { role: "price".into(), into: vec!["a.series".into(), "b.series".into()] },
|
Op::Feed { role: "price".into(), into: vec!["a.series".into(), "b.series".into()] },
|
||||||
Op::Connect { from: "a.value".into(), to: "sub.lhs".into() },
|
Op::Connect { from: "a.value".into(), to: "sub.lhs".into() },
|
||||||
Op::Connect { from: "b.value".into(), to: "sub.rhs".into() },
|
Op::Connect { from: "b.value".into(), to: "sub.rhs".into() },
|
||||||
@@ -1241,9 +1258,9 @@ mod tests {
|
|||||||
// (a) op-script: an SMA crossover with a tap on the raw spread (sub.value).
|
// (a) op-script: an SMA crossover with a tap on the raw spread (sub.value).
|
||||||
let ops = vec![
|
let ops = vec![
|
||||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
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("fast".into()), args: vec![], bind: vec![] },
|
||||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] },
|
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), args: vec![], bind: vec![] },
|
||||||
Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), bind: vec![] },
|
Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), args: vec![], bind: vec![] },
|
||||||
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] },
|
Op::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: "fast.value".into(), to: "sub.lhs".into() },
|
||||||
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
|
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
|
||||||
@@ -1277,6 +1294,71 @@ mod tests {
|
|||||||
assert_eq!(replay_flat.edges, built_flat.edges);
|
assert_eq!(replay_flat.edges, built_flat.edges);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- construction args (#271) -------------------------------------
|
||||||
|
|
||||||
|
/// `add_node` applies `try_args` AFTER resolve and BEFORE the bind loop
|
||||||
|
/// (spec §aura-engine): an arg-bearing type's `add` op, given a valid
|
||||||
|
/// `args` set, configures the real signature and the subsequent `bind`
|
||||||
|
/// sees the now-real `period_minutes` param — a data-only op-script
|
||||||
|
/// reaches a `Session` for any IANA timezone/open, not just the
|
||||||
|
/// `SessionFrankfurt` preset.
|
||||||
|
#[test]
|
||||||
|
fn add_op_with_args_builds_a_session() {
|
||||||
|
let mut s = session("g");
|
||||||
|
s.apply(Op::Source { role: "trigger".into(), kind: ScalarKind::F64 }).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
s.apply(Op::Add {
|
||||||
|
type_id: "Session".into(),
|
||||||
|
as_name: Some("ny".into()),
|
||||||
|
args: vec![("tz".into(), "America/New_York".into()), ("open".into(), "09:30".into())],
|
||||||
|
bind: vec![("period_minutes".into(), Scalar::i64(15))],
|
||||||
|
}),
|
||||||
|
Ok(()),
|
||||||
|
);
|
||||||
|
s.apply(Op::Feed { role: "trigger".into(), into: vec!["ny.trigger".into()] }).unwrap();
|
||||||
|
s.apply(Op::Expose { from: "ny.bars_since_open".into(), as_name: "bars".into() }).unwrap();
|
||||||
|
s.finish().expect("an args-configured Session finishes and builds");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Session` is arg-bearing (declares `tz`/`open`): an `add` op naming it
|
||||||
|
/// with NO `args` refuses at the op as `BadArg(MissingArg(..))` — the
|
||||||
|
/// pending builder never reaches the bind loop or the graph (spec
|
||||||
|
/// §aura-engine: "a pending resolve with no args therefore fails as
|
||||||
|
/// MissingArg").
|
||||||
|
#[test]
|
||||||
|
fn add_op_arg_bearing_type_without_args_refuses() {
|
||||||
|
let mut s = session("g");
|
||||||
|
assert_eq!(
|
||||||
|
s.apply(Op::Add {
|
||||||
|
type_id: "Session".into(),
|
||||||
|
as_name: Some("ny".into()),
|
||||||
|
args: vec![],
|
||||||
|
bind: vec![],
|
||||||
|
}),
|
||||||
|
Err(OpError::BadArg { node: "ny".into(), err: ArgOpError::MissingArg("tz".into()) }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A malformed arg value (strict-form refusal, spec §Closedness) surfaces
|
||||||
|
/// as `BadArg(BadValue{..})`, naming the arg, its declared `ArgKind`, and
|
||||||
|
/// the rejected string.
|
||||||
|
#[test]
|
||||||
|
fn add_op_bad_tz_refuses() {
|
||||||
|
let mut s = session("g");
|
||||||
|
assert_eq!(
|
||||||
|
s.apply(Op::Add {
|
||||||
|
type_id: "Session".into(),
|
||||||
|
as_name: Some("ny".into()),
|
||||||
|
args: vec![("tz".into(), "not_a_zone".into()), ("open".into(), "09:30".into())],
|
||||||
|
bind: vec![],
|
||||||
|
}),
|
||||||
|
Err(OpError::BadArg {
|
||||||
|
node: "ny".into(),
|
||||||
|
err: ArgOpError::BadValue { arg: "tz".into(), kind: ArgKind::Tz, got: "not_a_zone".into() },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Op::Use (#317) -----------------------------------------------
|
// ---- Op::Use (#317) -----------------------------------------------
|
||||||
|
|
||||||
/// A small two-node fixture composite for the `Op::Use` tests (#317): an
|
/// A small two-node fixture composite for the `Op::Use` tests (#317): an
|
||||||
@@ -1412,7 +1494,7 @@ mod tests {
|
|||||||
fn use_op_duplicate_instance_identifier_rejected() {
|
fn use_op_duplicate_instance_identifier_rejected() {
|
||||||
let subgraph = |_: &str| Some(use_fixture());
|
let subgraph = |_: &str| Some(use_fixture());
|
||||||
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
|
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
|
||||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("gate".into()), bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("gate".into()), args: vec![], bind: vec![] }).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] }),
|
s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] }),
|
||||||
Err(OpError::DuplicateIdentifier("gate".into()))
|
Err(OpError::DuplicateIdentifier("gate".into()))
|
||||||
@@ -1448,13 +1530,13 @@ mod tests {
|
|||||||
fn use_gang_fixture() -> Composite {
|
fn use_gang_fixture() -> Composite {
|
||||||
let ops = vec![
|
let ops = vec![
|
||||||
Op::Input { role: "price".into() },
|
Op::Input { role: "price".into() },
|
||||||
Op::Add { type_id: "SMA".into(), as_name: Some("channel_hi".into()), bind: vec![] },
|
Op::Add { type_id: "SMA".into(), as_name: Some("channel_hi".into()), args: vec![], bind: vec![] },
|
||||||
Op::Add { type_id: "SMA".into(), as_name: Some("channel_lo".into()), bind: vec![] },
|
Op::Add { type_id: "SMA".into(), as_name: Some("channel_lo".into()), args: vec![], bind: vec![] },
|
||||||
Op::Gang {
|
Op::Gang {
|
||||||
as_name: "channel_length".into(),
|
as_name: "channel_length".into(),
|
||||||
into: vec!["channel_hi.length".into(), "channel_lo.length".into()],
|
into: vec!["channel_hi.length".into(), "channel_lo.length".into()],
|
||||||
},
|
},
|
||||||
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
|
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
|
||||||
Op::Feed {
|
Op::Feed {
|
||||||
role: "price".into(),
|
role: "price".into(),
|
||||||
into: vec!["channel_hi.series".into(), "channel_lo.series".into()],
|
into: vec!["channel_hi.series".into(), "channel_lo.series".into()],
|
||||||
@@ -1505,7 +1587,7 @@ mod tests {
|
|||||||
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
|
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
|
||||||
s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] })
|
s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] })
|
||||||
.unwrap();
|
.unwrap();
|
||||||
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("solo".into()), bind: vec![] })
|
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("solo".into()), args: vec![], bind: vec![] })
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
s.apply(Op::Gang {
|
s.apply(Op::Gang {
|
||||||
@@ -1527,7 +1609,7 @@ mod tests {
|
|||||||
fn use_op_connect_closing_a_cycle_through_an_instance_is_rejected_eagerly() {
|
fn use_op_connect_closing_a_cycle_through_an_instance_is_rejected_eagerly() {
|
||||||
let subgraph = |_: &str| Some(use_fixture());
|
let subgraph = |_: &str| Some(use_fixture());
|
||||||
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
|
let mut s = GraphSession::new("g", &std_vocabulary, &subgraph);
|
||||||
s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("s".into()), bind: vec![] }).unwrap();
|
s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("s".into()), args: vec![], bind: vec![] }).unwrap();
|
||||||
s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] }).unwrap();
|
s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] }).unwrap();
|
||||||
s.apply(Op::Connect { from: "s.value".into(), to: "gate.price".into() }).unwrap();
|
s.apply(Op::Connect { from: "s.value".into(), to: "gate.price".into() }).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1595,7 +1677,7 @@ mod tests {
|
|||||||
Op::Add {
|
Op::Add {
|
||||||
type_id: "SMA".into(),
|
type_id: "SMA".into(),
|
||||||
as_name: Some("sma".into()),
|
as_name: Some("sma".into()),
|
||||||
bind: vec![("length".into(), Scalar::i64(3))],
|
args: vec![], bind: vec![("length".into(), Scalar::i64(3))],
|
||||||
},
|
},
|
||||||
Op::Feed { role: "x".into(), into: vec!["sma.series".into()] },
|
Op::Feed { role: "x".into(), into: vec!["sma.series".into()] },
|
||||||
Op::Expose { from: "sma.value".into(), as_name: "out".into() },
|
Op::Expose { from: "sma.value".into(), as_name: "out".into() },
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ pub use blueprint::{
|
|||||||
GangMember, OutField, RandomBinder, ReopenError, Role, SweepBinder, Tap, TapWire,
|
GangMember, OutField, RandomBinder, ReopenError, Role, SweepBinder, Tap, TapWire,
|
||||||
};
|
};
|
||||||
pub use blueprint_serde::{
|
pub use blueprint_serde::{
|
||||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintDoc, CompositeData,
|
blueprint_from_json, blueprint_identity_json, blueprint_to_json, ArgData, BlueprintDoc,
|
||||||
LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
|
CompositeData, LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
|
||||||
};
|
};
|
||||||
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
|
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
|
||||||
pub use construction::{replay, GraphSession, Op, OpError};
|
pub use construction::{replay, GraphSession, Op, OpError};
|
||||||
@@ -88,7 +88,8 @@ pub use walkforward::{
|
|||||||
// (SourceSpec.kind is a ScalarKind; sources/Recorder columns are Scalar /
|
// (SourceSpec.kind is a ScalarKind; sources/Recorder columns are Scalar /
|
||||||
// Firing / Timestamp) so a graph builder has one import surface, not two.
|
// Firing / Timestamp) so a graph builder has one import surface, not two.
|
||||||
pub use aura_core::{
|
pub use aura_core::{
|
||||||
BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind, Timestamp,
|
ArgKind, ArgOpError, BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind,
|
||||||
|
Timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ fn params() -> [Scalar; 3] {
|
|||||||
fn signal_ops() -> Vec<Op> {
|
fn signal_ops() -> Vec<Op> {
|
||||||
vec![
|
vec![
|
||||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
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("fast".into()), args: vec![], bind: vec![] },
|
||||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), 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, bind: vec![] },
|
Op::Add { type_id: "Sub".into(), as_name: None, args: vec![], bind: vec![] },
|
||||||
Op::Add { type_id: "Bias".into(), as_name: None, 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::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: "fast.value".into(), to: "sub.lhs".into() },
|
||||||
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
|
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
|
||||||
@@ -96,10 +96,10 @@ fn replayed_construction_compiles_identically_to_graphbuilder() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_attributes_eager_fault_to_its_op_index() {
|
fn replay_attributes_eager_fault_to_its_op_index() {
|
||||||
let ops = vec![
|
let ops = vec![
|
||||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, // 0 ok
|
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, bind: vec![] }, // 1 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, bind: vec![] }, // 2 fails
|
Op::Add { type_id: "Nope".into(), as_name: None, args: vec![], bind: vec![] }, // 2 fails
|
||||||
Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] }, // 3 never runs
|
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
|
// `.err().unwrap()` (not `unwrap_err`): the Ok arm `Composite` holds build
|
||||||
// closures and is not `Debug`, which `unwrap_err`'s bound would require.
|
// closures and is not `Debug`, which `unwrap_err`'s bound would require.
|
||||||
@@ -120,9 +120,9 @@ fn replay_attributes_eager_fault_to_its_op_index() {
|
|||||||
fn replay_attributes_holistic_fault_to_finalize_step() {
|
fn replay_attributes_holistic_fault_to_finalize_step() {
|
||||||
let ops = vec![
|
let ops = vec![
|
||||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 }, // 0
|
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("fast".into()), args: vec![], bind: vec![] }, // 1
|
||||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }, // 2
|
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, bind: vec![] }, // 3
|
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::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }, // 4
|
||||||
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }, // 5
|
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }, // 5
|
||||||
// sub.rhs deliberately left unwired -> only finalize can decide totality.
|
// sub.rhs deliberately left unwired -> only finalize can decide totality.
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ fn build_harness(
|
|||||||
Resample::builder().schema().clone(), // 0
|
Resample::builder().schema().clone(), // 0
|
||||||
Delay::builder().schema().clone(), // 1
|
Delay::builder().schema().clone(), // 1
|
||||||
Gt::builder().schema().clone(), // 2
|
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(), // 4
|
||||||
EqConst::builder().schema().clone(), // 5
|
EqConst::builder().schema().clone(), // 5
|
||||||
And::builder().schema().clone(), // 6
|
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 sq = g.add(Mul::builder()); // dev * dev
|
||||||
let var = g.add(Ema::builder().bind("length", Scalar::i64(n))); // EWMA variance
|
let var = g.add(Ema::builder().bind("length", Scalar::i64(n))); // EWMA variance
|
||||||
let sigma = g.add(Sqrt::builder());
|
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 upper = g.add(Add::builder()); // mean + k*sigma
|
||||||
let lower = g.add(Sub::builder()); // mean - k*sigma
|
let lower = g.add(Sub::builder()); // mean - k*sigma
|
||||||
let gt_hi = g.add(Gt::builder()); // price > upper
|
let gt_hi = g.add(Gt::builder()); // price > upper
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ pub fn build_harness() -> (Harness, Taps) {
|
|||||||
Resample::builder().schema().clone(), // 0
|
Resample::builder().schema().clone(), // 0
|
||||||
Delay::builder().schema().clone(), // 1
|
Delay::builder().schema().clone(), // 1
|
||||||
Gt::builder().schema().clone(), // 2
|
Gt::builder().schema().clone(), // 2
|
||||||
Session::builder(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES)
|
Session::configured(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES)
|
||||||
.schema()
|
.schema()
|
||||||
.clone(), // 3
|
.clone(), // 3
|
||||||
EqConst::builder().schema().clone(), // 4
|
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 delay = g.add(Delay::builder().bind("lag", Scalar::i64(1)));
|
||||||
let gt = g.add(Gt::builder());
|
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
|
// The ONLY two params: the EqConst targets, named so the space reads
|
||||||
// `entry_bar.target` / `exit_bar.target`. Their `target` is left UNBOUND.
|
// `entry_bar.target` / `exit_bar.target`. Their `target` is left UNBOUND.
|
||||||
let entry_bar = g.add(EqConst::builder().named("entry_bar"));
|
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`.
|
//! **Cold guard.** If the trigger column is empty (not yet fired) → `None`.
|
||||||
|
|
||||||
use aura_core::{
|
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};
|
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
|
/// Frankfurt-session bar indexer. Holds the open offset (minutes past local
|
||||||
/// midnight), the IANA timezone, and the bar period — baked at construction,
|
/// midnight), the IANA timezone, and the bar period — baked at construction,
|
||||||
/// never streamed and never a swept param (a timezone is not a scalar).
|
/// 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,
|
/// Roster factory: zero-arg, arg-bearing (spec §Concrete code shapes) — the
|
||||||
/// timezone, and period are **structural** config baked into the closure
|
/// timezone and open time come through the `args` channel (`try_args`),
|
||||||
/// (like `SimBroker::builder` bakes `pip_size`), NOT scalar params — the
|
/// declared by [`SESSION_ARGS`]. `period_minutes` becomes an ordinary
|
||||||
/// declared param list is empty and the `build_fn` ignores its slice (like
|
/// `ParamSpec` once `make` runs (a real scalar knob, unlike tz/open).
|
||||||
/// `Gt` / `Latch`).
|
pub fn builder() -> PrimitiveBuilder {
|
||||||
pub fn builder(open_hour: u32, open_minute: u32, tz: chrono_tz::Tz, period_minutes: i64) -> 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(
|
PrimitiveBuilder::new(
|
||||||
"Session",
|
"Session",
|
||||||
NodeSchema {
|
NodeSchema {
|
||||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }],
|
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "trigger".into() }],
|
||||||
output: vec![FieldSpec { name: "bars_since_open".into(), kind: ScalarKind::I64 }],
|
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",
|
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
|
/// The zero-arg `SessionFrankfurt` roster preset (#261): the Frankfurt open
|
||||||
@@ -244,4 +285,36 @@ mod tests {
|
|||||||
let inputs = trigger_input();
|
let inputs = trigger_input();
|
||||||
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2154,14 +2154,12 @@ mod tests {
|
|||||||
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
|
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
|
||||||
|
|
||||||
// A minimal fixture composite with one OPEN param, built from a
|
// 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,
|
// resolve it on load. `aura-composites`' shipped composites (vol_stop,
|
||||||
// risk_executor*) all route through `LinComb`, whose builder needs a
|
// risk_executor*) route through `LinComb`, an arg-bearing type (#271,
|
||||||
// structural arity argument and is therefore deliberately absent from
|
// `aura-vocabulary/src/lib.rs`) — using `Bias` here keeps this fixture
|
||||||
// the zero-arg `std_vocabulary` roster (see aura-std/src/vocabulary.rs)
|
// independent of that construction channel, so it stands in as the
|
||||||
// — they cannot round-trip through `blueprint_from_json` with this
|
// "real, open-param, vocabulary-loadable composite" the test needs.
|
||||||
// resolver, so this fixture stands in as the "real, open-param,
|
|
||||||
// vocabulary-loadable composite" the test needs.
|
|
||||||
let composite = Composite::new(
|
let composite = Composite::new(
|
||||||
"fixture",
|
"fixture",
|
||||||
vec![aura_strategy::Bias::builder().named("b").into()],
|
vec![aura_strategy::Bias::builder().named("b").into()],
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ pub fn wrap_r(
|
|||||||
if !reduce {
|
if !reduce {
|
||||||
// r_equity = cum_realized_r + unrealized_r — one tapped series for charting.
|
// r_equity = cum_realized_r + unrealized_r — one tapped series for charting.
|
||||||
let r_equity = g.add(
|
let r_equity = g.add(
|
||||||
LinComb::builder(2)
|
LinComb::configured(2)
|
||||||
.bind("weights[0]", Scalar::f64(1.0))
|
.bind("weights[0]", Scalar::f64(1.0))
|
||||||
.bind("weights[1]", 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
|
// 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).
|
// − Σopen_cost_in_r (the #221-deleted LinComb(4), weights 1,1,-1,-1).
|
||||||
let net_eq = g.add(
|
let net_eq = g.add(
|
||||||
LinComb::builder(4)
|
LinComb::configured(4)
|
||||||
.bind("weights[0]", Scalar::f64(1.0))
|
.bind("weights[0]", Scalar::f64(1.0))
|
||||||
.bind("weights[1]", Scalar::f64(1.0))
|
.bind("weights[1]", Scalar::f64(1.0))
|
||||||
.bind("weights[2]", Scalar::f64(-1.0))
|
.bind("weights[2]", Scalar::f64(-1.0))
|
||||||
|
|||||||
@@ -7,13 +7,6 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
aura-core = { path = "../aura-core" }
|
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]
|
[dev-dependencies]
|
||||||
aura-strategy = { path = "../aura-strategy" }
|
aura-strategy = { path = "../aura-strategy" }
|
||||||
|
|||||||
@@ -7,10 +7,15 @@
|
|||||||
//! indexed `F64` knobs that `Composite::param_space` aggregates (C8/C12/C19).
|
//! indexed `F64` knobs that `Composite::param_space` aggregates (C8/C12/C19).
|
||||||
|
|
||||||
use aura_core::{
|
use aura_core::{
|
||||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
ArgKind, ArgSpec, ArgValue, Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
|
||||||
ScalarKind,
|
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
|
/// Weighted sum of `N` f64 inputs: `Σ weights[i] · input[i]`. The `weights` are
|
||||||
/// construction parameters that configure the node and fix its arity
|
/// construction parameters that configure the node and fix its arity
|
||||||
/// (`weights.len()` inputs, in slot order). Emits `None` until *all* inputs
|
/// (`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)] }
|
Self { weights, out: [Cell::from_f64(0.0)] }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The param-generic recipe for a blueprint primitive. The `arity` is topology
|
/// Roster factory: zero-arg, arg-bearing. `arity` is topology (fixed per
|
||||||
/// (fixed per blueprint, C19), taken as a builder arg; only the weight *values*
|
/// blueprint, C19), now taken through the `args` channel instead of a
|
||||||
/// are injected, slot by slot, through `LinComb::new` (the single sizing gate).
|
/// Rust-side parameter.
|
||||||
pub fn builder(arity: usize) -> PrimitiveBuilder {
|
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)
|
let inputs = (0..arity)
|
||||||
.map(|i| PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: format!("term[{i}]") })
|
.map(|i| PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: format!("term[{i}]") })
|
||||||
.collect();
|
.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 {
|
impl Node for LinComb {
|
||||||
@@ -149,7 +180,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn input_slots_are_named_term_index() {
|
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();
|
let names: Vec<String> = lc.schema().inputs.iter().map(|p| p.name.clone()).collect();
|
||||||
assert_eq!(names, ["term[0]", "term[1]", "term[2]"]);
|
assert_eq!(names, ["term[0]", "term[1]", "term[2]"]);
|
||||||
}
|
}
|
||||||
@@ -157,7 +188,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn chained_bind_reconstructs_positional_vector() {
|
fn chained_bind_reconstructs_positional_vector() {
|
||||||
// bind BOTH weights, in reverse slot order, to DISTINCT values; build empty.
|
// 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[1]", Scalar::f64(2.0))
|
||||||
.bind("weights[0]", Scalar::f64(0.5));
|
.bind("weights[0]", Scalar::f64(0.5));
|
||||||
assert!(builder.params().is_empty());
|
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()));
|
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
|
// 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!(
|
assert_eq!(
|
||||||
partial.params().iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
|
partial.params().iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
|
||||||
["weights[1]"],
|
["weights[1]"],
|
||||||
@@ -187,4 +218,14 @@ mod tests {
|
|||||||
inputs2[1].push(Scalar::f64(3.0)).unwrap();
|
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()));
|
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 }],
|
vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
|
||||||
);
|
);
|
||||||
// vector knob expands flat to N indexed F64 entries
|
// 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.len(), 2);
|
||||||
assert_eq!(lc[0].name, "weights[0]");
|
assert_eq!(lc[0].name, "weights[0]");
|
||||||
assert_eq!(lc[1].name, "weights[1]");
|
assert_eq!(lc[1].name, "weights[1]");
|
||||||
|
|||||||
@@ -555,10 +555,10 @@ mod tests {
|
|||||||
ConstantCost::builder().schema().output.iter().map(|f| f.name.clone()).collect();
|
ConstantCost::builder().schema().output.iter().map(|f| f.name.clone()).collect();
|
||||||
assert_eq!(prod, COST_FIELD_NAMES.to_vec());
|
assert_eq!(prod, COST_FIELD_NAMES.to_vec());
|
||||||
let agg_out: Vec<String> =
|
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());
|
assert_eq!(agg_out, COST_FIELD_NAMES.to_vec());
|
||||||
let agg_in: Vec<String> =
|
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> =
|
let expected: Vec<String> =
|
||||||
COST_FIELD_NAMES.iter().map(|f| format!("cost[0].{f}")).collect();
|
COST_FIELD_NAMES.iter().map(|f| format!("cost[0].{f}")).collect();
|
||||||
assert_eq!(agg_in, expected);
|
assert_eq!(agg_in, expected);
|
||||||
|
|||||||
@@ -7,9 +7,15 @@
|
|||||||
//! the cost path is uniform whether one or several cost nodes are wired.
|
//! the cost path is uniform whether one or several cost nodes are wired.
|
||||||
|
|
||||||
use aura_core::{
|
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
|
/// 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
|
/// (`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
|
/// 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] }
|
Self { n_costs, out: [Cell::from_f64(0.0); COST_WIDTH] }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The param-generic recipe. `n_costs` is topology (fixed per blueprint, C19),
|
/// Roster factory: zero-arg, arg-bearing. `n_costs` is topology (fixed per
|
||||||
/// captured by the build closure (no per-build params). The input names are a
|
/// blueprint, C19), now taken through the `args` channel instead of a
|
||||||
/// lockstep contract with the connect side (`cost[k].<field>`).
|
/// Rust-side parameter.
|
||||||
pub fn builder(n_costs: usize) -> PrimitiveBuilder {
|
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);
|
let mut inputs = Vec::with_capacity(n_costs * COST_WIDTH);
|
||||||
for k in 0..n_costs {
|
for k in 0..n_costs {
|
||||||
for field in COST_FIELD_NAMES {
|
for field in COST_FIELD_NAMES {
|
||||||
@@ -63,6 +88,13 @@ impl CostSum {
|
|||||||
move |_| Box::new(CostSum::new(n_costs)),
|
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 {
|
impl Node for CostSum {
|
||||||
@@ -140,7 +172,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn input_slots_are_named_cost_index_field() {
|
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();
|
let names: Vec<String> = s.schema().inputs.iter().map(|p| p.name.clone()).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
names,
|
names,
|
||||||
@@ -161,4 +193,15 @@ mod tests {
|
|||||||
fn new_panics_on_zero() {
|
fn new_panics_on_zero() {
|
||||||
let _ = CostSum::new(0);
|
let _ = CostSum::new(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Twin identity (spec §aura-strategy): `configured(n_costs)` is exactly
|
||||||
|
/// `builder().try_args([("n_costs", n_costs)])`.
|
||||||
|
#[test]
|
||||||
|
fn cost_sum_configured_twin_equals_builder_try_args() {
|
||||||
|
let via_configured = CostSum::configured(2);
|
||||||
|
let via_try_args =
|
||||||
|
CostSum::builder().try_args(&[("n_costs".into(), "2".into())]).expect("valid n_costs configures");
|
||||||
|
assert_eq!(via_configured.construction_args(), via_try_args.construction_args());
|
||||||
|
assert_eq!(via_configured.schema(), via_try_args.schema());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,22 +8,27 @@
|
|||||||
//! takes it as an injected resolver, and a project `cdylib` later supplies its
|
//! takes it as an injected resolver, and a project `cdylib` later supplies its
|
||||||
//! own (C16) through the same seam.
|
//! own (C16) through the same seam.
|
||||||
//!
|
//!
|
||||||
//! Scope (#155): only the **zero-argument** `Type::builder()` factories are
|
//! Scope (#155, extended #271): the roster resolves any type whose factory is
|
||||||
//! resolvable. Builders that take structural construction arguments
|
//! `Type::builder()` with zero RUST-side arguments — this now includes
|
||||||
//! (`LinComb(arity)`, `CostSum(n_costs)`, `SimBroker(pip_size)`, `Session(..)`)
|
//! arg-bearing types (`Session`, `LinComb`, `CostSum`), whose `builder()` is
|
||||||
//! and the recording sinks (`Recorder`/`GatedRecorder`/`SeriesReducer`, which
|
//! itself zero-arg and returns a *pending* `PrimitiveBuilder`, configured
|
||||||
//! capture an `mpsc::Sender`) are deliberately absent — an absent `type_id`
|
//! 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`)
|
//! yields `None`, so the loader fails cleanly (`LoadError::UnknownNodeType`)
|
||||||
//! rather than guessing. Serialising structural-axis construction args is a
|
//! rather than guessing.
|
||||||
//! later, additive extension (#156/C20).
|
|
||||||
|
|
||||||
use aura_backtest::PositionManagement;
|
use aura_backtest::PositionManagement;
|
||||||
use aura_market::{Resample, SessionFrankfurt};
|
use aura_market::{Resample, Session, SessionFrankfurt};
|
||||||
use aura_std::{
|
use aura_std::{
|
||||||
Abs, Add, And, Const, CumSum, Delay, Div, Ema, EqConst, Gt, Latch, Max, Min, Mul, RollingMax,
|
Abs, Add, And, Const, CumSum, Delay, Div, Ema, EqConst, Gt, Latch, LinComb, Max, Min, Mul,
|
||||||
RollingMin, Scale, Select, Sign, Sma, Sqrt, Sub, When,
|
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;
|
use aura_core::PrimitiveBuilder;
|
||||||
|
|
||||||
/// The single roster source (#160): one `"TypeId" => Type` line per zero-arg
|
/// The single roster source (#160): one `"TypeId" => Type` line per zero-arg
|
||||||
@@ -67,6 +72,7 @@ std_vocabulary_roster! {
|
|||||||
"CarryCost" => CarryCost,
|
"CarryCost" => CarryCost,
|
||||||
"Const" => Const,
|
"Const" => Const,
|
||||||
"ConstantCost" => ConstantCost,
|
"ConstantCost" => ConstantCost,
|
||||||
|
"CostSum" => CostSum,
|
||||||
"CumSum" => CumSum,
|
"CumSum" => CumSum,
|
||||||
"Delay" => Delay,
|
"Delay" => Delay,
|
||||||
"Div" => Div,
|
"Div" => Div,
|
||||||
@@ -75,6 +81,7 @@ std_vocabulary_roster! {
|
|||||||
"FixedStop" => FixedStop,
|
"FixedStop" => FixedStop,
|
||||||
"Gt" => Gt,
|
"Gt" => Gt,
|
||||||
"Latch" => Latch,
|
"Latch" => Latch,
|
||||||
|
"LinComb" => LinComb,
|
||||||
"LongOnly" => LongOnly,
|
"LongOnly" => LongOnly,
|
||||||
"Max" => Max,
|
"Max" => Max,
|
||||||
"Min" => Min,
|
"Min" => Min,
|
||||||
@@ -85,6 +92,7 @@ std_vocabulary_roster! {
|
|||||||
"RollingMin" => RollingMin,
|
"RollingMin" => RollingMin,
|
||||||
"Scale" => Scale,
|
"Scale" => Scale,
|
||||||
"Select" => Select,
|
"Select" => Select,
|
||||||
|
"Session" => Session,
|
||||||
"SessionFrankfurt" => SessionFrankfurt,
|
"SessionFrankfurt" => SessionFrankfurt,
|
||||||
"Sign" => Sign,
|
"Sign" => Sign,
|
||||||
"Sizer" => Sizer,
|
"Sizer" => Sizer,
|
||||||
@@ -103,8 +111,11 @@ mod tests {
|
|||||||
/// label back, and only the rostered keys do. The builder's own `label()`
|
/// 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
|
/// 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
|
/// agreement even though the match arms and the list share the same
|
||||||
/// (mistyped) literal; construction-arg nodes and sinks stay absent so the
|
/// (mistyped) literal; an unresolvable id and a sink stay absent so the
|
||||||
/// loader fails cleanly rather than guessing.
|
/// 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]
|
#[test]
|
||||||
fn std_vocabulary_resolves_known_and_rejects_unknown() {
|
fn std_vocabulary_resolves_known_and_rejects_unknown() {
|
||||||
for &type_id in std_vocabulary_types() {
|
for &type_id in std_vocabulary_types() {
|
||||||
@@ -116,9 +127,9 @@ mod tests {
|
|||||||
"resolver key must round-trip to the same type label"
|
"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("nope").is_none());
|
||||||
assert!(std_vocabulary("LinComb").is_none());
|
assert!(std_vocabulary("LinComb").is_some());
|
||||||
assert!(std_vocabulary("Recorder").is_none());
|
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
|
/// act. The residual #160 drift — a new zero-arg node never rostered at
|
||||||
/// all — is not catchable here (no enumeration of zero-arg builders
|
/// all — is not catchable here (no enumeration of zero-arg builders
|
||||||
/// exists); it fails safe (clean `UnknownNodeType` on load, merely absent
|
/// 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]
|
#[test]
|
||||||
fn std_vocabulary_types_lists_exactly_the_resolvable_keys() {
|
fn std_vocabulary_types_lists_exactly_the_resolvable_keys() {
|
||||||
// known non-members stay out of the list
|
// 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(&"Recorder")); // sink
|
||||||
assert!(!std_vocabulary_types().contains(&"nope"));
|
assert!(!std_vocabulary_types().contains(&"nope"));
|
||||||
// count guard: pins the roster at exactly 33 entries
|
// count guard: pins the roster at exactly 36 entries
|
||||||
assert_eq!(std_vocabulary_types().len(), 33);
|
assert_eq!(std_vocabulary_types().len(), 36);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-7
@@ -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)
|
`src/lib.rs`, registered under the project's own `<namespace>::` prefix)
|
||||||
and appends its path to the project's `Aura.toml [nodes]` section.
|
and appends its path to the project's `Aura.toml [nodes]` section.
|
||||||
- **A block promoted to universal** — reused across projects and folded into
|
- **A block promoted to universal** — reused across projects and folded into
|
||||||
the engine itself — lives in `crates/aura-std/src/`, unprefixed, and is
|
the engine itself — lives in `crates/aura-std/` (or its sibling domain
|
||||||
rostered in `crates/aura-std/src/vocabulary.rs` instead of a node crate's
|
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
|
`vocabulary()` function. The pattern below is identical either way; only
|
||||||
the rostering call site differs (see "Rostering the type" below).
|
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
|
`aura_core::aura_project!` macro wires up (every `aura new` scaffold
|
||||||
emits a starter pair — see the worked example below). Add one match arm
|
emits a starter pair — see the worked example below). Add one match arm
|
||||||
to `vocabulary()` and one entry to `type_ids()`'s slice.
|
to `vocabulary()` and one entry to `type_ids()`'s slice.
|
||||||
- **Std-side** (only when promoting a node into `aura-std` itself): one
|
- **Std-side** (only when promoting a node into the shipped std
|
||||||
line in the `std_vocabulary_roster!` macro invocation in
|
vocabulary): one line in the `std_vocabulary_roster!` macro invocation in
|
||||||
[`crates/aura-std/src/vocabulary.rs`](../crates/aura-std/src/vocabulary.rs)
|
[`crates/aura-vocabulary/src/lib.rs`](../crates/aura-vocabulary/src/lib.rs)
|
||||||
— `"TypeId" => Type,` — which expands into both the resolver `match` and
|
— `"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
|
An unrostered type fails safe either way: the loader refuses with a clean
|
||||||
`LoadError::UnknownNodeType` naming the missing id, and the type is simply
|
`LoadError::UnknownNodeType` naming the missing id, and the type is simply
|
||||||
absent from `aura graph introspect --vocabulary` — never a silent partial
|
absent from `aura graph introspect --vocabulary` — never a silent partial
|
||||||
@@ -228,7 +234,7 @@ are dotted `<identifier>.<port>` on both sides of a wire.
|
|||||||
| `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. |
|
| `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"`). |
|
| `source` | `{"op":"source","role":<str>,"kind":<ScalarKind>}` | reserve a bound root **source** role of `kind` — a real input the harness feeds (e.g. `"price"`). |
|
||||||
| `input` | `{"op":"input","role":<str>}` | reserve an open root **input** role (kind inferred from the slots it feeds) — the formal parameter of an **open pattern**, a fragment meant to be wired by an *enclosing* graph. An open pattern builds and registers like any blueprint. Running it standalone is governed by the ordinary run gates: the harness binds input roles to archive columns **by name** (C26), so a pattern whose roles match the data runs as-is; what refuses, by name, is a role the harness cannot bind — or the run surface's other gates (a signal without a bias output or tap; free knobs). |
|
| `input` | `{"op":"input","role":<str>}` | reserve an open root **input** role (kind inferred from the slots it feeds) — the formal parameter of an **open pattern**, a fragment meant to be wired by an *enclosing* graph. An open pattern builds and registers like any blueprint. Running it standalone is governed by the ordinary run gates: the harness binds input roles to archive columns **by name** (C26), so a pattern whose roles match the data runs as-is; what refuses, by name, is a role the harness cannot bind — or the run surface's other gates (a signal without a bias output or tap; free knobs). |
|
||||||
| `add` | `{"op":"add","type":<TypeId>,"name":<str>?,"bind":{<param>:<Scalar>}?}` | instantiate a node of a type in the closed vocabulary (`aura graph introspect --vocabulary`) — see §0 below for how a type gets into that vocabulary in the first place. `name` becomes the node's identifier for later ops (default: the type's own lowercase label — two unnamed nodes of the same type then collide). `bind` sets zero or more of its params. |
|
| `add` | `{"op":"add","type":<TypeId>,"name":<str>?,"args":{<arg>:<str>}?,"bind":{<param>:<Scalar>}?}` | instantiate a node of a type in the closed vocabulary (`aura graph introspect --vocabulary`) — see §0 below for how a type gets into that vocabulary in the first place. `name` becomes the node's identifier for later ops (default: the type's own lowercase label — two unnamed nodes of the same type then collide). `args` (#271) configures an **arg-bearing** type's structural, non-scalar construction (`Session`'s timezone/open, `LinComb`/`CostSum`'s arity) — a closed table of string values, applied BEFORE `bind`; `aura graph introspect --node <T>` lists a type's declared `arg` rows. `bind` sets zero or more of the (now real) params. |
|
||||||
| `use` | `{"op":"use","ref":{"content_id":<id-or-prefix>}\|{"name":<label>},"name":<str>?,"bind":{<path>:<Scalar>}?}` | splice a **registered blueprint** into the graph as a nested composite under an instance identifier (`name`; default: the stored composite's own name). The ref resolves by content id (full or unique prefix) or by a register-time label; `graph build` echoes every resolution (`aura: note: use "…": … -> <id>`) so the exact id can be pinned. The instance's open input roles wire as `<instance>.<role>`, its outputs as `<instance>.<field>`; its open params surface path-qualified (`<instance>.<node>.<param>`), sweepable (ganging an instance's params is not yet supported — the `gang` op refuses a composite instance's member path). A doc-less stored source refuses at the op (C29). The emitted blueprint contains the subgraph *inline* — no reference survives into the artifact. |
|
| `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). |
|
| `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). |
|
| `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). |
|
||||||
@@ -342,6 +348,30 @@ Wire it like any node —
|
|||||||
`{"op":"add","type":"SessionFrankfurt","bind":{"period_minutes":{"I64":15}}}`,
|
`{"op":"add","type":"SessionFrankfurt","bind":{"period_minutes":{"I64":15}}}`,
|
||||||
feed its `trigger` from a once-per-bar stream, and read `bars_since_open`.
|
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
|
### Commands
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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 — `bars_since_open` is the shipped contract (#154).** The
|
||||||
session-context node `Session` (`crates/aura-market/src/session.rs`) emits
|
session-context node `Session` (`crates/aura-market/src/session.rs`) emits
|
||||||
**one** scalar stream, `bars_since_open: i64` (tz-aware, DST-correct, over a
|
**one** scalar stream, `bars_since_open: i64` (tz-aware, DST-correct), over a
|
||||||
baked Frankfurt open), and takes no scalar params; the zero-arg
|
timezone/open time that is now data-authorable via the typed construction-args
|
||||||
`SessionFrankfurt` roster preset (#261) exposes it with a single
|
channel (`Session` roster-reaches `std_vocabulary` as an arg-bearing type,
|
||||||
`period_minutes` knob. This is a deliberate narrowing pinned in the node's own
|
#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)`
|
contract: `bars_since_open` alone is the gate — a downstream `EqConst(== N)`
|
||||||
subsumes an `in_session: bool` stream (pre-open instants read `<= 0`, which
|
subsumes an `in_session: bool` stream (pre-open instants read `<= 0`, which
|
||||||
never match), and nothing consumes a `session_open_ts: timestamp`. The two
|
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`):
|
**Out of the round-trippable set** (deliberate; fails clean as `UnknownNodeType`):
|
||||||
recording sinks (they capture an `mpsc::Sender` — runtime identity, not param-generic
|
recording sinks (they capture an `mpsc::Sender` — runtime identity, not param-generic
|
||||||
data, C19) and construction-arg builders (`LinComb` / `CostSum` / `SimBroker` /
|
data, C19) and `SimBroker` (a scalar-typed baked value, a narrower gap than the args
|
||||||
`Session` — structural-axis args, a C20 concern), additively addable later (#156).
|
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
|
### Runs and families are built FROM blueprint-data
|
||||||
|
|
||||||
@@ -197,10 +199,17 @@ replayed in order; nodes are referenced **by identifier**, ports as dotted
|
|||||||
from the slots it feeds) — a reusable **pattern**'s formal parameter, left open
|
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
|
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.
|
op-script that never binds it, only `compile`/bootstrap require it bound.
|
||||||
- `add` — `{"op":"add","type":<TypeId>,"name":<str>?,"bind":{<param>:<Scalar>}?}` —
|
- `add` — `{"op":"add","type":<TypeId>,"name":<str>?,"args":{<arg>:<str>}?,"bind":{<param>:<Scalar>}?}` —
|
||||||
add a node of compiled-in type identity `type`; **`name` is its identifier**
|
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
|
(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
|
- `feed` — `{"op":"feed","role":<str>,"into":[<port>,…]}` — fan a root role into
|
||||||
interior input slots.
|
interior input slots.
|
||||||
- `connect` — `{"op":"connect","from":<port>,"to":<port>}` — wire an interior output
|
- `connect` — `{"op":"connect","from":<port>,"to":<port>}` — wire an interior output
|
||||||
@@ -262,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:
|
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
|
a new node type, edge semantics, or structural-axis kind) **bumps** `format_version` so
|
||||||
an old reader refuses cleanly (`LoadError::UnsupportedVersion` / `UnknownNodeType`).
|
an old reader refuses cleanly (`LoadError::UnsupportedVersion` / `UnknownNodeType`).
|
||||||
**Pre-ship dormancy (#61):** until the first external ship there are no out-of-repo
|
**Pre-ship dormancy (#61, ended by #271):** until the first external ship there were
|
||||||
readers — reader and writer change atomically in one commit — so the Tier-2 bump
|
no out-of-repo readers — reader and writer changed atomically in one commit — so the
|
||||||
discipline is dormant and structurally-semantic additions (the `gangs` section) land as
|
Tier-2 bump discipline lay dormant and structurally-semantic additions (the `gangs`
|
||||||
additive-optional fields of v1; the first ship consciously freezes v1, gangs included,
|
section) landed as additive-optional fields of v1. #271's construction args are the
|
||||||
and activates the bump discipline. The per-section required-flag scheme is deferred (no
|
first exercised Tier-2 bump (data-driven, next paragraph); v1 remains the version of
|
||||||
current Tier-2 section to validate it; recorded on #156).
|
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
|
### Enforcement shift — invariant 9 on the data plane
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -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
|
### atomic sim unit
|
||||||
**Avoid:** atomic unit, 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.
|
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.
|
||||||
@@ -67,6 +71,10 @@ The type-erased 64-bit word holding one scalar-base-type value with its kind str
|
|||||||
**Avoid:** —
|
**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).
|
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
|
### content id
|
||||||
**Avoid:** —
|
**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`.
|
The SHA-256 (hex) of an artifact's byte-canonical form — for a blueprint, `blueprint_to_json` (the byte-exact identity that keys the reproduction store and anchors `aura reproduce`, stamped into a run manifest as the `topology hash`); for a `process document` / `campaign document`, its canonical JSON (keying the sibling registry stores). One primitive, library-hosted in `aura-research` (`content_id_of`; the CLI delegates). Surfaced as `aura graph|process|campaign introspect --content-id`; two artifacts share a content id only when their canonical bytes agree, debug names included — contrast `identity id`.
|
||||||
@@ -263,7 +271,7 @@ The four streamed scalar kinds — `i64`, `f64`, `bool`, `timestamp` (a newtype
|
|||||||
|
|
||||||
### session node
|
### session node
|
||||||
**Avoid:** session window
|
**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
|
### sign-agreement
|
||||||
**Avoid:** sign consistency, market breadth
|
**Avoid:** sign consistency, market breadth
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user