gam_sae/inference/probe_runner.rs
1//! `ProbeRunner` — the closed loop between the steering primitive
2//! ([`crate::inference::steering`]) and the anytime-valid structure-evidence
3//! ledger ([`gam_terms::inference::structure_evidence`]).
4//!
5//! Both halves are implemented and tested in isolation; nothing wired them into
6//! a runnable experiment loop. This is that bridge. The evidence module decides
7//! WHICH claim to interrogate and HOW MUCH a probe should move the e-process
8//! (`plan_probe_for_contested_claim`); the steering module turns a chosen latent
9//! intervention into the on-manifold activation delta with its dosimetry and
10//! validity radius (`steer_delta`). The runner picks the contested claim, asks
11//! the planner for the experiment, realizes it through the steering primitive,
12//! and feeds the realized dose back into the ledger as anytime-valid evidence.
13//!
14//! # The discrimination coordinate
15//!
16//! For a contested claim about atom `k`, the two hypotheses are "this atom
17//! carries the steering move along its learned surface" (the alternative) versus
18//! "it does not" (the null). The steering primitive measures, in **nats of
19//! output-Fisher KL**, exactly how much behavioral effect a latent move along
20//! atom `k` actually delivers — its `predicted_nats` dose. That dose IS the
21//! expected per-observation log-growth of the deciding e-process under the
22//! alternative (the module docs' "the SAME quadratic form the steering dosimetry
23//! already computes, repurposed"). So each candidate latent move becomes a
24//! one-dimensional [`CandidateProbe`] whose hypothesis disagreement, read through
25//! the identity Fisher, reproduces the realized dose:
26//! `½ (μ₁ − μ₀)ᵀ F (μ₁ − μ₀) = predicted_nats` with `μ₀ = 0`,
27//! `μ₁ = √(2·predicted_nats)`, `F = [[1]]`. No fabricated metric — the real
28//! steering dose flows through the real planner.
29
30use ndarray::array;
31
32use gam_problem::RowMetric;
33use crate::inference::steering::{SteerPlan, steer_delta};
34use gam_terms::inference::structure_evidence::{
35 CandidateProbe, ClaimKind, ProbePlan, StructureLedger, plan_probe_for_contested_claim,
36};
37use crate::manifold::SaeManifoldTerm;
38
39/// The level the contested-claim selection and budget are computed at. Fixed so
40/// a probe can never be shopped across α after seeing the evidence (mirrors
41/// [`gam_terms::inference::structure_evidence::AtomBirthGate`]'s construction-time α).
42const PROBE_DESIGN_ALPHA: f64 = 0.05;
43
44/// Latent step length each candidate probe moves the contested atom by, per
45/// axis, away from its fitted representative coordinate. A modest step keeps the
46/// move inside the surface's local regime; the steering primitive reports the
47/// validity radius so an over-long move is flagged, never silently clipped.
48const PROBE_LATENT_STEP: f64 = 0.5;
49
50/// A planned probe carried alongside its realized steering intervention: the
51/// experiment-design output (`plan`), the on-manifold activation delta and
52/// dosimetry the steering primitive produced for it (`steer`), and the realized
53/// behavioral dose in nats once it has been measured (`realized_nats`, `None`
54/// until [`ProbeRunner::absorb`] banks it).
55#[derive(Clone, Debug)]
56pub struct RealizedProbe {
57 /// The experiment plan for the most contested claim: which candidate probe,
58 /// its expected per-observation log-growth, and the resolution budget.
59 pub plan: ProbePlan,
60 /// The realized steering intervention for the chosen candidate: the
61 /// activation-space δ, predicted dose, validity radius, off-manifold guard.
62 pub steer: SteerPlan,
63 /// The realized behavioral dose in nats once observed, banked by
64 /// [`ProbeRunner::absorb`] into the claim's e-process. `None` at design time.
65 pub realized_nats: Option<f64>,
66}
67
68/// The closed-loop probe runner over one fitted SAE-manifold term and its
69/// per-row output-Fisher metric.
70pub struct ProbeRunner<'a> {
71 /// The fitted term whose atoms the probes steer (read only).
72 pub term: &'a SaeManifoldTerm,
73 /// The per-row output-Fisher inner product the dose is measured through.
74 pub metric: &'a RowMetric,
75}
76
77impl<'a> ProbeRunner<'a> {
78 /// Design the next probe for the most contested claim in `ledger`.
79 ///
80 /// Picks the contested claim with the LEAST evidence (the one furthest from
81 /// the `1/α` Ville threshold — the most in need of interrogation), reads the
82 /// atom it concerns, and builds candidate steering moves along that atom's
83 /// latent axes from its fitted representative coordinate. Each candidate is
84 /// realized through [`steer_delta`] so its actual output-Fisher dose is
85 /// known; the candidates are handed to [`plan_probe_for_contested_claim`],
86 /// which selects the most discriminating one and converts the claim's
87 /// current evidence into a remaining budget. The selected candidate's
88 /// already-computed [`SteerPlan`] rides back in the result.
89 pub fn design_next(&self, ledger: &StructureLedger) -> Result<RealizedProbe, String> {
90 let (claim_idx, atom_k) = self.most_contested_atom_claim(ledger)?;
91 let current_log_e = ledger.claims()[claim_idx].evidence.current_e_value_log();
92
93 let candidates = self.candidate_steers(atom_k)?;
94 if candidates.is_empty() {
95 return Err(format!(
96 "ProbeRunner::design_next: atom {atom_k} (claim {claim_idx}) admits no steering \
97 candidate (zero latent dimension or no installed basis evaluator)"
98 ));
99 }
100
101 // Convert each realized steering dose into a one-dimensional candidate
102 // probe whose hypothesis disagreement, read through the identity Fisher,
103 // equals that dose: ½(μ₁)² = predicted_nats ⇒ μ₁ = √(2·dose).
104 let probes: Vec<CandidateProbe> = candidates
105 .iter()
106 .map(|steer| {
107 let dose = steer.predicted_nats.unwrap_or(0.0).max(0.0);
108 CandidateProbe {
109 delta: steer.delta.clone(),
110 predicted_mean_null: array![0.0],
111 predicted_mean_alt: array![(2.0 * dose).sqrt()],
112 }
113 })
114 .collect();
115 let fisher = array![[1.0]];
116
117 let plan =
118 plan_probe_for_contested_claim(&probes, &fisher, PROBE_DESIGN_ALPHA, current_log_e)
119 .ok_or_else(|| {
120 format!(
121 "ProbeRunner::design_next: no candidate probe discriminates the hypotheses \
122 for atom {atom_k} (every reachable steering move delivers zero output-Fisher \
123 dose — the claim is undecidable by steering, a finding not a failure)"
124 )
125 })?;
126
127 let steer = candidates.into_iter().nth(plan.probe).ok_or_else(|| {
128 format!(
129 "ProbeRunner::design_next: planner selected candidate {} of {} for atom \
130 {atom_k}",
131 plan.probe,
132 probes.len()
133 )
134 })?;
135
136 Ok(RealizedProbe {
137 plan,
138 steer,
139 realized_nats: None,
140 })
141 }
142
143 /// Absorb a realized probe outcome, updating the ledger's evidence for the
144 /// probe's claim.
145 ///
146 /// `realized_nats` is the dose the probe actually delivered when run (the
147 /// observed output-Fisher KL of the steered response). Under the local
148 /// Gaussian output model the alternative-vs-null log-likelihood ratio of one
149 /// such observation is exactly that dose, so it routes straight into the
150 /// claim's e-process through [`StructureLedger::absorb_probe_outcome`] as
151 /// `log(alt) − log(null) = realized_nats − 0`. The contract its docstring
152 /// requires — both hypotheses' densities frozen before the outcome — holds
153 /// here: the steering plan (and thus both predictions) was fixed at design
154 /// time, before any outcome existed.
155 pub fn absorb(&self, ledger: &mut StructureLedger, probe: &RealizedProbe, realized_nats: f64) {
156 let Ok((claim_idx, _)) = self.claim_for_steer(ledger, &probe.steer) else {
157 return;
158 };
159 // The realized log-LR of one observation under the local Gaussian model
160 // is the delivered dose; the null density contributes log-likelihood 0.
161 ledger
162 .absorb_probe_outcome(claim_idx, realized_nats, 0.0)
163 .ok();
164 }
165
166 /// The ledger index and atom index of the contested claim with the LEAST
167 /// accumulated evidence (the one furthest from certification). Only claims
168 /// naming a concrete atom — [`ClaimKind::AtomExists`] and
169 /// [`ClaimKind::GeometryKind`] — are steerable; binding-edge and custom
170 /// claims have no single atom to drive and are skipped.
171 fn most_contested_atom_claim(
172 &self,
173 ledger: &StructureLedger,
174 ) -> Result<(usize, usize), String> {
175 let mut best: Option<(usize, usize, f64)> = None;
176 for (idx, claim) in ledger.claims().iter().enumerate() {
177 let Some(atom_k) = steerable_atom(&claim.kind) else {
178 continue;
179 };
180 if atom_k >= self.term.k_atoms() {
181 continue;
182 }
183 let log_e = claim.evidence.current_e_value_log();
184 match best {
185 Some((_, _, best_log_e)) if best_log_e <= log_e => {}
186 _ => best = Some((idx, atom_k, log_e)),
187 }
188 }
189 best.map(|(idx, atom_k, _)| (idx, atom_k)).ok_or_else(|| {
190 "ProbeRunner: ledger has no contested claim naming a steerable atom in this term"
191 .to_string()
192 })
193 }
194
195 /// Find the ledger claim a realized steer belongs to: the contested
196 /// steerable claim whose atom matches the steer's atom index, least-evidence
197 /// first (the same selection `design_next` used).
198 fn claim_for_steer(
199 &self,
200 ledger: &StructureLedger,
201 steer: &SteerPlan,
202 ) -> Result<(usize, usize), String> {
203 let mut best: Option<(usize, f64)> = None;
204 for (idx, claim) in ledger.claims().iter().enumerate() {
205 if steerable_atom(&claim.kind) != Some(steer.atom) {
206 continue;
207 }
208 let log_e = claim.evidence.current_e_value_log();
209 match best {
210 Some((_, best_log_e)) if best_log_e <= log_e => {}
211 _ => best = Some((idx, log_e)),
212 }
213 }
214 best.map(|(idx, _)| (idx, steer.atom))
215 .ok_or_else(|| format!("ProbeRunner: no claim names steered atom {}", steer.atom))
216 }
217
218 /// Build the realized steering candidates for atom `atom_k`: a positive and
219 /// negative [`PROBE_LATENT_STEP`] move along each latent axis, from the
220 /// atom's fitted representative (most-active-row) coordinate. Each move is
221 /// realized through [`steer_delta`] so the planner sees its true dose.
222 fn candidate_steers(&self, atom_k: usize) -> Result<Vec<SteerPlan>, String> {
223 let t0 = self.representative_coordinate(atom_k);
224 let d = t0.len();
225 let mut out = Vec::with_capacity(2 * d);
226 for axis in 0..d {
227 for &sign in &[1.0_f64, -1.0_f64] {
228 let mut t_to = t0.clone();
229 t_to[axis] += sign * PROBE_LATENT_STEP;
230 out.push(steer_delta(self.term, self.metric, atom_k, &t0, &t_to)?);
231 }
232 }
233 Ok(out)
234 }
235
236 /// Atom `atom_k`'s fitted latent coordinate at its most-active row — the
237 /// representative operating point a probe perturbs away from. Falls back to
238 /// the origin when the atom is active on no row.
239 fn representative_coordinate(&self, atom_k: usize) -> Vec<f64> {
240 let assignments = self.term.assignment.assignments();
241 let n = self.term.n_obs();
242 let mut best_row = 0usize;
243 let mut best_mass = f64::NEG_INFINITY;
244 for row in 0..n {
245 let mass = assignments[[row, atom_k]];
246 if mass > best_mass {
247 best_mass = mass;
248 best_row = row;
249 }
250 }
251 self.term.assignment.coords[atom_k].row(best_row).to_vec()
252 }
253}
254
255/// The atom a structural claim is about, when it is one a single steering move
256/// can interrogate. `None` for claims with no single steerable atom (binding
257/// edges concern a pair; custom claims name no atom).
258fn steerable_atom(kind: &ClaimKind) -> Option<usize> {
259 match kind {
260 ClaimKind::AtomExists { atom } | ClaimKind::GeometryKind { atom, .. } => Some(*atom),
261 ClaimKind::BindingEdge { .. } | ClaimKind::Custom { .. } => None,
262 }
263}