use ndarray::array;
use crate::inference::row_metric::RowMetric;
use crate::inference::steering::{SteerPlan, steer_delta};
use crate::inference::structure_evidence::{
CandidateProbe, ClaimKind, ProbePlan, StructureLedger, plan_probe_for_contested_claim,
};
use crate::terms::sae_manifold::SaeManifoldTerm;
const PROBE_DESIGN_ALPHA: f64 = 0.05;
const PROBE_LATENT_STEP: f64 = 0.5;
#[derive(Clone, Debug)]
pub struct RealizedProbe {
pub plan: ProbePlan,
pub steer: SteerPlan,
pub realized_nats: Option<f64>,
}
pub struct ProbeRunner<'a> {
pub term: &'a SaeManifoldTerm,
pub metric: &'a RowMetric,
}
impl<'a> ProbeRunner<'a> {
pub fn design_next(&self, ledger: &StructureLedger) -> Result<RealizedProbe, String> {
let (claim_idx, atom_k) = self.most_contested_atom_claim(ledger)?;
let current_log_e = ledger.claims()[claim_idx].evidence.current_e_value_log();
let candidates = self.candidate_steers(atom_k)?;
if candidates.is_empty() {
return Err(format!(
"ProbeRunner::design_next: atom {atom_k} (claim {claim_idx}) admits no steering \
candidate (zero latent dimension or no installed basis evaluator)"
));
}
let probes: Vec<CandidateProbe> = candidates
.iter()
.map(|steer| {
let dose = steer.predicted_nats.unwrap_or(0.0).max(0.0);
CandidateProbe {
delta: steer.delta.clone(),
predicted_mean_null: array![0.0],
predicted_mean_alt: array![(2.0 * dose).sqrt()],
}
})
.collect();
let fisher = array![[1.0]];
let plan =
plan_probe_for_contested_claim(&probes, &fisher, PROBE_DESIGN_ALPHA, current_log_e)
.ok_or_else(|| {
format!(
"ProbeRunner::design_next: no candidate probe discriminates the hypotheses \
for atom {atom_k} (every reachable steering move delivers zero output-Fisher \
dose — the claim is undecidable by steering, a finding not a failure)"
)
})?;
let steer = candidates.into_iter().nth(plan.probe).ok_or_else(|| {
format!(
"ProbeRunner::design_next: planner selected candidate {} of {} for atom \
{atom_k}",
plan.probe,
probes.len()
)
})?;
Ok(RealizedProbe {
plan,
steer,
realized_nats: None,
})
}
pub fn absorb(&self, ledger: &mut StructureLedger, probe: &RealizedProbe, realized_nats: f64) {
let Ok((claim_idx, _)) = self.claim_for_steer(ledger, &probe.steer) else {
return;
};
ledger
.absorb_probe_outcome(claim_idx, realized_nats, 0.0)
.ok();
}
fn most_contested_atom_claim(
&self,
ledger: &StructureLedger,
) -> Result<(usize, usize), String> {
let mut best: Option<(usize, usize, f64)> = None;
for (idx, claim) in ledger.claims().iter().enumerate() {
let Some(atom_k) = steerable_atom(&claim.kind) else {
continue;
};
if atom_k >= self.term.k_atoms() {
continue;
}
let log_e = claim.evidence.current_e_value_log();
match best {
Some((_, _, best_log_e)) if best_log_e <= log_e => {}
_ => best = Some((idx, atom_k, log_e)),
}
}
best.map(|(idx, atom_k, _)| (idx, atom_k)).ok_or_else(|| {
"ProbeRunner: ledger has no contested claim naming a steerable atom in this term"
.to_string()
})
}
fn claim_for_steer(
&self,
ledger: &StructureLedger,
steer: &SteerPlan,
) -> Result<(usize, usize), String> {
let mut best: Option<(usize, f64)> = None;
for (idx, claim) in ledger.claims().iter().enumerate() {
if steerable_atom(&claim.kind) != Some(steer.atom) {
continue;
}
let log_e = claim.evidence.current_e_value_log();
match best {
Some((_, best_log_e)) if best_log_e <= log_e => {}
_ => best = Some((idx, log_e)),
}
}
best.map(|(idx, _)| (idx, steer.atom))
.ok_or_else(|| format!("ProbeRunner: no claim names steered atom {}", steer.atom))
}
fn candidate_steers(&self, atom_k: usize) -> Result<Vec<SteerPlan>, String> {
let t0 = self.representative_coordinate(atom_k);
let d = t0.len();
let mut out = Vec::with_capacity(2 * d);
for axis in 0..d {
for &sign in &[1.0_f64, -1.0_f64] {
let mut t_to = t0.clone();
t_to[axis] += sign * PROBE_LATENT_STEP;
out.push(steer_delta(self.term, self.metric, atom_k, &t0, &t_to)?);
}
}
Ok(out)
}
fn representative_coordinate(&self, atom_k: usize) -> Vec<f64> {
let assignments = self.term.assignment.assignments();
let n = self.term.n_obs();
let mut best_row = 0usize;
let mut best_mass = f64::NEG_INFINITY;
for row in 0..n {
let mass = assignments[[row, atom_k]];
if mass > best_mass {
best_mass = mass;
best_row = row;
}
}
self.term.assignment.coords[atom_k].row(best_row).to_vec()
}
}
fn steerable_atom(kind: &ClaimKind) -> Option<usize> {
match kind {
ClaimKind::AtomExists { atom } | ClaimKind::GeometryKind { atom, .. } => Some(*atom),
ClaimKind::BindingEdge { .. } | ClaimKind::Custom { .. } => None,
}
}