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).
153 lines
6.8 KiB
Rust
153 lines
6.8 KiB
Rust
// Milestone fieldtest — Topology-as-data (C24), Example 4.
|
|
//
|
|
// The forward-compatibility discipline the milestone promises (#156 / C24
|
|
// two-tier), exercised from the loader's seat, plus cross-surface interop:
|
|
//
|
|
// 0. Cross-surface interop: the blueprint emitted by the `aura graph build`
|
|
// CLI (mt_2_blueprint.cli.json, trailing newline and all) loads cleanly via
|
|
// the library blueprint_from_json and runs — proving CLI-authored topology
|
|
// is library-consumable.
|
|
// 1. Tier-1 (additive-optional tolerance): a future writer adds an UNKNOWN
|
|
// optional field — at the envelope level AND inside a node — and an older
|
|
// reader must (a) load it, (b) drop it on re-serialize back to the SAME
|
|
// canonical form, and (c) run BIT-IDENTICALLY to the clean blueprint.
|
|
// 2. Tier-2 (must-understand refusal): a too-new format_version and an
|
|
// out-of-vocabulary node type must each fail CLEANLY BY NAME
|
|
// (LoadError::UnsupportedVersion / UnknownNodeType) — never a panic, never
|
|
// a silently-wrong graph.
|
|
//
|
|
// Public interface only: blueprint_to_json / blueprint_from_json / LoadError /
|
|
// std_vocabulary, learned from `cargo doc` + the C24 ledger. No crates/*/src.
|
|
|
|
use std::sync::mpsc;
|
|
|
|
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
|
use aura_engine::{
|
|
blueprint_from_json, blueprint_to_json, BlueprintNode, Composite, Edge, GraphBuilder, LoadError,
|
|
OutField, Role, Target, VecSource,
|
|
};
|
|
use aura_std::{std_vocabulary, Bias, Recorder, Sma, Sub};
|
|
|
|
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")
|
|
}
|
|
|
|
fn run_signal(signal: Composite) -> Vec<(i64, f64)> {
|
|
let prices: [f64; 12] = [
|
|
1.00, 1.01, 1.02, 1.03, 1.05, 1.08, 1.06, 1.04, 1.02, 1.01, 1.03, 1.07,
|
|
];
|
|
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 }],
|
|
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();
|
|
let mut h = harness
|
|
.bootstrap_with_params(vec![Scalar::F64(1.0)])
|
|
.expect("bootstrap");
|
|
h.run(vec![Box::new(VecSource::new(stream))]);
|
|
drop(h);
|
|
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 canonical = blueprint_to_json(&author_signal()).expect("serialize");
|
|
let baseline_run = run_signal(author_signal());
|
|
|
|
// --- 0. cross-surface interop: load the CLI `graph build` output ----------
|
|
match std::fs::read_to_string("mt_2_blueprint.cli.json") {
|
|
Ok(cli_json) => {
|
|
let from_cli = blueprint_from_json(&cli_json, &std_vocabulary)
|
|
.expect("CLI-emitted blueprint must load via the library loader");
|
|
assert_eq!(
|
|
run_signal(from_cli),
|
|
baseline_run,
|
|
"CLI-authored topology must run identically to the Rust-authored twin"
|
|
);
|
|
let trailing_nl = cli_json.ends_with('\n');
|
|
println!(
|
|
"0. cross-surface interop: CLI blueprint loaded + ran identically \
|
|
(loader tolerated trailing newline = {trailing_nl})"
|
|
);
|
|
}
|
|
Err(_) => println!("0. (skipped: run mt_2 CLI build first to produce mt_2_blueprint.cli.json)"),
|
|
}
|
|
|
|
// --- 1. Tier-1: unknown optional fields are tolerated ---------------------
|
|
// envelope-level unknown key
|
|
let env_unknown = canonical.replacen('{', r#"{"x_future_envelope":{"any":[1,2]},"#, 1);
|
|
// node-level unknown key (inject inside the first primitive object)
|
|
let node_unknown = canonical.replacen(
|
|
r#"{"type":"SMA","name":"fast""#,
|
|
r#"{"x_future_node_flag":true,"type":"SMA","name":"fast""#,
|
|
1,
|
|
);
|
|
for (label, mutated) in [("envelope", &env_unknown), ("node", &node_unknown)] {
|
|
let loaded = blueprint_from_json(mutated, &std_vocabulary)
|
|
.unwrap_or_else(|e| panic!("Tier-1 {label} unknown field must be tolerated, got {e:?}"));
|
|
let reser = blueprint_to_json(&loaded).expect("re-serialize");
|
|
assert_eq!(
|
|
reser, canonical,
|
|
"Tier-1 {label}: re-serialized form must drop the unknown field and match canonical"
|
|
);
|
|
assert_eq!(
|
|
run_signal(loaded),
|
|
baseline_run,
|
|
"Tier-1 {label}: must run bit-identically to the clean blueprint"
|
|
);
|
|
println!("1. Tier-1 {label} unknown field: tolerated, dropped on re-emit, ran identical.");
|
|
}
|
|
|
|
// --- 2. Tier-2: must-understand refusals, by name, no panic ---------------
|
|
// (Composite has no Debug impl, so we cannot {:?} the Ok arm — match explicitly.)
|
|
let too_new = canonical.replacen(r#""format_version":1"#, r#""format_version":999"#, 1);
|
|
match blueprint_from_json(&too_new, &std_vocabulary) {
|
|
Err(e @ LoadError::UnsupportedVersion { .. }) => {
|
|
println!("2a. too-new format_version (999): refused -> {e:?}")
|
|
}
|
|
Err(other) => panic!("2a. expected UnsupportedVersion, got {other:?}"),
|
|
Ok(_) => panic!("2a. expected refusal, but a graph was silently loaded"),
|
|
}
|
|
|
|
let bad_type = canonical.replacen(r#""type":"Sub""#, r#""type":"FOO""#, 1);
|
|
match blueprint_from_json(&bad_type, &std_vocabulary) {
|
|
Err(e @ LoadError::UnknownNodeType(..)) => {
|
|
println!("2b. out-of-vocabulary node type (FOO): refused -> {e:?}")
|
|
}
|
|
Err(other) => panic!("2b. expected UnknownNodeType, got {other:?}"),
|
|
Ok(_) => panic!("2b. expected refusal, but a graph was silently loaded"),
|
|
}
|
|
|
|
println!("PASS: Tier-1 tolerance + Tier-2 by-name refusal (no panic, no silent wrong graph).");
|
|
}
|