Skip to main content

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 crate::inference::steering::{SteerPlan, steer_delta};
33use crate::manifold::SaeManifoldTerm;
34use gam_problem::RowMetric;
35use gam_terms::inference::structure_evidence::{
36    CandidateProbe, ClaimKind, ProbePlan, StructureLedger, plan_probe_for_contested_claim,
37};
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 claim_kind = &ledger.claims()[claim_idx].kind;
92        let current_log_e = ledger.claims()[claim_idx].evidence.current_e_value_log();
93
94        let candidates = self.candidate_steers(atom_k)?;
95        if candidates.is_empty() {
96            return Err(format!(
97                "ProbeRunner::design_next: atom {atom_k} (claim {claim_idx}) admits no steering \
98                 candidate (zero latent dimension or no installed basis evaluator)"
99            ));
100        }
101
102        // Convert each realized steering dose into a one-dimensional candidate
103        // probe whose hypothesis disagreement, read through the identity Fisher,
104        // equals that dose: ½(μ₁)² = predicted_nats ⇒ μ₁ = √(2·dose).
105        let probes: Vec<CandidateProbe> = candidates
106            .iter()
107            .map(|steer| {
108                let objective = self.probe_objective(claim_kind, steer);
109                CandidateProbe {
110                    delta: steer.delta.clone(),
111                    predicted_mean_null: array![0.0],
112                    predicted_mean_alt: array![(2.0 * objective).sqrt()],
113                }
114            })
115            .collect();
116        let fisher = array![[1.0]];
117
118        let plan =
119            plan_probe_for_contested_claim(&probes, &fisher, PROBE_DESIGN_ALPHA, current_log_e)
120                .ok_or_else(|| {
121                    format!(
122                        "ProbeRunner::design_next: no candidate probe discriminates the hypotheses \
123                     for atom {atom_k} (every reachable steering move delivers zero design \
124                     objective — the claim is undecidable by steering, a finding not a failure)"
125                    )
126                })?;
127
128        let steer = candidates.into_iter().nth(plan.probe).ok_or_else(|| {
129            format!(
130                "ProbeRunner::design_next: planner selected candidate {} of {} for atom \
131                     {atom_k}",
132                plan.probe,
133                probes.len()
134            )
135        })?;
136
137        Ok(RealizedProbe {
138            plan,
139            steer,
140            realized_nats: None,
141        })
142    }
143
144    /// Absorb a realized probe outcome, updating the ledger's evidence for the
145    /// probe's claim.
146    ///
147    /// `realized_nats` is the dose the probe actually delivered when run (the
148    /// observed output-Fisher KL of the steered response). Under the local
149    /// Gaussian output model the alternative-vs-null log-likelihood ratio of one
150    /// such observation is exactly that dose, so it routes straight into the
151    /// claim's e-process through [`StructureLedger::absorb_probe_outcome`] as
152    /// `log(alt) − log(null) = realized_nats − 0`. The contract its docstring
153    /// requires — both hypotheses' densities frozen before the outcome — holds
154    /// here: the steering plan (and thus both predictions) was fixed at design
155    /// time, before any outcome existed.
156    pub fn absorb(&self, ledger: &mut StructureLedger, probe: &RealizedProbe, realized_nats: f64) {
157        let Ok((claim_idx, _)) = self.claim_for_steer(ledger, &probe.steer) else {
158            return;
159        };
160        // The realized log-LR of one observation under the local Gaussian model
161        // is the delivered dose; the null density contributes log-likelihood 0.
162        ledger
163            .absorb_probe_outcome(claim_idx, realized_nats, 0.0)
164            .ok();
165    }
166
167    /// The ledger index and atom index of the contested claim with the LEAST
168    /// accumulated evidence (the one furthest from certification). Only claims
169    /// naming a concrete atom — [`ClaimKind::AtomExists`] and
170    /// [`ClaimKind::GeometryKind`] — are steerable; binding-edge and custom
171    /// claims have no single atom to drive and are skipped.
172    fn most_contested_atom_claim(
173        &self,
174        ledger: &StructureLedger,
175    ) -> Result<(usize, usize), String> {
176        let mut best: Option<(usize, usize, f64)> = None;
177        for (idx, claim) in ledger.claims().iter().enumerate() {
178            let Some(atom_k) = steerable_atom(&claim.kind) else {
179                continue;
180            };
181            if atom_k >= self.term.k_atoms() {
182                continue;
183            }
184            let log_e = claim.evidence.current_e_value_log();
185            match best {
186                Some((_, _, best_log_e)) if best_log_e <= log_e => {}
187                _ => best = Some((idx, atom_k, log_e)),
188            }
189        }
190        best.map(|(idx, atom_k, _)| (idx, atom_k)).ok_or_else(|| {
191            "ProbeRunner: ledger has no contested claim naming a steerable atom in this term"
192                .to_string()
193        })
194    }
195
196    /// Find the ledger claim a realized steer belongs to: the contested
197    /// steerable claim whose atom matches the steer's atom index, least-evidence
198    /// first (the same selection `design_next` used).
199    fn claim_for_steer(
200        &self,
201        ledger: &StructureLedger,
202        steer: &SteerPlan,
203    ) -> Result<(usize, usize), String> {
204        let mut best: Option<(usize, f64)> = None;
205        for (idx, claim) in ledger.claims().iter().enumerate() {
206            if steerable_atom(&claim.kind) != Some(steer.atom) {
207                continue;
208            }
209            let log_e = claim.evidence.current_e_value_log();
210            match best {
211                Some((_, best_log_e)) if best_log_e <= log_e => {}
212                _ => best = Some((idx, log_e)),
213            }
214        }
215        best.map(|(idx, _)| (idx, steer.atom))
216            .ok_or_else(|| format!("ProbeRunner: no claim names steered atom {}", steer.atom))
217    }
218
219    /// Design objective for a realized steering candidate. Existence claims use
220    /// the behavioral KL dose directly. Geometry adjudication is different: the
221    /// curvature signal scales as observed chart coverage to the fourth power,
222    /// so a token spent at an already-covered coordinate buys essentially no new
223    /// curvature-certification power. For geometry claims the candidate score is
224    /// therefore the expected increase in `extent^4` of the observed chart, per
225    /// probe token (all candidates here cost one token), rather than raw dose.
226    fn probe_objective(&self, claim_kind: &ClaimKind, steer: &SteerPlan) -> f64 {
227        match claim_kind {
228            ClaimKind::GeometryKind { .. } => self.chart_extent_fourth_gain(steer),
229            ClaimKind::AtomExists { .. }
230            | ClaimKind::BindingEdge { .. }
231            | ClaimKind::Custom { .. } => steer.predicted_nats.unwrap_or(0.0).max(0.0),
232        }
233    }
234
235    /// Increase in the atom's observed latent chart extent to the fourth power
236    /// after adding this probe endpoint. The extent is the root-mean-square axis
237    /// range, which is zero only for a collapsed chart and grows when any latent
238    /// direction obtains genuinely wider coverage.
239    fn chart_extent_fourth_gain(&self, steer: &SteerPlan) -> f64 {
240        let coords = self.term.assignment.coords[steer.atom].as_matrix();
241        let d = steer.t_to.len();
242        if d == 0 {
243            return 0.0;
244        }
245        let mut extent_sq = 0.0_f64;
246        let mut extended_extent_sq = 0.0_f64;
247        for axis in 0..d {
248            let mut lo = f64::INFINITY;
249            let mut hi = f64::NEG_INFINITY;
250            for row in 0..coords.nrows() {
251                let t = coords[[row, axis]];
252                if t.is_finite() {
253                    lo = lo.min(t);
254                    hi = hi.max(t);
255                }
256            }
257            if !lo.is_finite() || !hi.is_finite() {
258                lo = steer.t_from[axis];
259                hi = steer.t_from[axis];
260            }
261            let range = (hi - lo).max(0.0);
262            extent_sq += range * range;
263
264            let to = steer.t_to[axis];
265            let extended_range = (hi.max(to) - lo.min(to)).max(0.0);
266            extended_extent_sq += extended_range * extended_range;
267        }
268        let inv_d = 1.0 / d as f64;
269        let extent_fourth = (extent_sq * inv_d).powi(2);
270        let extended_extent_fourth = (extended_extent_sq * inv_d).powi(2);
271        (extended_extent_fourth - extent_fourth).max(0.0)
272    }
273
274    /// Build the realized steering candidates for atom `atom_k`: a positive and
275    /// negative [`PROBE_LATENT_STEP`] move along each latent axis, from the
276    /// atom's fitted representative (most-active-row) coordinate. Each move is
277    /// realized through [`steer_delta`] so the planner sees its true dose.
278    fn candidate_steers(&self, atom_k: usize) -> Result<Vec<SteerPlan>, String> {
279        let t0 = self.representative_coordinate(atom_k);
280        let d = t0.len();
281        let mut out = Vec::with_capacity(2 * d);
282        for axis in 0..d {
283            for &sign in &[1.0_f64, -1.0_f64] {
284                let mut t_to = t0.clone();
285                t_to[axis] += sign * PROBE_LATENT_STEP;
286                out.push(steer_delta(self.term, self.metric, atom_k, &t0, &t_to)?);
287            }
288        }
289        Ok(out)
290    }
291
292    /// Atom `atom_k`'s fitted latent coordinate at its most-active row — the
293    /// representative operating point a probe perturbs away from. Falls back to
294    /// the origin when the atom is active on no row.
295    fn representative_coordinate(&self, atom_k: usize) -> Vec<f64> {
296        let assignments = self.term.assignment.assignments();
297        let n = self.term.n_obs();
298        let mut best_row = 0usize;
299        let mut best_mass = f64::NEG_INFINITY;
300        for row in 0..n {
301            let mass = assignments[[row, atom_k]];
302            if mass > best_mass {
303                best_mass = mass;
304                best_row = row;
305            }
306        }
307        self.term.assignment.coords[atom_k].row(best_row).to_vec()
308    }
309}
310
311/// The atom a structural claim is about, when it is one a single steering move
312/// can interrogate. `None` for claims with no single steerable atom (binding
313/// edges concern a pair; custom claims name no atom).
314fn steerable_atom(kind: &ClaimKind) -> Option<usize> {
315    match kind {
316        ClaimKind::AtomExists { atom } | ClaimKind::GeometryKind { atom, .. } => Some(*atom),
317        ClaimKind::BindingEdge { .. } | ClaimKind::Custom { .. } => None,
318    }
319}