feat(0094): content-addressed reproduction — aura reproduce <family-id> (iter 1)
Restore C18 reproducibility for generated (blueprint-sweep) runs (#158, acc 2): a sweep's topology is stored once, content-addressed by the topology_hash the manifest already carries, and `aura reproduce <family-id>` re-derives every persisted member bit-identically (C1) from the stored blueprint. - aura-registry: a dumb content-addressed bytes store — Registry::put_blueprint / get_blueprint / blueprints_dir over runs/blueprints/<hash>.json. No sha2 dep (caller owns the hash; reproduction's bit-identical compare is the integrity check); absent id -> Ok(None) (treat-as-empty, like load). - aura-cli: run_blueprint_member extracted from blueprint_sweep_family's member closure (behaviour-preserving; keystone stays green) so the sweep AND reproduce re-run the identical reduce-mode path — bit-identity by construction. The store write hooks the sweep persist seam (one blueprint per family, shared topo). reproduce_family_in loads each member's blueprint by hash, reconstructs the point from the recorded params (point_from_params, inverse of zip_params, over the WRAPPED signal's param_space so the prefixed knob names match), re-runs, and compares metrics. `["reproduce", id]` dispatch arm; refuse-don't-guess on an unknown id / missing stored blueprint (exit 2), DIVERGED -> exit 1. Dependency decision (per-case review, ratified): enabled serde_json `float_roundtrip`. Stored member metrics round-trip through families.jsonl as f64 JSON; the default parser can be 1 ULP off, which would make disk-loaded != recomputed and "bit-identical" (C1) physically impossible. The feature guarantees exact f64 round-trip. Blast-radius clean (full workspace suite green); it serves determinism, the invariant reproduction rests on. Canary: f64_blueprint_param_survives_store_round_trip_bit_identically. Two plan bugs caught + fixed by the implement loop and verified: reproduce's param_space must come from the wrapped signal (prefixed names, the #167 lockstep), and the family id is 0-indexed (live-0 / smacross-0, not -1). Verified: full workspace suite green (51 suites); clippy -D warnings clean; and the binary end-to-end — `aura sweep` (3 members, one shared stored blueprint) then `aura reproduce live-0` (3/3 bit-identical incl the open-at-end member), unknown id -> exit 2. acc 1 (graph introspect --content-id) + acc 3 (Tier-1 id stability) land in iteration 2 (plan 0094b). refs #158
This commit is contained in:
+7
-1
@@ -27,4 +27,10 @@ publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# float_roundtrip is load-bearing for bit-identical reproduction (`aura reproduce`,
|
||||
# #158): an f64 blueprint param (e.g. a Bias `scale`) is round-tripped through the
|
||||
# content-addressed store as JSON, and the DEFAULT serde_json parser can be 1 ULP off,
|
||||
# which would re-derive a member as DIVERGED. This feature guarantees the exact f64 is
|
||||
# parsed back. Exercised (with a 1-ULP canary value) by aura-cli's
|
||||
# `f64_blueprint_param_survives_store_round_trip_bit_identically`.
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
|
||||
+264
-32
@@ -2469,6 +2469,128 @@ fn runs_family(id: &str, rank: Option<&str>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of reproducing one persisted family: per member, whether its re-run
|
||||
/// metrics are bit-identical to the stored metrics (C1).
|
||||
struct ReproduceReport {
|
||||
outcomes: Vec<(String, bool)>,
|
||||
}
|
||||
|
||||
/// Reconstruct a member's bootstrap point from its recorded named params — the inverse
|
||||
/// of `zip_params(space, point)`. Walks the reloaded signal's `param_space` in order
|
||||
/// (deterministic for the same blueprint) and reads each knob's value from the manifest.
|
||||
fn point_from_params(space: &[ParamSpec], params: &[(String, Scalar)]) -> Vec<Cell> {
|
||||
space
|
||||
.iter()
|
||||
.map(|ps| {
|
||||
params
|
||||
.iter()
|
||||
.find(|(n, _)| n == &ps.name)
|
||||
.map(|(_, s)| s.cell())
|
||||
.unwrap_or_else(|| {
|
||||
// A manifest missing a param the reloaded space expects is
|
||||
// corrupted-on-disk data — exit cleanly (`aura:` + exit 2) like
|
||||
// every other persisted-data failure on the reproduce path, not
|
||||
// a panic.
|
||||
eprintln!("aura: manifest is missing param {}", ps.name);
|
||||
std::process::exit(2);
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Re-derive every member of a persisted sweep family from the content-addressed store
|
||||
/// and compare to the stored result, against an explicit registry (testable seam).
|
||||
fn reproduce_family_in(reg: &Registry, id: &str, data: &DataSource) -> ReproduceReport {
|
||||
let members = reg.load_family_members().unwrap_or_else(|e| {
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(2);
|
||||
});
|
||||
let Some(family) = group_families(members).into_iter().find(|f| f.id == id) else {
|
||||
// reproduce is an action, not a lookup: an unknown id is a hard error (distinct
|
||||
// from `runs family <id>`'s treat-as-empty exit 0).
|
||||
eprintln!("aura: no such family '{id}'");
|
||||
std::process::exit(2);
|
||||
};
|
||||
let pip = data.pip_size();
|
||||
let window = data.full_window();
|
||||
let mut outcomes = Vec::new();
|
||||
for member in &family.members {
|
||||
let stored = &member.report;
|
||||
let hash = stored.manifest.topology_hash.clone().unwrap_or_else(|| {
|
||||
eprintln!("aura: family member has no topology_hash; not a generated run");
|
||||
std::process::exit(2);
|
||||
});
|
||||
let doc = reg
|
||||
.get_blueprint(&hash)
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(2);
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("aura: blueprint {hash} missing from store");
|
||||
std::process::exit(2);
|
||||
});
|
||||
// Reload the stored blueprint per use: a Composite is !Clone, and both the
|
||||
// param-space probe (below) and the re-run each consume one. The doc was
|
||||
// canonical-serialized at store time, so every reload is infallible.
|
||||
let reload = || {
|
||||
blueprint_from_json(&doc, &|t| std_vocabulary(t)).unwrap_or_else(|e| {
|
||||
eprintln!("aura: stored blueprint {hash} does not parse: {e:?}");
|
||||
std::process::exit(2);
|
||||
})
|
||||
};
|
||||
// The param_space of the WRAPPED signal — its knobs carry the `stage1_r`
|
||||
// wrapper's `stage1_signal.` node-path prefix, exactly the names the manifest
|
||||
// recorded at write time. Mirrors `blueprint_sweep_family`'s probe so the
|
||||
// reproduce-side space name-matches the stored params (raw `signal.param_space()`
|
||||
// would drop the prefix and `point_from_params` could not find the knobs).
|
||||
let (tx_eq, _) = mpsc::channel();
|
||||
let (tx_ex, _) = mpsc::channel();
|
||||
let (tx_r, _) = mpsc::channel();
|
||||
let (tx_req, _) = mpsc::channel();
|
||||
let space =
|
||||
wrap_stage1r(reload(), tx_eq, tx_ex, tx_r, tx_req, false, true, None).param_space();
|
||||
let point = point_from_params(&space, &stored.manifest.params);
|
||||
let label = stored
|
||||
.manifest
|
||||
.params
|
||||
.iter()
|
||||
.map(|(n, v)| format!("{n}={}", render_value(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let rerun = run_blueprint_member(
|
||||
reload(),
|
||||
&point,
|
||||
&space,
|
||||
data.run_sources(),
|
||||
window,
|
||||
stored.manifest.seed,
|
||||
pip,
|
||||
&hash,
|
||||
);
|
||||
outcomes.push((label, rerun.metrics == stored.metrics));
|
||||
}
|
||||
ReproduceReport { outcomes }
|
||||
}
|
||||
|
||||
/// `aura reproduce <family-id>`: re-derive a persisted sweep family from the
|
||||
/// content-addressed blueprint store and verify each member reproduces bit-identically
|
||||
/// (C18 "re-derives full results on demand"). Synthetic data this cycle (deterministic);
|
||||
/// recorded-dataset reproduction rides the DataServer seam (#124).
|
||||
fn reproduce_family(id: &str) {
|
||||
let rep = reproduce_family_in(&default_registry(), id, &DataSource::Synthetic);
|
||||
let total = rep.outcomes.len();
|
||||
let ok = rep.outcomes.iter().filter(|(_, b)| *b).count();
|
||||
for (label, identical) in &rep.outcomes {
|
||||
let verdict = if *identical { "bit-identical" } else { "DIVERGED" };
|
||||
println!("{id} member {label} reproduced: {verdict}");
|
||||
}
|
||||
println!("reproduced {ok}/{total} members bit-identically");
|
||||
if ok != total {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// --- MACD proof-of-concept (a richer, nested indicator + strategy) -----------
|
||||
|
||||
/// The MACD signal as a named composite: price → fast/slow `Ema` → the MACD line
|
||||
@@ -2970,6 +3092,48 @@ fn run_signal_stage1r(signal: Composite, params: &[Scalar], data: RunData, seed:
|
||||
RunReport { manifest, metrics }
|
||||
}
|
||||
|
||||
/// Run one bootstrapped member of a loaded-signal sweep: the reduce-mode path
|
||||
/// (`SeriesReducer` folds eq/ex, `GatedRecorder` retains the gated R rows — the
|
||||
/// O(cycles)→O(trades) fold), shared by the live sweep AND reproduction so a
|
||||
/// reproduced member re-derives bit-identically (C1). `signal` is a freshly
|
||||
/// reloaded blueprint (`Composite` is `!Clone`); `point` is the member's bound
|
||||
/// cells; `space` gives the by-name manifest params; `topo` the shared signal hash.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_blueprint_member(
|
||||
signal: Composite,
|
||||
point: &[Cell],
|
||||
space: &[ParamSpec],
|
||||
sources: Vec<Box<dyn aura_engine::Source>>,
|
||||
window: (Timestamp, Timestamp),
|
||||
seed: u64,
|
||||
pip: f64,
|
||||
topo: &str,
|
||||
) -> RunReport {
|
||||
let (tx_eq, rx_eq) = mpsc::channel();
|
||||
let (tx_ex, rx_ex) = mpsc::channel();
|
||||
let (tx_r, rx_r) = mpsc::channel();
|
||||
let (tx_req, _rx_req) = mpsc::channel();
|
||||
let mut h = wrap_stage1r(signal, tx_eq, tx_ex, tx_r, tx_req, false, true, None)
|
||||
.bootstrap_with_cells(point)
|
||||
.expect("member bootstraps (point kind-checked against param_space)");
|
||||
h.run(sources);
|
||||
let named = zip_params(space, point); // by-name params for the manifest record
|
||||
let mut manifest = sim_optimal_manifest(named, window, seed, pip);
|
||||
manifest.broker = stage1_r_broker_label(pip);
|
||||
manifest.topology_hash = Some(topo.to_string());
|
||||
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
|
||||
let (total_pips, max_drawdown) = rx_eq
|
||||
.try_iter()
|
||||
.next()
|
||||
.map(|(_, row)| (row[0].as_f64(), row[1].as_f64()))
|
||||
.unwrap_or((0.0, 0.0));
|
||||
let bias_sign_flips =
|
||||
rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0);
|
||||
let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None };
|
||||
m.r = Some(summarize_r(&r_rows, &[]));
|
||||
RunReport { manifest, metrics: m }
|
||||
}
|
||||
|
||||
/// Sweep a serialized signal `doc` over user-named param-space axes — the structural
|
||||
/// twin of [`stage1_r_sweep_family`], with three deviations. (1) The signal source is
|
||||
/// `wrap_stage1r(blueprint_from_json(doc))` — a loaded blueprint, not the Rust-built
|
||||
@@ -3015,33 +3179,9 @@ fn blueprint_sweep_family(
|
||||
binder = binder.axis(n, vals.clone());
|
||||
}
|
||||
binder.sweep(|point| {
|
||||
// fresh per-member graph (deviation 2: reload + fresh channels) bootstrapped
|
||||
// with the point's cells.
|
||||
let (tx_eq, rx_eq) = mpsc::channel();
|
||||
let (tx_ex, rx_ex) = mpsc::channel();
|
||||
let (tx_r, rx_r) = mpsc::channel();
|
||||
let (tx_req, _rx_req) = mpsc::channel();
|
||||
let mut h = wrap_stage1r(reload(doc), tx_eq, tx_ex, tx_r, tx_req, false, true, None)
|
||||
.bootstrap_with_cells(point)
|
||||
.expect("member bootstraps (point kind-checked against param_space by resolve_axes)");
|
||||
h.run(data.run_sources());
|
||||
let named = zip_params(&space, point); // by-name params for the manifest record
|
||||
let mut manifest = sim_optimal_manifest(named, window, 0, pip);
|
||||
manifest.broker = stage1_r_broker_label(pip);
|
||||
manifest.topology_hash = Some(topo.clone()); // deviation 3: the loaded signal's hash
|
||||
// reduce-mode fold, EXACTLY as stage1_r_sweep_family's no-trace arm: SeriesReducer
|
||||
// folds eq/ex to one summary row, GatedRecorder retains the gated R rows.
|
||||
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
|
||||
let (total_pips, max_drawdown) = rx_eq
|
||||
.try_iter()
|
||||
.next()
|
||||
.map(|(_, row)| (row[0].as_f64(), row[1].as_f64()))
|
||||
.unwrap_or((0.0, 0.0));
|
||||
let bias_sign_flips =
|
||||
rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0);
|
||||
let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None };
|
||||
m.r = Some(summarize_r(&r_rows, &[]));
|
||||
RunReport { manifest, metrics: m }
|
||||
// fresh per-member graph (Composite is !Clone, reload per member) run through
|
||||
// the shared reduce-mode member path — the same fn reproduction re-runs.
|
||||
run_blueprint_member(reload(doc), point, &space, data.run_sources(), window, 0, pip, &topo)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3066,10 +3206,27 @@ fn run_blueprint_sweep(doc: &str, axes: &[(String, Vec<Scalar>)], name: &str, pe
|
||||
eprintln!("aura: {e:?}");
|
||||
std::process::exit(2);
|
||||
});
|
||||
// Record the family unconditionally (C18/C21 lineage), exactly like `run_sweep`, and
|
||||
// keep the assigned id so every printed member line carries it — the stdout is then
|
||||
// linkable to the stored `FamilyKind::Sweep` family.
|
||||
let id = match default_registry().append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) {
|
||||
let reg = default_registry();
|
||||
// Store the canonical blueprint ONCE, keyed by the family's shared topology_hash —
|
||||
// exactly the bytes whose SHA256 the members carry (#164 byte-canonical, round-trip
|
||||
// idempotent). One stored topology per family (C18/C11/C12).
|
||||
let topo = family.points[0]
|
||||
.report
|
||||
.manifest
|
||||
.topology_hash
|
||||
.clone()
|
||||
.expect("a blueprint sweep stamps every member's topology_hash");
|
||||
let canonical = blueprint_to_json(
|
||||
&blueprint_from_json(doc, &|t| std_vocabulary(t))
|
||||
.expect("doc parse-validated at the dispatch boundary"),
|
||||
)
|
||||
.expect("a loaded blueprint re-serializes");
|
||||
reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| {
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(2);
|
||||
});
|
||||
// Record the family unconditionally (C18/C21 lineage), exactly like `run_sweep`.
|
||||
let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
eprintln!("aura: {e}");
|
||||
@@ -3710,7 +3867,7 @@ const COST_FLAGS_NOTE: &str = "\ncost flags (stage1-r only; all >= 0, charged in
|
||||
--carry-per-cycle = carry in price units per ENGINE cycle (not per day, not an overnight swap)";
|
||||
|
||||
const USAGE: &str =
|
||||
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] [--cost-per-trade <f64>] [--slip-vol-mult <f64>] [--carry-per-cycle <f64>] | aura chart <name> [--tap <t>] [--panels] | aura graph | aura sweep [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura mc --strategy stage1-r [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--block-len <n>] [--resamples <n>] [--seed <n>] | aura walkforward [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--select <argmax|plateau:mean|plateau:worst>] | aura generalize [--strategy stage1-r] --real <SYM1,SYM2,...> --fast <n> --slow <n> --stop-length <n> --stop-k <f> [--from <ms>] [--to <ms>] [--metric <r-metric>] [--name <n>] | aura runs families | aura runs family <id> [rank <metric>]";
|
||||
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] [--cost-per-trade <f64>] [--slip-vol-mult <f64>] [--carry-per-cycle <f64>] | aura chart <name> [--tap <t>] [--panels] | aura graph | aura sweep [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura mc --strategy stage1-r [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--block-len <n>] [--resamples <n>] [--seed <n>] | aura walkforward [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--select <argmax|plateau:mean|plateau:worst>] | aura generalize [--strategy stage1-r] --real <SYM1,SYM2,...> --fast <n> --slow <n> --stop-length <n> --stop-k <f> [--from <ms>] [--to <ms>] [--metric <r-metric>] [--name <n>] | aura runs families | aura runs family <id> [rank <metric>] | aura reproduce <family-id>";
|
||||
|
||||
fn main() {
|
||||
// Restore the default SIGPIPE disposition. Rust's runtime sets SIGPIPE to SIG_IGN
|
||||
@@ -3852,6 +4009,7 @@ fn main() {
|
||||
["runs", "families"] => runs_families(),
|
||||
["runs", "family", id] => runs_family(id, None),
|
||||
["runs", "family", id, "rank", metric] => runs_family(id, Some(metric)),
|
||||
["reproduce", id] => reproduce_family(id),
|
||||
_ => {
|
||||
eprintln!("aura: {USAGE}");
|
||||
std::process::exit(2);
|
||||
@@ -5025,6 +5183,80 @@ mod tests {
|
||||
assert_ne!(k4, k6, "distinct grid points key distinctly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reproduce_family_re_derives_every_member_bit_identically() {
|
||||
// a unique temp runs store so the on-disk family + blueprint store do not collide.
|
||||
let dir = std::env::temp_dir().join(format!("aura-repro-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let reg = Registry::open(dir.join("runs.jsonl"));
|
||||
|
||||
let open = stage1_signal(None, None);
|
||||
let doc = blueprint_to_json(&open).expect("serializes");
|
||||
let data = DataSource::Synthetic;
|
||||
// 2x grid over slow.length {4,6} at fast=2 — slow=4 is the open-at-end member.
|
||||
let axes = vec![
|
||||
("stage1_signal.fast.length".to_string(), vec![Scalar::i64(2)]),
|
||||
("stage1_signal.slow.length".to_string(), vec![Scalar::i64(4), Scalar::i64(6)]),
|
||||
];
|
||||
let family = blueprint_sweep_family(&doc, &axes, &data).expect("axes resolve");
|
||||
|
||||
// persist exactly as run_blueprint_sweep does: store the blueprint, append the family.
|
||||
let topo = family.points[0].report.manifest.topology_hash.clone().expect("topo");
|
||||
let canonical =
|
||||
blueprint_to_json(&blueprint_from_json(&doc, &|t| std_vocabulary(t)).unwrap()).unwrap();
|
||||
reg.put_blueprint(&topo, &canonical).expect("store blueprint");
|
||||
let id = reg
|
||||
.append_family("repro", FamilyKind::Sweep, &sweep_member_reports(&family))
|
||||
.expect("append");
|
||||
|
||||
// reproduce: every member re-derives bit-identically (incl the open-at-end member).
|
||||
let rep = reproduce_family_in(®, &id, &data);
|
||||
assert_eq!(rep.outcomes.len(), 2, "two members reproduced");
|
||||
assert!(
|
||||
rep.outcomes.iter().all(|(_, ok)| *ok),
|
||||
"every member re-derives bit-identically: {:?}",
|
||||
rep.outcomes
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Property: an f64 blueprint param survives the content-addressed store's
|
||||
/// serialize -> parse -> re-serialize round-trip **bit-identically**, so `aura
|
||||
/// reproduce` re-derives an f64-bearing member without DIVERGED. This is exactly
|
||||
/// what workspace `serde_json/float_roundtrip` buys: the constant below is a
|
||||
/// full-precision f64 (`0.12387080150408619`) that the DEFAULT serde_json parser
|
||||
/// mis-parses by 1 ULP — with `float_roundtrip` on it parses back exactly, so the
|
||||
/// canonical bytes are stable and the re-run reproduces. The i64-axis reproduce
|
||||
/// tests exercise a `scale=0.5` blueprint (exactly representable), so this is the
|
||||
/// only test that actually depends on the feature.
|
||||
#[test]
|
||||
fn f64_blueprint_param_survives_store_round_trip_bit_identically() {
|
||||
// 1-ULP canary for serde_json float_roundtrip: parses back off-by-one without it.
|
||||
const HARD_SCALE: f64 = 0.12387080150408619;
|
||||
// A one-node signal whose only knob is the non-short-decimal f64 scale.
|
||||
let mut g = GraphBuilder::new("scale_probe");
|
||||
let bias = g.add(Bias::builder().named("bias").bind("scale", Scalar::f64(HARD_SCALE)));
|
||||
let signal = g.source_role("signal", ScalarKind::F64);
|
||||
g.feed(signal, vec![bias.input("signal")]);
|
||||
g.expose(bias.output("bias"), "bias");
|
||||
let sig = g.build().expect("one-node bias signal wiring resolves");
|
||||
|
||||
// the store keeps canonical bytes verbatim (dumb bytes-by-key), so the f64
|
||||
// fidelity lives entirely in this serialize -> parse -> re-serialize hop.
|
||||
let doc = blueprint_to_json(&sig).expect("serializes");
|
||||
assert!(
|
||||
doc.contains("0.12387080150408619"),
|
||||
"canonical JSON carries the full-precision f64: {doc}"
|
||||
);
|
||||
let reloaded = blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads");
|
||||
let doc2 = blueprint_to_json(&reloaded).expect("re-serializes");
|
||||
// Without float_roundtrip the reparsed scale drifts 1 ULP and ryu re-serializes
|
||||
// it to a different string, so these canonical bytes would differ.
|
||||
assert_eq!(doc, doc2, "f64 blueprint param survives the store round-trip bit-identically");
|
||||
}
|
||||
|
||||
/// `parse_walkforward_args` defaults to synthetic / name "walkforward" / no
|
||||
/// persist, admits `--real <SYMBOL>` (window-less here) yielding `DataChoice::Real`,
|
||||
/// and rejects two name flags or a `--real` missing its symbol.
|
||||
|
||||
@@ -3287,6 +3287,68 @@ fn aura_sweep_loads_a_blueprint_and_persists_a_sweep_family() {
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property (#158/#164, C18/C11/C12): a blueprint sweep content-addresses its topology
|
||||
/// — the canonical blueprint is persisted ONCE under `runs/blueprints/<topology_hash>.json`,
|
||||
/// keyed by the very SHA256 every member's manifest carries, holding exactly the bytes
|
||||
/// `blueprint_to_json(blueprint_from_json(doc))` yields. That stored topology is what makes
|
||||
/// a generated sweep family reproducible from disk (the `aura reproduce` re-derivation).
|
||||
#[test]
|
||||
fn aura_sweep_persists_the_canonical_blueprint_keyed_by_topology_hash() {
|
||||
use aura_engine::{blueprint_from_json, blueprint_to_json};
|
||||
use aura_std::std_vocabulary;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
let cwd = temp_cwd("blueprint-sweep-store");
|
||||
let fixture = format!("{}/tests/fixtures/stage1_signal_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = Command::new(BIN)
|
||||
.args([
|
||||
"sweep", &fixture,
|
||||
"--axis", "stage1_signal.fast.length=2,4",
|
||||
"--axis", "stage1_signal.slow.length=8,16",
|
||||
"--trace", "f",
|
||||
])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura sweep blueprint");
|
||||
assert!(out.status.success(), "exit: {:?} stderr={}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// exactly one blueprint stored for the whole family (one topology per family).
|
||||
let store = cwd.join("runs/blueprints");
|
||||
let mut entries: Vec<_> = std::fs::read_dir(&store)
|
||||
.unwrap_or_else(|e| panic!("the sweep must create the blueprint store {store:?}: {e}"))
|
||||
.map(|e| e.expect("dir entry").path())
|
||||
.collect();
|
||||
entries.sort();
|
||||
assert_eq!(entries.len(), 1, "one stored topology per family: {entries:?}");
|
||||
|
||||
// keyed by a 64-hex topology hash, holding the canonical re-serialization.
|
||||
let stored_path = &entries[0];
|
||||
let stem = stored_path.file_stem().expect("stem").to_str().expect("utf-8");
|
||||
assert_eq!(stem.len(), 64, "content id is a 64-hex SHA256: {stem}");
|
||||
assert!(stem.bytes().all(|b| b.is_ascii_hexdigit()), "content id is hex: {stem}");
|
||||
|
||||
let doc = std::fs::read_to_string(&fixture).expect("read fixture");
|
||||
let expected = blueprint_to_json(&blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads"))
|
||||
.expect("re-serializes");
|
||||
let stored = std::fs::read_to_string(stored_path).expect("read stored blueprint");
|
||||
assert_eq!(stored, expected, "the store holds the canonical blueprint bytes");
|
||||
|
||||
// content-addressing, the feature's load-bearing property: the key IS the SHA256 of
|
||||
// the stored bytes — `get_blueprint(hash)` addresses exactly these bytes by identity,
|
||||
// not by coincidence of the store and the members computing the hash separately.
|
||||
let content_id: String =
|
||||
Sha256::digest(stored.as_bytes()).iter().map(|b| format!("{b:02x}")).collect();
|
||||
assert_eq!(stem, content_id, "the store key is the SHA256 of its content");
|
||||
|
||||
// and that key is exactly what every family member's manifest carries — the hash the
|
||||
// reproduce path reads back to fetch the topology. All 4 members share the one topology.
|
||||
let members = std::fs::read_to_string(cwd.join("runs/families.jsonl")).expect("read family store");
|
||||
let carried = members.matches(&format!("\"topology_hash\":\"{stem}\"")).count();
|
||||
assert_eq!(carried, 4, "every member records the store key as its topology_hash: {members}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// An `--axis` name that does not resolve against the loaded blueprint's param_space
|
||||
/// fails clean (a named BindError, non-zero exit), not a panic. The fully-bound
|
||||
/// fixture has an empty param_space, so any axis name is unknown.
|
||||
@@ -3304,3 +3366,143 @@ fn aura_sweep_rejects_an_unknown_axis() {
|
||||
"names the unresolved axis: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#158, C18 "re-derives full results on demand"): `aura reproduce <family-id>`
|
||||
/// re-derives every member of a persisted sweep family from the content-addressed
|
||||
/// blueprint store and reports each bit-identical, exiting 0. Drives the verb through the
|
||||
/// binary — the `reproduce_family` stdout contract (a per-member `bit-identical` line plus
|
||||
/// a `reproduced N/N` summary) that the `reproduce_family_in` unit seam bypasses.
|
||||
#[test]
|
||||
fn aura_reproduce_re_derives_a_persisted_sweep_family() {
|
||||
let cwd = temp_cwd("blueprint-reproduce");
|
||||
let fixture = format!("{}/tests/fixtures/stage1_signal_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let sweep = Command::new(BIN)
|
||||
.args([
|
||||
"sweep", &fixture,
|
||||
"--axis", "stage1_signal.fast.length=2,4",
|
||||
"--axis", "stage1_signal.slow.length=8,16",
|
||||
"--trace", "f",
|
||||
])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura sweep blueprint");
|
||||
assert!(sweep.status.success(), "sweep exit: {:?} stderr={}", sweep.status, String::from_utf8_lossy(&sweep.stderr));
|
||||
|
||||
// `--trace f` names the family `f-0`; reproduce it from disk.
|
||||
let out = Command::new(BIN)
|
||||
.args(["reproduce", "f-0"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura reproduce");
|
||||
assert!(out.status.success(), "reproduce exit: {:?} stderr={}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||||
// one verdict line per member (all bit-identical) + the summary; the label renders
|
||||
// param values portably (`length=2`, not the `I64(2)` Debug form).
|
||||
assert_eq!(
|
||||
stdout.lines().filter(|l| l.contains("reproduced: bit-identical")).count(),
|
||||
4,
|
||||
"every member re-derives bit-identically: {stdout}"
|
||||
);
|
||||
assert!(stdout.contains("stage1_signal.fast.length=2"), "label renders values portably: {stdout}");
|
||||
assert!(!stdout.contains("I64("), "label must not leak the Scalar Debug form: {stdout}");
|
||||
assert!(stdout.contains("reproduced 4/4 members bit-identically"), "summary line: {stdout}");
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property (C1/C18 integrity net): when a persisted member's stored metrics no longer
|
||||
/// match its re-derivation, `aura reproduce` reports that member `DIVERGED` and exits 1
|
||||
/// — the non-happy branch of the verb. Forced by tampering one member's on-disk
|
||||
/// `total_pips` after the sweep (the store's bit-identical compare IS the integrity check).
|
||||
#[test]
|
||||
fn aura_reproduce_reports_a_diverged_member_and_exits_one() {
|
||||
let cwd = temp_cwd("blueprint-reproduce-diverge");
|
||||
let fixture = format!("{}/tests/fixtures/stage1_signal_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let sweep = Command::new(BIN)
|
||||
.args([
|
||||
"sweep", &fixture,
|
||||
"--axis", "stage1_signal.fast.length=2,4",
|
||||
"--axis", "stage1_signal.slow.length=8,16",
|
||||
"--trace", "f",
|
||||
])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura sweep blueprint");
|
||||
assert!(sweep.status.success(), "sweep exit: {:?} stderr={}", sweep.status, String::from_utf8_lossy(&sweep.stderr));
|
||||
|
||||
// corrupt the first member's stored `total_pips` so its re-run metrics differ.
|
||||
let store = cwd.join("runs/families.jsonl");
|
||||
let s = std::fs::read_to_string(&store).expect("read family store");
|
||||
let key = "\"total_pips\":";
|
||||
let at = s.find(key).expect("a member records total_pips") + key.len();
|
||||
let end = at + s[at..].find(',').expect("total_pips value terminates");
|
||||
let corrupted = format!("{}999.0{}", &s[..at], &s[end..]);
|
||||
std::fs::write(&store, corrupted).expect("write corrupted store");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["reproduce", "f-0"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura reproduce");
|
||||
assert_eq!(out.status.code(), Some(1), "a diverged member exits 1: stderr={}", String::from_utf8_lossy(&out.stderr));
|
||||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||||
assert!(stdout.contains("reproduced: DIVERGED"), "the tampered member is reported DIVERGED: {stdout}");
|
||||
assert!(stdout.contains("reproduced 3/4 members bit-identically"), "summary counts the diverged member: {stdout}");
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// E2E (acc 2): persist a blueprint sweep, then `aura reproduce <id>` re-derives every
|
||||
/// member bit-identically from the content-addressed store and exits 0.
|
||||
#[test]
|
||||
fn aura_reproduce_re_derives_a_persisted_sweep_bit_identically() {
|
||||
let cwd = temp_cwd("reproduce_roundtrip");
|
||||
let fixture = format!("{}/tests/fixtures/stage1_signal_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||
|
||||
let sweep = std::process::Command::new(BIN)
|
||||
.args([
|
||||
"sweep",
|
||||
&fixture,
|
||||
"--axis",
|
||||
"stage1_signal.fast.length=2",
|
||||
"--axis",
|
||||
"stage1_signal.slow.length=4,6",
|
||||
"--name",
|
||||
"smacross",
|
||||
])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("run aura sweep");
|
||||
assert!(sweep.status.success(), "sweep ok: {}", String::from_utf8_lossy(&sweep.stderr));
|
||||
|
||||
let repro = std::process::Command::new(BIN)
|
||||
.args(["reproduce", "smacross-0"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("run aura reproduce");
|
||||
let stdout = String::from_utf8_lossy(&repro.stdout);
|
||||
assert!(repro.status.success(), "reproduce exits 0: {}", String::from_utf8_lossy(&repro.stderr));
|
||||
assert!(
|
||||
stdout.contains("reproduced 2/2 members bit-identically"),
|
||||
"every member re-derives: {stdout}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// E2E (negative): `aura reproduce <unknown>` is a hard error (exit non-zero + named
|
||||
/// cause on stderr), distinct from `runs family <id>`'s treat-as-empty exit 0.
|
||||
#[test]
|
||||
fn aura_reproduce_rejects_an_unknown_family() {
|
||||
let cwd = temp_cwd("reproduce_unknown");
|
||||
let out = std::process::Command::new(BIN)
|
||||
.args(["reproduce", "no-such-family-1"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("run aura reproduce");
|
||||
assert!(!out.status.success(), "unknown family exits non-zero");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("no such family 'no-such-family-1'"),
|
||||
"names the cause: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,43 @@ impl Registry {
|
||||
}
|
||||
Ok(reports)
|
||||
}
|
||||
|
||||
/// The content-addressed blueprint store dir — a sibling of the runs store,
|
||||
/// `<runs.jsonl>.with_file_name("blueprints")`. A topology is stored once,
|
||||
/// keyed by its content id (`topology_hash`), so a whole sweep family's
|
||||
/// members share one stored blueprint (C18 tiny manifest; C11/C12 dedup).
|
||||
fn blueprints_dir(&self) -> PathBuf {
|
||||
self.path.with_file_name("blueprints")
|
||||
}
|
||||
|
||||
/// The single content-id→path mapping `put_blueprint` and `get_blueprint` both
|
||||
/// route through, so the store can never write one path and read another (a
|
||||
/// drifted key would silently break round-trip — `get` returns `None` and
|
||||
/// reproduction cannot re-derive the member, rather than erroring).
|
||||
fn blueprint_path(&self, hash: &str) -> PathBuf {
|
||||
self.blueprints_dir().join(format!("{hash}.json"))
|
||||
}
|
||||
|
||||
/// Write-once content-addressed put: `blueprints/<hash>.json` = `canonical_json`.
|
||||
/// Idempotent — the same content id always addresses identical canonical bytes,
|
||||
/// so a repeated write re-writes identical content. The registry does NOT
|
||||
/// verify `sha256(bytes) == hash` (no `sha2` dep here): the caller owns the
|
||||
/// hash, and reproduction's bit-identical metric compare is the integrity check.
|
||||
pub fn put_blueprint(&self, hash: &str, canonical_json: &str) -> Result<(), RegistryError> {
|
||||
fs::create_dir_all(self.blueprints_dir())?;
|
||||
fs::write(self.blueprint_path(hash), canonical_json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a stored blueprint by content id; `Ok(None)` if absent — the same
|
||||
/// treat-as-empty discipline `load` applies to a missing runs store.
|
||||
pub fn get_blueprint(&self, hash: &str) -> Result<Option<String>, RegistryError> {
|
||||
match fs::read_to_string(self.blueprint_path(hash)) {
|
||||
Ok(s) => Ok(Some(s)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(RegistryError::Io(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which metric a best-first comparison keys on. Resolves a metric *name* (a
|
||||
@@ -662,6 +699,17 @@ mod tests {
|
||||
assert_eq!(reg.load().expect("load missing"), Vec::<RunReport>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blueprint_store_round_trips_by_content_id() {
|
||||
let reg = Registry::open(temp_family_dir("blueprint_store_round_trip"));
|
||||
let canonical = r#"{"format_version":1,"input_roles":[],"nodes":[],"edges":[]}"#;
|
||||
reg.put_blueprint("deadbeef", canonical).expect("put");
|
||||
// exact bytes come back, keyed by content id
|
||||
assert_eq!(reg.get_blueprint("deadbeef").expect("get"), Some(canonical.to_string()));
|
||||
// an absent id is Ok(None), not an error (treat-as-empty discipline)
|
||||
assert_eq!(reg.get_blueprint("never-written").expect("get"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_line_is_a_parse_error_with_line_number() {
|
||||
let path = temp_path("corrupt");
|
||||
|
||||
Reference in New Issue
Block a user