feat(aura-cli): aura graph renders a wired graph as an ASCII DAG (#13)
Make a wired graph introspectable so a mis-wiring becomes visible. Two
selectable views, both authored from one built-in sample blueprint:
aura graph clustered blueprint view — composites drawn as named
ascii-dag cluster boxes (pre-inline, C9)
aura graph --compiled flat compilat view — composite boundaries dissolved
(C23); the graph the run loop actually runs
The payoff is intrinsic, param-carrying node labels: two SMAs read SMA(2) /
SMA(4), so a swapped fast/slow input reads back differently. This is realized
by a `label()` default method on the core Node trait — a non-load-bearing
render symbol the run loop never reads (wiring is by index), the symbol C23
already reserves citing #13. Recorded as a C8 refinement in the ledger.
Rejected the alternatives in brainstorm: a separate Describe trait (forces a
combined trait-object dance for a label the ledger calls non-load-bearing) and
extrinsic parallel labels (decoupled from the node, so an author can label the
slow SMA "fast" and mask the very bug the render exists to surface).
Mechanism:
- aura-core: Node::label() default method (additive, object-safe, single-line).
- aura-std: per-node overrides — SMA(n)/Exposure(s)/SimBroker(p) carry params;
Sub/Add/LinComb/Recorder are bare (their identity is not a mis-wiring axis).
- aura-engine: Composite gains an authored `name` (cluster title; dissolves at
inline) + read-only graph-as-data accessors on Blueprint/Composite. No
external dependency — the engine stays dependency-pure (C16); ascii-dag lives
only in aura-cli. The label-free index-wired compilat (C23) is unchanged.
- aura-cli: ascii-dag v0.9.1 + a graph.rs adapter (Vertical mode; labels
materialized into an owned Vec<String> that outlives the borrow-based Graph).
`aura run` is untouched (the sample duplication is dedup idea #14).
Tests: a concern-defining test (swapped sample renders != correct), structure
pins (cluster present in blueprint view, absent in compiled view), per-node
label disambiguation, and two frozen byte-goldens of the deterministic render.
The cycle-0012 bit-identical and run-sample non-regression tests stay green.
Verified by the orchestrator (not the agent report): cargo build --workspace
clean; cargo test --workspace all green; cargo clippy --workspace --all-targets
-D warnings clean; both views run live and render as expected.
closes #13
This commit is contained in:
Generated
+7
@@ -46,10 +46,17 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ascii-dag"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd898d2dcffeeb51015c511a2849a8637c894b833afc946936d48b1e5c8aa6ec"
|
||||
|
||||
[[package]]
|
||||
name = "aura-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ascii-dag",
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-std",
|
||||
|
||||
@@ -13,3 +13,4 @@ path = "src/main.rs"
|
||||
aura-core = { path = "../aura-core" }
|
||||
aura-engine = { path = "../aura-engine" }
|
||||
aura-std = { path = "../aura-std" }
|
||||
ascii-dag = "0.9.1"
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! The `aura graph` ASCII-DAG adapter (#13): turns the engine's graph-as-data
|
||||
//! (C9) into an `ascii_dag::Graph` rendered to a `String`. Two views:
|
||||
//! `render_blueprint` draws composites as named cluster boxes (pre-inline);
|
||||
//! `render_compilat` draws the flat post-inline graph (boundaries dissolved,
|
||||
//! C23). Rendering reads structure + node `label()`s only — never `eval`.
|
||||
//!
|
||||
//! ascii-dag borrows its node/subgraph labels as `&'a str`, so each function
|
||||
//! first materializes the owned label `String`s (which outlive the `Graph`),
|
||||
//! then borrows into them. `RenderMode::Vertical` is mandatory: Horizontal
|
||||
//! collapses a fan-out onto one path.
|
||||
|
||||
use ascii_dag::graph::{Graph, RenderMode};
|
||||
use aura_core::Node;
|
||||
use aura_engine::{Blueprint, BlueprintNode, Edge, SourceSpec, Target};
|
||||
|
||||
/// How one top-level blueprint item maps into display nodes (blueprint view).
|
||||
enum ItemDisplay {
|
||||
/// A leaf is one display node at this id.
|
||||
Leaf(usize),
|
||||
/// A composite is a cluster of interior leaf display ids; its output port and
|
||||
/// input roles resolve edges crossing its boundary.
|
||||
Composite {
|
||||
interior_ids: Vec<usize>,
|
||||
output_interior: usize,
|
||||
input_roles: Vec<Vec<Target>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// The display id an edge *from* this item originates at (its producer).
|
||||
fn producer_id(d: &ItemDisplay) -> usize {
|
||||
match d {
|
||||
ItemDisplay::Leaf(id) => *id,
|
||||
ItemDisplay::Composite { interior_ids, output_interior, .. } => interior_ids[*output_interior],
|
||||
}
|
||||
}
|
||||
|
||||
/// The display id(s) an edge *into* this item at `slot` reaches (its consumers).
|
||||
/// A composite input role fans into several interior targets.
|
||||
fn consumer_ids(d: &ItemDisplay, slot: usize) -> Vec<usize> {
|
||||
match d {
|
||||
ItemDisplay::Leaf(id) => vec![*id],
|
||||
ItemDisplay::Composite { interior_ids, input_roles, .. } => {
|
||||
input_roles[slot].iter().map(|t| interior_ids[t.node]).collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blueprint view: composites become labelled cluster boxes (pre-inline, C9).
|
||||
pub fn render_blueprint(bp: &Blueprint) -> String {
|
||||
let mut labels: Vec<String> = Vec::new();
|
||||
let mut sg_names: Vec<String> = Vec::new();
|
||||
let mut memberships: Vec<(usize, Vec<usize>)> = Vec::new();
|
||||
let mut edges: Vec<(usize, usize)> = Vec::new();
|
||||
let mut item_display: Vec<ItemDisplay> = Vec::with_capacity(bp.nodes().len());
|
||||
|
||||
// pass 1: assign display ids + labels; open a subgraph per composite; record
|
||||
// interior edges (the interior ids are in hand here).
|
||||
for item in bp.nodes() {
|
||||
match item {
|
||||
BlueprintNode::Leaf(node) => {
|
||||
let id = labels.len();
|
||||
labels.push(node.label());
|
||||
item_display.push(ItemDisplay::Leaf(id));
|
||||
}
|
||||
BlueprintNode::Composite(c) => {
|
||||
let mut interior_ids = Vec::with_capacity(c.nodes().len());
|
||||
for inner in c.nodes() {
|
||||
match inner {
|
||||
BlueprintNode::Leaf(node) => {
|
||||
let id = labels.len();
|
||||
labels.push(node.label());
|
||||
interior_ids.push(id);
|
||||
}
|
||||
BlueprintNode::Composite(_) => unimplemented!(
|
||||
"cycle 0013 renders leaf-interior composites (the built-in \
|
||||
sample); nested-composite cluster rendering is a follow-up"
|
||||
),
|
||||
}
|
||||
}
|
||||
for e in c.edges() {
|
||||
edges.push((interior_ids[e.from], interior_ids[e.to]));
|
||||
}
|
||||
let sg = sg_names.len();
|
||||
sg_names.push(c.name().to_string());
|
||||
memberships.push((sg, interior_ids.clone()));
|
||||
item_display.push(ItemDisplay::Composite {
|
||||
interior_ids,
|
||||
output_interior: c.output().node,
|
||||
input_roles: c.input_roles().to_vec(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sources as producer display nodes (unnamed in the data model -> label by kind)
|
||||
let mut source_ids: Vec<usize> = Vec::with_capacity(bp.sources().len());
|
||||
for src in bp.sources() {
|
||||
let id = labels.len();
|
||||
labels.push(format!("source:{:?}", src.kind));
|
||||
source_ids.push(id);
|
||||
}
|
||||
|
||||
// top-level edges, resolved through composite boundaries
|
||||
for e in bp.edges() {
|
||||
let from = producer_id(&item_display[e.from]);
|
||||
for to in consumer_ids(&item_display[e.to], e.slot) {
|
||||
edges.push((from, to));
|
||||
}
|
||||
}
|
||||
// source -> target edges
|
||||
for (src, &sid) in bp.sources().iter().zip(&source_ids) {
|
||||
for t in &src.targets {
|
||||
for to in consumer_ids(&item_display[t.node], t.slot) {
|
||||
edges.push((sid, to));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// build the borrowed-label Graph (labels + sg_names are final & owned)
|
||||
let mut g = Graph::with_mode(RenderMode::Vertical);
|
||||
for (id, l) in labels.iter().enumerate() {
|
||||
g.add_node(id, l);
|
||||
}
|
||||
let sg_ids: Vec<usize> = sg_names.iter().map(|n| g.add_subgraph(n)).collect();
|
||||
for (sg, members) in &memberships {
|
||||
g.put_nodes(members).inside(sg_ids[*sg]).expect("valid subgraph placement");
|
||||
}
|
||||
for (from, to) in edges {
|
||||
g.add_edge(from, to, None);
|
||||
}
|
||||
g.render()
|
||||
}
|
||||
|
||||
/// Compiled view: the flat post-inline graph (no clusters; boundaries dissolved,
|
||||
/// C23). Each `Box<dyn Node>` labels itself; node display id = node index.
|
||||
pub fn render_compilat(nodes: &[Box<dyn Node>], sources: &[SourceSpec], edges: &[Edge]) -> String {
|
||||
let mut labels: Vec<String> = nodes.iter().map(|n| n.label()).collect();
|
||||
let source_base = labels.len();
|
||||
for src in sources {
|
||||
labels.push(format!("source:{:?}", src.kind));
|
||||
}
|
||||
|
||||
let mut g = Graph::with_mode(RenderMode::Vertical);
|
||||
for (id, l) in labels.iter().enumerate() {
|
||||
g.add_node(id, l);
|
||||
}
|
||||
for e in edges {
|
||||
g.add_edge(e.from, e.to, None);
|
||||
}
|
||||
for (i, src) in sources.iter().enumerate() {
|
||||
for t in &src.targets {
|
||||
g.add_edge(source_base + i, t.node, None);
|
||||
}
|
||||
}
|
||||
g.render()
|
||||
}
|
||||
+167
-3
@@ -6,9 +6,12 @@
|
||||
//! recording sinks), runs it deterministically (C1), and prints the run's
|
||||
//! metrics + manifest (#6) as canonical JSON to stdout (the headline C14 move).
|
||||
|
||||
mod graph;
|
||||
|
||||
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{
|
||||
f64_field, summarize, Edge, Harness, RunManifest, RunReport, SourceSpec, Target,
|
||||
f64_field, summarize, Blueprint, BlueprintNode, Composite, Edge, Harness, OutPort,
|
||||
RunManifest, RunReport, SourceSpec, Target,
|
||||
};
|
||||
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
@@ -111,15 +114,79 @@ fn run_sample() -> RunReport {
|
||||
}
|
||||
}
|
||||
|
||||
/// The SMA-cross signal as a named composite (price -> fast/slow SMA -> spread).
|
||||
/// CLI-local sample builder; the engine ships no sample (the duplication with
|
||||
/// `blueprint.rs`'s test helper is the dedup tracked in #14).
|
||||
fn sma_cross(name: &str, fast: usize, slow: usize) -> Composite {
|
||||
Composite::new(
|
||||
name,
|
||||
vec![Sma::new(fast).into(), Sma::new(slow).into(), Sub::new().into()],
|
||||
vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
||||
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
||||
],
|
||||
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
|
||||
OutPort { node: 2, field: 0 },
|
||||
)
|
||||
}
|
||||
|
||||
/// The sample signal-quality blueprint, parameterized by the SMA windows so a
|
||||
/// test can author a deliberately swapped variant. Recorders need a channel to
|
||||
/// construct; the receivers are dropped because the render never runs the graph.
|
||||
fn build_sample(fast: usize, slow: usize) -> Blueprint {
|
||||
let (tx_eq, _rx_eq) = mpsc::channel();
|
||||
let (tx_ex, _rx_ex) = mpsc::channel();
|
||||
Blueprint::new(
|
||||
vec![
|
||||
BlueprintNode::Composite(sma_cross("sma_cross", fast, slow)),
|
||||
Exposure::new(0.5).into(),
|
||||
SimBroker::new(0.0001).into(),
|
||||
Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq).into(),
|
||||
Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex).into(),
|
||||
],
|
||||
vec![SourceSpec {
|
||||
kind: ScalarKind::F64,
|
||||
targets: vec![
|
||||
Target { node: 0, slot: 0 }, // price -> sma_cross role 0
|
||||
Target { node: 2, slot: 1 }, // price -> SimBroker price slot
|
||||
],
|
||||
}],
|
||||
vec![
|
||||
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // spread -> Exposure
|
||||
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> broker slot 0
|
||||
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity -> sink
|
||||
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure -> sink
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// The built-in sample rendered by `aura graph`.
|
||||
fn sample_blueprint() -> Blueprint {
|
||||
build_sample(2, 4)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
match args.next().as_deref() {
|
||||
// strict: a bare `run` proceeds; a trailing token falls through to the
|
||||
// usage-error path rather than masquerading as a successful run (#16).
|
||||
Some("run") if args.next().is_none() => println!("{}", run_sample().to_json()),
|
||||
Some("--help") | Some("-h") => println!("usage: aura run"),
|
||||
Some("graph") => {
|
||||
// `--compiled` selects the flat post-inline view; default is the
|
||||
// clustered blueprint view. Strictness beyond this stays minimal (#16).
|
||||
let compiled = args.next().as_deref() == Some("--compiled");
|
||||
let bp = sample_blueprint();
|
||||
let out = if compiled {
|
||||
let (nodes, sources, edges) = bp.compile().expect("valid sample blueprint");
|
||||
graph::render_compilat(&nodes, &sources, &edges)
|
||||
} else {
|
||||
graph::render_blueprint(&bp)
|
||||
};
|
||||
println!("{out}");
|
||||
}
|
||||
Some("--help") | Some("-h") => println!("usage: aura run | aura graph [--compiled]"),
|
||||
_ => {
|
||||
eprintln!("aura: usage: aura run");
|
||||
eprintln!("aura: usage: aura run | aura graph [--compiled]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
@@ -129,6 +196,103 @@ fn main() {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The sample authored with fast/slow SMA windows swapped — the mis-wiring
|
||||
/// the render must surface. Test-only: nothing outside tests builds it.
|
||||
fn sample_blueprint_swapped() -> Blueprint {
|
||||
build_sample(4, 2)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blueprint_view_shows_cluster_and_param_labels() {
|
||||
let out = graph::render_blueprint(&sample_blueprint());
|
||||
// the composite renders as a named cluster box
|
||||
assert!(out.contains("sma_cross"), "missing composite name:\n{out}");
|
||||
// param-carrying labels disambiguate the two SMAs
|
||||
assert!(out.contains("SMA(2)"), "missing SMA(2):\n{out}");
|
||||
assert!(out.contains("SMA(4)"), "missing SMA(4):\n{out}");
|
||||
for needle in ["Sub", "Exposure(0.5)", "SimBroker(0.0001)", "Recorder"] {
|
||||
assert!(out.contains(needle), "missing {needle}:\n{out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiled_view_dissolves_the_composite_boundary() {
|
||||
let bp = sample_blueprint();
|
||||
let (nodes, sources, edges) = bp.compile().expect("valid sample");
|
||||
let out = graph::render_compilat(&nodes, &sources, &edges);
|
||||
// node labels survive inlining...
|
||||
assert!(out.contains("SMA(2)") && out.contains("SMA(4)"), "labels lost:\n{out}");
|
||||
// ...but the composite cluster name does NOT (boundary dissolved, C23)
|
||||
assert!(!out.contains("sma_cross"), "compiled view must not show the cluster:\n{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swapped_sma_inputs_render_differently() {
|
||||
// the property the cycle exists to buy: a mis-wiring is no longer invisible.
|
||||
let correct = graph::render_blueprint(&sample_blueprint());
|
||||
let swapped = graph::render_blueprint(&sample_blueprint_swapped());
|
||||
assert_ne!(correct, swapped, "a fast/slow SMA swap must change the render");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blueprint_view_golden() {
|
||||
let out = graph::render_blueprint(&sample_blueprint());
|
||||
// ascii-dag's Sugiyama layout is deterministic (no RNG); these are the
|
||||
// exact bytes `aura graph` emits (render() ends in three newlines).
|
||||
let expected = r#" [source:F64]
|
||||
┌─────────└┐────────┐
|
||||
│ │ │
|
||||
╔═════╪══════════╪════╗ │
|
||||
║ sma_cross │ ║ │
|
||||
║ ↓ ↓ ║ │
|
||||
║ [SMA(2)] [SMA(4)] ║ │
|
||||
║ └──┌───────┘ ║ │
|
||||
║ ↓ ┌─╫───┘
|
||||
║ [Sub] │ ║
|
||||
║ │ │ ║
|
||||
╚════════╪══════════╪═╝
|
||||
│ │
|
||||
↓ │
|
||||
[Exposure(0.5)] │
|
||||
┌───────┘────────┐ │
|
||||
↓ ↓─┘
|
||||
[Recorder] [SimBroker(0.0001)]
|
||||
│
|
||||
↓
|
||||
[Recorder]
|
||||
|
||||
|
||||
"#;
|
||||
assert_eq!(out, expected, "blueprint render drifted; re-capture if intended");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiled_view_golden() {
|
||||
let bp = sample_blueprint();
|
||||
let (nodes, sources, edges) = bp.compile().expect("valid sample");
|
||||
let out = graph::render_compilat(&nodes, &sources, &edges);
|
||||
let expected = r#" [source:F64]
|
||||
┌────────└─┐──────┐
|
||||
↓ ↓ │
|
||||
[SMA(2)] [SMA(4)] │
|
||||
└────┌─────┘ │
|
||||
↓ ┌──────┘
|
||||
[Sub] │
|
||||
│ │
|
||||
↓ └────┐
|
||||
[Exposure(0.5)] │
|
||||
┌──────┘─────────┐│
|
||||
↓ ↓┘
|
||||
[Recorder] [SimBroker(0.0001)]
|
||||
┌─────┘
|
||||
↓
|
||||
[Recorder]
|
||||
|
||||
|
||||
"#;
|
||||
assert_eq!(out, expected, "compiled render drifted; re-capture if intended");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_sample_is_deterministic_and_non_trivial() {
|
||||
let r1 = run_sample();
|
||||
|
||||
@@ -65,6 +65,17 @@ pub struct NodeSchema {
|
||||
pub trait Node {
|
||||
fn schema(&self) -> NodeSchema;
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]>;
|
||||
/// A one-line, **non-load-bearing** render label (C23): a debug symbol for
|
||||
/// tracing / graph rendering (#13), never read by the run loop and never
|
||||
/// part of wiring (which is by index). Overrides SHOULD carry the node's
|
||||
/// identifying params so identical node types disambiguate (`SMA(2)` vs
|
||||
/// `SMA(4)`). MUST be single-line (no `\n`): ascii-dag breaks box drawing on
|
||||
/// a multiline label. The default is a placeholder; every shipped node
|
||||
/// overrides it. Returns an owned `String` and takes `&self`, so `Node`
|
||||
/// stays object-safe and `Box<dyn Node>::label()` dispatches.
|
||||
fn label(&self) -> String {
|
||||
"node".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -79,4 +90,19 @@ mod tests {
|
||||
assert_eq!(b.firing, Firing::Barrier(0));
|
||||
assert_ne!(a.firing, b.firing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_label_is_placeholder() {
|
||||
// A node that does not override label() falls back to the placeholder.
|
||||
struct Bare;
|
||||
impl Node for Bare {
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema { inputs: vec![], output: vec![] }
|
||||
}
|
||||
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> {
|
||||
None
|
||||
}
|
||||
}
|
||||
assert_eq!(Bare.label(), "node");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ impl BlueprintNode {
|
||||
/// interior edges (local indices), input roles (role `r` fans into the interior
|
||||
/// targets `input_roles[r]`), and the one exposed output port.
|
||||
pub struct Composite {
|
||||
name: String,
|
||||
nodes: Vec<BlueprintNode>,
|
||||
edges: Vec<Edge>,
|
||||
input_roles: Vec<Vec<Target>>,
|
||||
@@ -59,15 +60,39 @@ pub struct Composite {
|
||||
}
|
||||
|
||||
impl Composite {
|
||||
/// Build a composite from its interior items, interior edges (local indices),
|
||||
/// input roles, and output port.
|
||||
/// Build a composite from its authored name, interior items, interior edges
|
||||
/// (local indices), input roles, and output port. The `name` is a
|
||||
/// non-load-bearing render symbol (the cluster title for #13); it does not
|
||||
/// reach the compilat (the boundary dissolves at inline, C23).
|
||||
pub fn new(
|
||||
name: impl Into<String>,
|
||||
nodes: Vec<BlueprintNode>,
|
||||
edges: Vec<Edge>,
|
||||
input_roles: Vec<Vec<Target>>,
|
||||
output: OutPort,
|
||||
) -> Self {
|
||||
Self { nodes, edges, input_roles, output }
|
||||
Self { name: name.into(), nodes, edges, input_roles, output }
|
||||
}
|
||||
|
||||
/// The authored render name (cluster title, #13). Non-load-bearing.
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
/// The interior blueprint items (read-only graph-as-data, C9).
|
||||
pub fn nodes(&self) -> &[BlueprintNode] {
|
||||
&self.nodes
|
||||
}
|
||||
/// The interior edges (local indices).
|
||||
pub fn edges(&self) -> &[Edge] {
|
||||
&self.edges
|
||||
}
|
||||
/// The input roles: role `r` fans into `input_roles()[r]` interior targets.
|
||||
pub fn input_roles(&self) -> &[Vec<Target>] {
|
||||
&self.input_roles
|
||||
}
|
||||
/// The single exposed output port.
|
||||
pub fn output(&self) -> OutPort {
|
||||
self.output
|
||||
}
|
||||
|
||||
/// The derived interface the enclosing graph wires against: input role `r`'s
|
||||
@@ -119,6 +144,19 @@ impl Blueprint {
|
||||
Self { nodes, sources, edges }
|
||||
}
|
||||
|
||||
/// The top-level blueprint items (read-only graph-as-data, C9).
|
||||
pub fn nodes(&self) -> &[BlueprintNode] {
|
||||
&self.nodes
|
||||
}
|
||||
/// The declared sources.
|
||||
pub fn sources(&self) -> &[SourceSpec] {
|
||||
&self.sources
|
||||
}
|
||||
/// The top-level edges (blueprint-level indices).
|
||||
pub fn edges(&self) -> &[Edge] {
|
||||
&self.edges
|
||||
}
|
||||
|
||||
/// Lower to the flat compilat: inline every composite (recursive), offset
|
||||
/// interior indices, rewrite edges, and fan input roles out. The run loop and
|
||||
/// `bootstrap`'s data model are unchanged; the lowered compilat is wired by raw
|
||||
@@ -201,7 +239,9 @@ fn inline_composite(
|
||||
flat_nodes: &mut Vec<Box<dyn Node>>,
|
||||
flat_edges: &mut Vec<Edge>,
|
||||
) -> Result<ItemLowering, CompileError> {
|
||||
let Composite { nodes, edges, input_roles, output } = c;
|
||||
// `name` is the non-load-bearing render symbol (#13); it dissolves at inline
|
||||
// (C23 — the boundary does not reach the compilat), so it is not destructured.
|
||||
let Composite { name: _, nodes, edges, input_roles, output } = c;
|
||||
let item_count = nodes.len();
|
||||
|
||||
// the output port must name an in-range interior item (field range checked
|
||||
@@ -354,6 +394,7 @@ mod tests {
|
||||
// one interior node (Join2: 2 f64 inputs, 1 f64 output); two roles, each
|
||||
// feeding one interior slot; output port = the Join2 output field 0.
|
||||
let c = Composite::new(
|
||||
"c",
|
||||
vec![BlueprintNode::Leaf(Box::new(Join2 { out: [Scalar::F64(0.0)] }))],
|
||||
vec![],
|
||||
vec![
|
||||
@@ -431,6 +472,7 @@ mod tests {
|
||||
/// the SMA-cross shape.
|
||||
fn fan_composite() -> Composite {
|
||||
Composite::new(
|
||||
"fan",
|
||||
vec![pass1(), pass1(), join2()],
|
||||
vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
||||
@@ -479,6 +521,7 @@ mod tests {
|
||||
// Pass1 slots; the inner Join2 lands at flat index 2.
|
||||
let inner = fan_composite();
|
||||
let outer = Composite::new(
|
||||
"outer",
|
||||
vec![BlueprintNode::Composite(inner)],
|
||||
vec![],
|
||||
vec![vec![Target { node: 0, slot: 0 }]],
|
||||
@@ -511,6 +554,7 @@ mod tests {
|
||||
fn bad_interior_index_rejected() {
|
||||
// interior edge references interior node 9, which does not exist
|
||||
let c = Composite::new(
|
||||
"c",
|
||||
vec![pass1()],
|
||||
vec![Edge { from: 0, to: 9, slot: 0, from_field: 0 }],
|
||||
vec![vec![Target { node: 0, slot: 0 }]],
|
||||
@@ -525,6 +569,7 @@ mod tests {
|
||||
fn role_kind_mismatch_rejected() {
|
||||
// role 0 fans into a Pass1 f64 slot AND a SinkI64 i64 slot -> mismatch
|
||||
let c = Composite::new(
|
||||
"c",
|
||||
vec![pass1(), BlueprintNode::Leaf(Box::new(SinkI64))],
|
||||
vec![],
|
||||
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
|
||||
@@ -539,6 +584,7 @@ mod tests {
|
||||
fn output_port_out_of_range_rejected() {
|
||||
// output names field 5 of a node whose output has one field
|
||||
let c = Composite::new(
|
||||
"c",
|
||||
vec![pass1()],
|
||||
vec![],
|
||||
vec![vec![Target { node: 0, slot: 0 }]],
|
||||
@@ -629,6 +675,7 @@ mod tests {
|
||||
/// output (the fast-minus-slow spread). Interior wired with raw local indices.
|
||||
fn sma_cross(fast: usize, slow: usize) -> Composite {
|
||||
Composite::new(
|
||||
"sma_cross",
|
||||
vec![Sma::new(fast).into(), Sma::new(slow).into(), Sub::new().into()],
|
||||
vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
||||
|
||||
@@ -53,6 +53,10 @@ impl Node for Add {
|
||||
self.out[0] = Scalar::F64(a[0] + b[0]);
|
||||
Some(&self.out)
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
"Add".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -36,6 +36,10 @@ impl Node for Exposure {
|
||||
self.out[0] = Scalar::F64((w[0] / self.scale).clamp(-1.0, 1.0));
|
||||
Some(&self.out)
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
format!("Exposure({})", self.scale)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -63,6 +63,10 @@ impl Node for LinComb {
|
||||
self.out[0] = Scalar::F64(acc);
|
||||
Some(&self.out)
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
"LinComb".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -55,6 +55,10 @@ impl Node for Recorder {
|
||||
let _ = self.tx.send((ctx.now(), row));
|
||||
None
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
"Recorder".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -82,6 +82,10 @@ impl Node for SimBroker {
|
||||
self.out[0] = Scalar::F64(self.cum);
|
||||
Some(&self.out)
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
format!("SimBroker({})", self.pip_size)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -43,6 +43,10 @@ impl Node for Sma {
|
||||
self.out[0] = Scalar::F64(sum / self.length as f64);
|
||||
Some(&self.out)
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
format!("SMA({})", self.length)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -86,4 +90,23 @@ mod tests {
|
||||
inputs[0].push(Scalar::F64(9.0)).unwrap();
|
||||
assert_eq!(sma.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(9.0)].as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_carry_identifying_params() {
|
||||
use crate::{Add, Exposure, LinComb, Recorder, SimBroker, Sub};
|
||||
use aura_core::{Firing, ScalarKind};
|
||||
|
||||
// the load-bearing payoff: two SMAs disambiguate by window
|
||||
assert_eq!(Sma::new(2).label(), "SMA(2)");
|
||||
assert_eq!(Sma::new(4).label(), "SMA(4)");
|
||||
// param-carrying single nodes
|
||||
assert_eq!(Exposure::new(0.5).label(), "Exposure(0.5)");
|
||||
assert_eq!(SimBroker::new(0.0001).label(), "SimBroker(0.0001)");
|
||||
// bare-kind nodes (identity is not a mis-wiring axis here, per spec)
|
||||
assert_eq!(Sub::new().label(), "Sub");
|
||||
assert_eq!(Add::new().label(), "Add");
|
||||
assert_eq!(LinComb::new(vec![1.0, -1.0]).label(), "LinComb");
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
assert_eq!(Recorder::new(&[ScalarKind::F64], Firing::Any, tx).label(), "Recorder");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ impl Node for Sub {
|
||||
self.out[0] = Scalar::F64(a[0] - b[0]);
|
||||
Some(&self.out)
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
"Sub".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -237,6 +237,14 @@ out-of-graph side effect. In-graph routing stays engine-owned data (the edge tab
|
||||
of the graph is the node's own side effect — and that boundary is the
|
||||
determinism / graph-as-data boundary (C1/C7).
|
||||
|
||||
**Refinement (Construction-layer milestone — render labels, 2026-06-05).** A node
|
||||
additionally exposes `label() -> String`, a **single-line, non-load-bearing**
|
||||
render symbol: a default trait method the run loop never calls (wiring is by
|
||||
index, C23). Overrides carry the node's identifying params (`SMA(2)` vs `SMA(4)`)
|
||||
so a graph render (C9 graph-as-data, #13) disambiguates identical node types and
|
||||
surfaces a mis-wiring. Like `FieldSpec.name`, it is an informative debug symbol,
|
||||
not part of the C8 dataflow contract — adding it changes no run behaviour.
|
||||
|
||||
### C9 — Fractal, acyclic composition
|
||||
**Guarantee.** A composite is itself a `Node` that wires a sub-graph and exposes
|
||||
one output; signal, combined signal, and (with execution) strategy are all the
|
||||
|
||||
Reference in New Issue
Block a user