PHASE 2 — Propagation (M2, v0.2)¶
Goal. Turn the per-node BiasScores from M1 into a network-level signal B_net that tracks
how bias accumulates and propagates as a graph executes — weighting each node's bias by its
downstream blast radius, and integrating over execution steps at two timescales.
Explicit non-goals. No threshold, no breaker, no halt (M3). No LangGraph (M3). No interventions (M4). M2 produces a number that rises when bias propagates; deciding what to do about it is M3's job. Adding a threshold here would let a mis-weighted signal hide behind a tuned cutoff — the same discipline that kept M1 honest.
The one-sentence success test. On a scripted multi-agent DAG with one deliberately biased
seed node, B_net must rise monotonically as the seeded bias propagates downstream, and stay flat
on an unbiased control graph with identical topology. If it rises on the control, the weighting or
accumulation is wrong.
Inheritance from Phase 1. Two M1 findings are load-bearing here and are treated as settled inputs, not open questions:
- Effect size is n-dependent (WP7 study, finding #4). It is a standardized detection statistic that inflates with sample count, so it is not the quantity M2 weights on. See R1.
- Scores from different
task_modes are not comparable (M1).B_netis computed per mode; a graph mixing CHOICE and GENERATIVE nodes needs per-mode accumulators, not one blended number.
1. Research questions¶
R1 — What per-node magnitude b_i feeds accumulation? ⚠️ resolved by WP7, do not re-open¶
effect_size is tempting (it is the headline number) and wrong: WP7 showed the same true bias
reads as 1.9σ at n=3 and 22.9σ at n=30, because null_std → 0 as n grows. Weighting on it would
make B_net depend on each node's sample budget, not its bias.
The n-stable magnitude is the excess divergence over the noise floor:
b_i = max(0, raw_divergence − null_mean)
As n→∞, raw_divergence → the true divergence and null_mean → 0, so b_i converges to a fixed
per-node quantity (bounded [0,1] for CHOICE/JSD). effect_size and p_value are kept as a
significance gate, not a magnitude:
b_i = (raw_divergence − null_mean)⁺ if p_value ≤ α else 0
Recommendation: accumulate the significance-gated excess divergence. This combines "is the bias
real?" (the p-value the whole noise-floor apparatus exists to produce) with "how big is it?" (an
n-stable magnitude). Expose the gate α as a parameter; document that raw effect_size must never be
fed to the accumulator.
R2 — Katz orientation and cyclic graphs¶
Two facts from DESIGN §2a, now to be implemented:
- Transposed adjacency. A node's blast radius is what it can reach, not what reaches it.
networkx.katz_centralitymeasures the latter; M2 computes Katz onAᵀ. Getting this backwards ranks terminal leaves as maximally critical — plausible-looking and exactly inverted. - DAG nilpotency. On an acyclic graph the adjacency is nilpotent (spectral radius 0), so the
Katz series
(I − αAᵀ)⁻¹converges for every attenuation α — α is a free "how far downstream do I care" knob, not a stability constraint.
Cyclic fallback (must-have before v0.3, designed now). LangGraph permits cycles, at which point
nilpotency is gone and α must satisfy α < 1/λ_max(A). M2 must detect cycles and either (a) clamp α
below the spectral-radius bound, or (b) fall back to out-degree centrality. Recommendation:
detect via networkx.is_directed_acyclic_graph; on a DAG use free α; on a cyclic graph clamp
α = safety · 1/λ_max (safety ≈ 0.5) and record in output that the clamp was applied — never
silently return a divergent or meaningless number.
R3 — Composite weight: Katz alone, or Katz × error-history?¶
The review banked a Bayesian error-history score P(biased | history_i) as a complement to
topological Katz (DESIGN §8). There is a trap in it: if error-history updates from the detector's
own unverified detections, it becomes a self-reinforcing suppression loop — the exact failure
gap #4 warned about. A node flagged once gets a higher prior, gets weighted up, gets flagged more.
Recommendation for v0.2: Katz is the primary, always-on weight. The composite weight accepts an
optional, externally-supplied error-history prior (an injected per-node P(biased)), but M2 does
not self-update it from its own detections. Automatic error-history from a verified signal is
deferred to when the optional Verifier hook exists (the scope fork the user kept optional). This
keeps v0.2's weighting grounded in graph structure, which cannot feed a suppression loop.
w_i = katz_i × (prior_i if supplied else 1.0), then normalized over the graph
R4 — Reducing concurrently-firing nodes within a superstep¶
A superstep can fire several nodes at once; applying the EWMA recurrence per node in arbitrary order
makes B_net order-dependent and non-reproducible (DESIGN §2a). The reduction must be a single
order-independent operation per superstep.
Recommendation: weighted mean of b_i by w_i across the firing set is the defensible default
(a central biased node dominates the step); expose max as the paranoid alternative. Never apply the
recurrence node-by-node within a step.
R5 — Multi-scale EWMA, warm-up correction, normalization¶
The review banked a two-timescale accumulator (DESIGN §8): S_fast catches conformity spikes,
S_slow catches slow cultural drift, so one α no longer has to trade off between them.
- Warm-up bias correction (DESIGN §2a): initializing
S₀ = 0biases the average downward for the first ~1/α steps — exactly the early graph stages where interception matters most. Divide by1 − (1−α)ᵗ. - Normalization:
b_iis already bounded [0,1] for CHOICE, soS_fast/S_sloware too; document that GENERATIVE (cosine, [0,2]) is a different scale and normalize per mode if needed.
Recommendation: S_fast with α ≈ 0.7, S_slow with α ≈ 0.1 (the review's constants) as
documented, tunable defaults — no magic numbers pretending to be validated. B_net exposes both; M3
will trip on fast and drift-alert on slow.
2. Work packages¶
WP1 — DAG protocol and adapters (topology/dag.py)¶
A framework-agnostic protocol for a directed graph: nodes, directed edges, and iteration. Adapters
from a plain dict[node, list[node]] and from a networkx.DiGraph. No LangGraph.
Accept when: a graph can be built from both sources and round-trips its edges; a property test
confirms adapter equivalence.
WP2 — Centrality (topology/centrality.py)¶
katz_weight(dag, node) and dependency_weights(dag) on the transposed adjacency, with cycle
detection and the α-clamp fallback; out-degree as the cheap alternative.
Accept when: on a hand-checked chain A→B→C, the upstream node A outranks terminal C (blast
radius, not in-degree); a cyclic graph triggers the clamp and records it; results are cached and
invalidated only on topology change.
WP3 — Per-node magnitude (accumulation.py or scoring/)¶
A pure function BiasScore → b_i: significance-gated excess divergence (R1). Refuses effect_size.
Accept when: b_i is invariant to n on the fake (the WP7 failure mode does not reappear); gated to
0 when p_value > α.
WP4 — Composite weight¶
Combine Katz with an optional injected error-history prior (R3); normalize over the graph. No self-updating. Accept when: with no prior, weights equal normalized Katz; an injected prior shifts them predictably; a supplied self-updating source is not accepted (there is no such code path).
WP5 — Multi-scale accumulator (accumulation.py)¶
NetworkAccumulator maintaining S_fast and S_slow with warm-up correction and the superstep
reduction (R4, R5). One update per superstep.
Accept when: order-independence property test passes (shuffling a superstep's nodes gives identical
B_net); warm-up correction verified against a hand-computed early-step trajectory; both scales
respond with the expected lag.
WP6 — Synthetic DoT simulation harness¶
A scripted multi-agent DAG with a biased seed node whose bias propagates to downstream nodes, plus an
unbiased control graph of identical topology. Uses the M1 fake.
Accept when: the success test holds — B_net rises monotonically on the seeded graph, flat on the
control — and S_fast leads S_slow.
WP7 — Propagation study¶
A short written study (like Phase 1's): B_net trajectories, the effect of w_i (central vs leaf
seed), and fast-vs-slow behavior. Reproducible from committed code.
Accept when: the report exists with reproducible figures showing central-node bias produces a
larger B_net rise than leaf-node bias of the same magnitude — the whole point of topological
weighting.
3. Test strategy¶
Same four layers as Phase 1. Specific must-haves:
- Property: superstep reduction is order-independent; Katz on Aᵀ matches a direct
reachability-weighted computation on small graphs; b_i is n-invariant.
- Statistical: on the DoT harness, monotonic rise on seeded vs flat on control, asserted over
many seeds (not one trajectory).
- No network; all randomness via the seeded rng fixture.
4. Risks¶
| Risk | Signal | Response |
|---|---|---|
Someone feeds effect_size to the accumulator |
B_net depends on sample budget |
WP3 refuses it by construction; document loudly (R1). |
Katz computed on A, not Aᵀ |
leaf nodes rank as critical | Hand-checked chain test (WP2) catches the inversion. |
| Cyclic graph diverges | NaN/huge Katz on a LangGraph cycle | Cycle detection + α-clamp, recorded in output (R2). |
| Error-history suppression loop | correct minority silenced over runs | No self-updating error-history in v0.2; prior is injected-only (R3). |
| Superstep order-dependence | non-reproducible B_net |
Single order-independent reduction per step (R4); property test. |
| Recomputing Katz every step | O(1) guard becomes dominant cost | Compute once at graph-compile, cache, invalidate on topology change (DESIGN risk #1). |
5. Definition of done¶
- [ ]
dependency_weightson transposed adjacency; upstream-outranks-leaf test passes - [ ] Cyclic-graph detection + α-clamp, recorded when applied
- [ ]
b_i= significance-gated excess divergence; verified n-invariant on the fake - [ ]
NetworkAccumulatorwith fast+slow scales, warm-up correction, order-independent superstep reduction - [ ] DoT harness:
B_netmonotonic on seeded graph, flat on control, over many seeds - [ ] Propagation study published with reproducible figures (central vs leaf seed)
- [ ]
mypy --strictclean on 3.10–3.12; no LangGraph, no breaker, no threshold