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 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}