3f75d59598
Closes the cycle-0090 (#156) work and delivers the "Topology-as-data: blueprint serialization & loader (C24)" milestone. Cycle-0090 audit (architect drift review, range 7ad7f58..HEAD): drift-clean on substance. Tier-1 forward-tolerance is genuinely serde-default (deny_unknown_fields = 0 in every production struct), pinned at envelope + primitive across both the in-crate and public-seam tests; Tier-2 refusals real and green; no struct/signature/runtime change; the invariant-5 acyclicity lockstep (construction gate <-> bootstrap Kahn sort) undisturbed; invariant-9 resolver stays injected, no registry. The one medium item the architect flagged — the C24 Remaining block carried no deferral signal for #158/#159 — is fixed in this commit's ledger update. Milestone fieldtest (fieldtests/milestone-topology-as-data/, the close-gate evidence): GREEN, 0 behavioural bugs. A downstream consumer ran the full author -> serialize -> load -> construct -> introspect -> reproduce story from the public surface alone — round-trip bit-exact on both authoring surfaces (Rust builder + op-script CLI), build-free introspection, Tier-1/Tier-2 forward-compat by name, fail-fast diagnostics by identifier. Verified the headline claims myself (the two Rust binaries + the CLI build/diff + the fail-fast exits), not just the agent's report. Findings triaged to the forward queue (none blocking): op-script grammar undocumented + a stale cycle-0088 example (#163); canonical trailing-newline divergence + Composite Debug/PartialEq ergonomics (#164); CLI discoverability of build/introspect + no `graph run` (commented on #159); the newline as a content-addressing prerequisite (commented on #158). #158 (content-address topology in the manifest) and #159 (retire the hard-wired harnesses) were moved OUT of this milestone: both are premature until the World generates runs from blueprint-data (commit still fully identifies every hard-wired topology), so they belong to the project-as-crate / World cycle. The C24 ledger Status records the delivery and the deferral. Ephemeral cycle-0090 spec + plan removed per the lifecycle convention. The Gitea milestone container is closed on user ratification (this run tees up the close, does not click it).
124 lines
5.5 KiB
Rust
124 lines
5.5 KiB
Rust
// Milestone fieldtest — Topology-as-data (C24), Example 1.
|
|
//
|
|
// The headline round-trip the milestone promises: a researcher authors a small
|
|
// SMA-cross bias signal with the Rust GraphBuilder, serializes the topology to
|
|
// canonical JSON (blueprint_to_json), loads it back through the std vocabulary
|
|
// resolver (blueprint_from_json + aura_std::std_vocabulary), and proves
|
|
// (a) the canonical JSON round-trips byte-identically (serialize -> load ->
|
|
// re-serialize == original), and
|
|
// (b) the RELOADED blueprint runs BIT-IDENTICALLY to its Rust-built twin
|
|
// (C1: the recorded bias trace matches value-for-value).
|
|
//
|
|
// Discovery: the GraphBuilder / blueprint_to_json / blueprint_from_json surface
|
|
// was learned from `cargo doc` doc-comments + the C24 ledger entry + `aura graph
|
|
// introspect --node <T>` (which gives each node's port and bind shapes). No
|
|
// crates/*/src was read.
|
|
//
|
|
// Note on the run path: a recording sink (Recorder) is deliberately OUTSIDE the
|
|
// serializable round-trippable set (C24 ledger: it captures an mpsc::Sender —
|
|
// runtime identity, not param-generic data). So the *serializable* unit is the
|
|
// pure signal sub-composite (price source -> SMAs -> Sub -> Bias -> expose
|
|
// bias); to OBSERVE a run we nest that sub-composite under a tiny throwaway
|
|
// harness wrapper that adds a Recorder. The same wrapper is applied to both the
|
|
// original and the reloaded sub-composite, so the comparison is apples-to-apples.
|
|
|
|
use std::sync::mpsc;
|
|
|
|
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
|
use aura_engine::{
|
|
blueprint_from_json, blueprint_to_json, BlueprintNode, Composite, Edge, GraphBuilder, OutField,
|
|
Role, Target, VecSource,
|
|
};
|
|
use aura_std::{std_vocabulary, Bias, Recorder, Sma, Sub};
|
|
|
|
/// Author the serializable signal sub-composite via the Rust builder API.
|
|
/// SMA(2) fast, SMA(4) slow, Sub = fast - slow, Bias over the spread, exposing
|
|
/// the single `bias` field. The two SMA lengths are baked (`.bind`); Bias.scale
|
|
/// is left as a tunable param (mirrors the canonical op-script form).
|
|
fn author_signal() -> Composite {
|
|
let mut gb = GraphBuilder::new("graph");
|
|
let price = gb.source_role("price", ScalarKind::F64);
|
|
let fast = gb.add(Sma::builder().named("fast").bind("length", Scalar::I64(2)));
|
|
let slow = gb.add(Sma::builder().named("slow").bind("length", Scalar::I64(4)));
|
|
gb.feed(price, [fast.input("series"), slow.input("series")]);
|
|
let sub = gb.add(Sub::builder().named("sub"));
|
|
gb.connect(fast.output("value"), sub.input("lhs"));
|
|
gb.connect(slow.output("value"), sub.input("rhs"));
|
|
let bias = gb.add(Bias::builder().named("bias"));
|
|
gb.connect(sub.output("value"), bias.input("signal"));
|
|
gb.expose(bias.output("bias"), "bias");
|
|
gb.build().expect("signal composite should build")
|
|
}
|
|
|
|
/// Run a signal sub-composite by nesting it under a Recorder harness and
|
|
/// recording the exposed `bias` each fired cycle. Returns (ts, bias) rows.
|
|
fn run_signal(signal: Composite, prices: &[f64]) -> Vec<(i64, f64)> {
|
|
let (tx, rx) = mpsc::channel::<(Timestamp, Vec<Scalar>)>();
|
|
let harness = Composite::new(
|
|
"harness",
|
|
vec![
|
|
BlueprintNode::Composite(signal),
|
|
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(),
|
|
],
|
|
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], // bias -> recorder col 0
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }],
|
|
source: Some(ScalarKind::F64),
|
|
}],
|
|
vec![] as Vec<OutField>,
|
|
);
|
|
let stream: Vec<(Timestamp, Scalar)> = prices
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, &p)| (Timestamp(60_000_000_000 * i as i64), Scalar::F64(p)))
|
|
.collect();
|
|
// bias.scale is the one tunable param left in the space; bind it at bootstrap.
|
|
let mut h = harness
|
|
.bootstrap_with_params(vec![Scalar::F64(1.0)])
|
|
.expect("harness should bootstrap");
|
|
h.run(vec![Box::new(VecSource::new(stream))]);
|
|
drop(h); // release the recorder's tx clone
|
|
rx.iter()
|
|
.map(|(ts, row)| match row[0] {
|
|
Scalar::F64(x) => (ts.0, x),
|
|
other => panic!("expected f64 bias, got {other:?}"),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn main() {
|
|
let prices: Vec<f64> = vec![
|
|
1.00, 1.01, 1.02, 1.03, 1.05, 1.08, 1.06, 1.04, 1.02, 1.01, 1.03, 1.07,
|
|
];
|
|
|
|
// --- (a) canonical JSON round-trip ---------------------------------------
|
|
let orig = author_signal();
|
|
let json1 = blueprint_to_json(&orig).expect("serialize original");
|
|
std::fs::write("mt_1_rust_blueprint.json", &json1).expect("persist artifact");
|
|
|
|
let loaded = blueprint_from_json(&json1, &std_vocabulary).expect("load from json");
|
|
let json2 = blueprint_to_json(&loaded).expect("re-serialize loaded");
|
|
assert_eq!(
|
|
json1, json2,
|
|
"canonical JSON must round-trip byte-identically (serialize->load->re-serialize)"
|
|
);
|
|
println!("(a) canonical JSON round-trip: IDENTICAL ({} bytes)", json1.len());
|
|
|
|
// --- (b) run identity: reloaded twin runs bit-identical to the original ---
|
|
let orig_run = run_signal(author_signal(), &prices);
|
|
let loaded_run = run_signal(loaded, &prices);
|
|
assert_eq!(
|
|
orig_run, loaded_run,
|
|
"C1: the reloaded blueprint must run bit-identically to its Rust-built twin"
|
|
);
|
|
println!(
|
|
"(b) run identity: {} recorded bias rows, value-for-value IDENTICAL",
|
|
orig_run.len()
|
|
);
|
|
for (ts, b) in &loaded_run {
|
|
println!(" ts={ts:>14} bias={b:+.6}");
|
|
}
|
|
println!("PASS: author -> serialize -> load -> reproduce (C1 round-trip).");
|
|
}
|