audit: param-gang cycle close — drift-clean, mirror-walk interleave pinned
Architect review over 962b249..fc9cf23: clean. What holds: check_gangs is the single structural predicate at all three minting boundaries (builder.rs:226, construction.rs:396, blueprint_serde.rs:222 — the eager op arm is the sanctioned by-identifier subset, connect/edge_kind_check precedent); identity stays debug-name-blind per #171 (gang/member names blanked, (node,pos,kind) survives); format version 1 + Tier-2 dormancy note consistent across code and C24 ledger; no stale two-axis / 'separate #61' claims survive in docs or comments; commit-body deviation claims verified true against the diff. The one [low] finding — the new collect_params<->expansion_map mirror-walk lockstep pair untested for gang members interleaved with other open params — is closed here (fix disposition) with ganged_interleave_shift_through_matches_member_bound_twin: LinComb.weights[1] ganged with Bias.scale while weights[0] stays open earlier on the SAME node and Sma.length follows the gang in walk order; equality asserted on param_space order, wiring, and full run traces vs the member-bound twin. Load-bearingness proven by an injected double-count bug failing the test. Verified: aura-engine lib 265/0, clippy -D warnings clean. refs #61
This commit is contained in:
@@ -1154,7 +1154,7 @@ mod tests {
|
||||
use crate::test_fixtures::{composite_sma_cross_harness, synthetic_prices};
|
||||
use crate::{f64_field, summarize, ParamRange, RandomSpace, RunManifest, VecSource};
|
||||
use aura_core::{Cell, Ctx, FieldSpec, Firing, NodeSchema, Timestamp};
|
||||
use aura_std::{Bias, Ema, Recorder, SimBroker, Sma, Sub};
|
||||
use aura_std::{Bias, Ema, LinComb, Recorder, SimBroker, Sma, Sub};
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// One knob fanning into two sibling open params passes the gate; the
|
||||
@@ -1447,6 +1447,152 @@ mod tests {
|
||||
assert!(!g_trace.is_empty());
|
||||
}
|
||||
|
||||
/// Interleaved-gang fixture (architect [low] finding, cycle close): one gang
|
||||
/// member sits at a NON-zero pos among its own node's open params
|
||||
/// (`LinComb`'s `weights[1]`, with `weights[0]` still open and EARLIER in
|
||||
/// walk order), the other member is a sibling node's sole param
|
||||
/// (`Bias.scale`), and a THIRD node's independent param (`Sma.length`) walks
|
||||
/// AFTER the gang — so `expansion_map`'s "un-ganged slot consumes the next
|
||||
/// public index" branch must correctly shift past the gang in the MIDDLE of
|
||||
/// the walk, not only at an edge of it.
|
||||
fn ganged_interleave() -> Composite {
|
||||
Composite::new(
|
||||
"sig3",
|
||||
vec![
|
||||
LinComb::builder(2).into(),
|
||||
Bias::builder().into(),
|
||||
Sma::builder().named("c").into(),
|
||||
],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "price".into(),
|
||||
targets: vec![
|
||||
Target { node: 0, slot: 0 },
|
||||
Target { node: 0, slot: 1 },
|
||||
Target { node: 1, slot: 0 },
|
||||
Target { node: 2, slot: 0 },
|
||||
],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![
|
||||
OutField { node: 0, field: 0, name: "lincomb_out".into() },
|
||||
OutField { node: 1, field: 0, name: "bias_out".into() },
|
||||
OutField { node: 2, field: 0, name: "sma_out".into() },
|
||||
],
|
||||
)
|
||||
.with_gangs(vec![Gang {
|
||||
name: "shared".into(),
|
||||
kind: ScalarKind::F64,
|
||||
members: vec![
|
||||
GangMember { node: 0, pos: 1, name: "weights[1]".into() },
|
||||
GangMember { node: 1, pos: 0, name: "scale".into() },
|
||||
],
|
||||
}])
|
||||
.expect("weights[1] (pos 1, not 0) ganged with bias.scale, both F64")
|
||||
}
|
||||
|
||||
/// The un-ganged twin of [`ganged_interleave`]: identical topology, all four
|
||||
/// params (`lincomb.weights[0]`, `lincomb.weights[1]`, `bias.scale`,
|
||||
/// `c.length`) stay independently open.
|
||||
fn unganged_interleave() -> Composite {
|
||||
Composite::new(
|
||||
"sig3",
|
||||
vec![
|
||||
LinComb::builder(2).into(),
|
||||
Bias::builder().into(),
|
||||
Sma::builder().named("c").into(),
|
||||
],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "price".into(),
|
||||
targets: vec![
|
||||
Target { node: 0, slot: 0 },
|
||||
Target { node: 0, slot: 1 },
|
||||
Target { node: 1, slot: 0 },
|
||||
Target { node: 2, slot: 0 },
|
||||
],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![
|
||||
OutField { node: 0, field: 0, name: "lincomb_out".into() },
|
||||
OutField { node: 1, field: 0, name: "bias_out".into() },
|
||||
OutField { node: 2, field: 0, name: "sma_out".into() },
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Wrap [`ganged_interleave`]/[`unganged_interleave`] under a recording root
|
||||
/// and run the synthetic price fixture, taping all THREE composite outputs
|
||||
/// (one per node): topology here is param-value-invariant (`edges` equality
|
||||
/// alone would not catch a mis-shifted index landing a value on the wrong
|
||||
/// node), so the trace over every affected node is what actually detects it.
|
||||
fn run_triplet(bp: Composite, params: &[Scalar]) -> Vec<(Timestamp, Vec<Scalar>)> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let root = Composite::new(
|
||||
"h3",
|
||||
vec![
|
||||
BlueprintNode::Composite(bp),
|
||||
Recorder::builder(
|
||||
vec![ScalarKind::F64, ScalarKind::F64, ScalarKind::F64],
|
||||
Firing::Any,
|
||||
tx,
|
||||
)
|
||||
.into(),
|
||||
],
|
||||
vec![
|
||||
Edge { from: 0, to: 1, slot: 0, from_field: 0 },
|
||||
Edge { from: 0, to: 1, slot: 1, from_field: 1 },
|
||||
Edge { from: 0, to: 1, slot: 2, from_field: 2 },
|
||||
],
|
||||
vec![Role {
|
||||
name: "src".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![],
|
||||
);
|
||||
let prices = crate::test_fixtures::synthetic_prices();
|
||||
let mut h = root.bootstrap_with_params(params.to_vec()).expect("bootstraps");
|
||||
h.run(vec![Box::new(VecSource::new(prices))]);
|
||||
rx.try_iter().collect()
|
||||
}
|
||||
|
||||
/// THE property under test: `collect_params` (public `param_space` order) and
|
||||
/// `expansion_map` (raw-slot -> public-index) mirror each other even when a
|
||||
/// gang's first member is NOT its node's first open param and an independent
|
||||
/// param on a LATER node follows the gang in walk order — the "shift-through"
|
||||
/// branch (an un-ganged slot consuming the next public index around a gang)
|
||||
/// fires in the MIDDLE of the walk, not only at an edge. Values are pairwise
|
||||
/// distinct (7 / 4 / 3) so a mis-mapped index changes which node receives
|
||||
/// which value, diverging the run trace. `c.length` stays small (3) so the
|
||||
/// SMA warms up within the 7-row synthetic price fixture.
|
||||
#[test]
|
||||
fn ganged_interleave_shift_through_matches_member_bound_twin() {
|
||||
let space = ganged_interleave().param_space();
|
||||
let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect();
|
||||
let kinds: Vec<ScalarKind> = space.iter().map(|p| p.kind).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["lincomb.weights[0]", "shared", "c.length"],
|
||||
"the gang publishes at its first member's walk position, flanked by \
|
||||
an earlier independent param on the SAME node and a later \
|
||||
independent param on a DIFFERENT node"
|
||||
);
|
||||
assert_eq!(kinds, [ScalarKind::F64, ScalarKind::F64, ScalarKind::I64]);
|
||||
|
||||
let ganged_point = [Scalar::f64(7.0), Scalar::f64(4.0), Scalar::i64(3)];
|
||||
let twin_point = [Scalar::f64(7.0), Scalar::f64(4.0), Scalar::f64(4.0), Scalar::i64(3)];
|
||||
|
||||
let g = ganged_interleave().compile_with_params(&ganged_point).expect("ganged compile");
|
||||
let t = unganged_interleave().compile_with_params(&twin_point).expect("twin compile");
|
||||
assert_eq!(g.edges, t.edges, "identical wiring");
|
||||
|
||||
let g_trace = run_triplet(ganged_interleave(), &ganged_point);
|
||||
let t_trace = run_triplet(unganged_interleave(), &twin_point);
|
||||
assert_eq!(g_trace, t_trace, "ganged run diverged from the member-bound twin");
|
||||
assert!(!g_trace.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_kind_check_accepts_match_and_rejects_mismatch() {
|
||||
use super::edge_kind_check;
|
||||
|
||||
Reference in New Issue
Block a user