Skip to main content

gam_solve/
row_sampling_measure.rs

1//! `RowSamplingMeasure` — the Fisher-mass **enrichment** producer (role (c) of #980).
2//!
3//! # What this is, and what it must never be
4//!
5//! A [`RowSamplingMeasure`] turns a [`RowMetric`] into a per-row **sampling measure**:
6//! a normalized non-negative weight per row, proportional to that row's
7//! behavioral *liveness* (its output-Fisher mass). It exists for **discovery /
8//! seeding only** — to OVERSAMPLE the behaviorally-live rows so that a rare but
9//! behaviorally-important feature (few rows, high Fisher mass, drowned among
10//! many common low-coupling rows) is actually *seen* by a discovery batch.
11//!
12//! ## The load-bearing invariant
13//!
14//! **The measure NEVER enters the reconstruction loss, the gradient, the
15//! evidence criterion, or any optimizer-facing quantity.** Sampling ADDS
16//! attention; it never reweights representation. Concretely:
17//!
18//! * it does not multiply any residual, any `quad_form`, any whitened Jacobian,
19//!   or any penalty;
20//! * it does not feed REML / LAML, the ρ trust-region ratio, or `φ̂`;
21//! * it only chooses *which rows a discovery/seeding pass looks at first*, and
22//!   how many times, leaving every per-row loss bit-for-bit unchanged.
23//!
24//! This is the dual of the #980 failure mode (where an output-Fisher inner
25//! product silently replaced the reconstruction loss): here the Fisher mass is
26//! used *strictly* as an attention prior over rows, with the loss untouched.
27//! The enrichment ordering returns row indices with multiplicity — the consumer
28//! visits those rows for *seeding/proposal* purposes; the fit it then runs on
29//! any selected row uses the unmodified per-row objective.
30//!
31//! # Graceful degradation (absent harvest ⇒ today's behavior)
32//!
33//! The measure is **magic-by-default**, mirroring [`RowMetric`]:
34//!
35//! * [`MetricProvenance::Euclidean`] (no per-row Fisher factors were harvested)
36//!   ⇒ every row's liveness is identical (`tr(I_p) = p`), so the measure is
37//!   **exactly uniform** and the enrichment ordering is the plain index order
38//!   with uniform multiplicity. Absent harvest is therefore bit-for-bit today's
39//!   "look at every row equally" behavior, never an error.
40//! * A factored provenance ([`MetricProvenance::OutputFisher`] /
41//!   [`MetricProvenance::WhitenedStructured`]) ⇒ rows are weighted by their
42//!   `tr(M_n)` Fisher mass, oversampling the live rows.
43//!
44//! Any pathological metric (all-zero mass, a non-finite block) also degrades to
45//! the uniform measure rather than producing a degenerate or `NaN` sampling
46//! distribution.
47//!
48//! # Why `tr(M_n)` is the right liveness scalar
49//!
50//! The per-row metric `M_n = U_n U_nᵀ` is the output-Fisher inner product on
51//! latent motion at row `n`. Its trace `tr(M_n) = Σ_i e_iᵀ M_n e_i =
52//! Σ_i fisher_mass(n, e_i)` is the total behavioral mass of that row summed over
53//! output coordinates — basis-independent and exactly the quantity
54//! [`RowMetric::fisher_mass`] reports for a unit of motion along each axis. It
55//! is the canonical row liveness derivable from the metric *alone*, with no
56//! external tangent supplied, and it collapses to the constant `p` under
57//! Euclidean — which is precisely the uniform-measure degeneracy we want.
58
59use gam_linalg::utils::splitmix64_hash;
60use gam_problem::{MetricProvenance, RowMetric};
61
62/// Where a [`RowSamplingMeasure`] came from — the honest record of whether the
63/// enrichment is real (Fisher-mass driven) or the graceful uniform fallback.
64#[derive(Clone, Copy, PartialEq, Eq, Debug)]
65pub enum MeasureProvenance {
66    /// No behavioral signal was available (Euclidean metric, or a degenerate
67    /// metric that produced no usable mass). The measure is exactly uniform:
68    /// every row carries weight `1 / n`. This is bit-for-bit "look at every row
69    /// equally" — today's behavior with no harvest.
70    Uniform,
71    /// The measure is `∝ tr(M_n)` from a factored [`RowMetric`]. Behaviorally
72    /// live rows carry proportionally more sampling weight. The carried
73    /// [`MetricProvenance`] is the metric provenance that produced the mass, so
74    /// a consumer can certify the inner product behind the enrichment.
75    FisherMass(MetricProvenance),
76}
77
78/// A per-row **sampling measure** over `n` rows, normalized to sum to 1.
79///
80/// Built from a [`RowMetric`] via [`RowSamplingMeasure::from_metric`]. The weights are a
81/// proper probability measure (non-negative, finite, summing to 1) used for
82/// **discovery/seeding oversampling only** — see the module docs for the
83/// invariant that it touches no loss / gradient / criterion.
84#[derive(Clone, Debug)]
85pub struct RowSamplingMeasure {
86    provenance: MeasureProvenance,
87    /// Normalized per-row sampling weights; `weights.len() == n_rows` and
88    /// `Σ weights == 1` (exactly uniform `1/n` in the fallback).
89    weights: Vec<f64>,
90}
91
92/// Certified coreset error budget carried to race consumers.
93#[derive(Clone, Copy, Debug, PartialEq)]
94pub struct CoresetCertificate {
95    /// Spectral approximation radius for the log-determinant term:
96    /// `(1 - eps_spectral)H <= H_C <= (1 + eps_spectral)H` on the effective
97    /// eigenspace.
98    pub eps_spectral: f64,
99    /// Additive likelihood error radius supplied by the sensitivity coreset on
100    /// its documented chart ball.
101    pub eps_likelihood: f64,
102    /// Rank of the factored border plus active-coordinate subspace actually
103    /// certified. Null directions of the summed row sketch are excluded.
104    pub dim_effective: usize,
105    /// Number of distinct rows retained by the coreset.
106    pub n_selected: usize,
107}
108
109impl CoresetCertificate {
110    pub fn new(
111        eps_spectral: f64,
112        eps_likelihood: f64,
113        dim_effective: usize,
114        n_selected: usize,
115    ) -> Result<Self, String> {
116        if !(eps_spectral.is_finite() && eps_spectral >= 0.0 && eps_spectral < 1.0) {
117            return Err(format!(
118                "coreset certificate requires 0 <= eps_spectral < 1, got {eps_spectral}"
119            ));
120        }
121        if !(eps_likelihood.is_finite() && eps_likelihood >= 0.0) {
122            return Err(format!(
123                "coreset certificate requires finite non-negative eps_likelihood, got {eps_likelihood}"
124            ));
125        }
126        Ok(Self {
127            eps_spectral,
128            eps_likelihood,
129            dim_effective,
130            n_selected,
131        })
132    }
133
134    /// Worst-case log-determinant transfer error implied by the spectral
135    /// certificate.
136    pub fn logdet_error_bound(&self) -> f64 {
137        self.dim_effective as f64 * ((1.0 + self.eps_spectral) / (1.0 - self.eps_spectral)).ln()
138    }
139
140    /// Race-transfer margin: consumers must require a coreset decision margin
141    /// strictly above this value before inheriting the full-corpus verdict.
142    pub fn race_transfer_margin(&self) -> f64 {
143        2.0 * (self.logdet_error_bound() + self.eps_likelihood)
144    }
145
146}
147
148/// Certificate gate for coreset-backed race decisions.
149#[derive(Clone, Copy, Debug, PartialEq)]
150pub enum CoresetMarginVerdict {
151    Certified {
152        decision_margin: f64,
153        required_margin: f64,
154    },
155    InsufficientMargin {
156        decision_margin: f64,
157        required_margin: f64,
158    },
159}
160
161/// Output of deterministic BSS spectral row selection.
162#[derive(Clone, Debug, PartialEq)]
163pub struct SpectralCoreset {
164    /// Distinct selected row indices, ascending.
165    pub indices: Vec<usize>,
166    /// Non-negative row weights aligned with `indices`.
167    pub weights: Vec<f64>,
168    /// Spectral certificate for this row coreset. `eps_likelihood` is zero here;
169    /// combine with a sensitivity certificate before certifying full evidence.
170    pub certificate: CoresetCertificate,
171}
172
173/// Greedy deterministic sensitivity coreset under a row budget.
174#[derive(Clone, Debug, PartialEq)]
175pub struct SensitivityCoreset {
176    /// Selected rows sorted by decreasing sensitivity, then row index.
177    pub indices: Vec<usize>,
178    /// Sensitivity mass retained by the selected rows.
179    pub selected_sensitivity_mass: f64,
180    /// Sensitivity mass not retained by the budget. A likelihood consumer can
181    /// map this to its additive `eps_likelihood` on the documented chart ball.
182    pub residual_sensitivity_mass: f64,
183}
184
185impl RowSamplingMeasure {
186    /// Build the enrichment measure from a [`RowMetric`].
187    ///
188    /// The per-row liveness is the Fisher mass `tr(M_n)` read from the metric's
189    /// validated PSD blocks. The result is normalized to a proper sampling
190    /// measure. Degrades to the **uniform** measure (every row `1/n`) when the
191    /// metric is Euclidean, carries no usable mass (all rows ≤ 0), or yields any
192    /// non-finite mass — never an error, mirroring [`RowMetric`]'s
193    /// magic-by-default discipline.
194    ///
195    /// This function reads only the metric's geometry; it writes nothing into
196    /// the metric, the loss, the gradient, or any criterion.
197    pub fn from_metric(metric: &RowMetric) -> Self {
198        let n = metric.n_rows();
199        if n == 0 {
200            return Self {
201                provenance: MeasureProvenance::Uniform,
202                weights: Vec::new(),
203            };
204        }
205
206        // Euclidean ⇒ exactly uniform by construction. Short-circuit so the
207        // fallback is bit-for-bit `1/n`, not "tr(I_p)=p then renormalize" (which
208        // is the same value, but the explicit path documents intent and avoids
209        // any floating-point renormalization noise).
210        if matches!(metric.provenance(), MetricProvenance::Euclidean) {
211            return Self::uniform(n);
212        }
213
214        let mass = per_row_fisher_mass(metric);
215        Self::from_masses(metric.provenance(), mass)
216    }
217
218    /// The uniform measure over `n` rows: every row weight `1 / n`. The graceful
219    /// fallback and the explicit "no behavioral harvest" measure.
220    pub fn uniform(n: usize) -> Self {
221        let w = if n == 0 { 0.0 } else { 1.0 / n as f64 };
222        Self {
223            provenance: MeasureProvenance::Uniform,
224            weights: vec![w; n],
225        }
226    }
227
228    /// Construct from raw per-row masses, normalizing to a proper measure.
229    /// Falls back to uniform if the masses carry no usable signal.
230    ///
231    /// Crate-visible so the two-tier harvest (`gam_inference::harvest`)
232    /// can lift designed-subsample Fisher masses to a full-corpus measure
233    /// through the same validation/normalization path.
234    pub fn from_masses(metric_provenance: MetricProvenance, masses: Vec<f64>) -> Self {
235        let n = masses.len();
236        if n == 0 {
237            return Self::uniform(0);
238        }
239        // Clamp negatives to zero (a validated PSD block has `tr ≥ 0`, but a
240        // tiny normalizer round-off could dip below) and reject non-finite.
241        let mut total = 0.0_f64;
242        let mut clean = vec![0.0_f64; n];
243        let mut all_finite = true;
244        for (i, &m) in masses.iter().enumerate() {
245            if !m.is_finite() {
246                all_finite = false;
247                break;
248            }
249            let v = if m > 0.0 { m } else { 0.0 };
250            clean[i] = v;
251            total += v;
252        }
253
254        if !all_finite || !(total > 0.0) {
255            // No usable behavioral signal ⇒ degrade to uniform, never NaN.
256            return Self::uniform(n);
257        }
258
259        let inv = 1.0 / total;
260        for w in clean.iter_mut() {
261            *w *= inv;
262        }
263        Self {
264            provenance: MeasureProvenance::FisherMass(metric_provenance),
265            weights: clean,
266        }
267    }
268
269    /// The normalized per-row sampling weights (`Σ == 1`). Read-only; this is a
270    /// sampling measure, never a loss weight.
271    pub fn weights(&self) -> &[f64] {
272        &self.weights
273    }
274
275    /// The measure's provenance — `Uniform` (graceful fallback / no harvest) or
276    /// `FisherMass` (real behavioral enrichment).
277    pub fn provenance(&self) -> MeasureProvenance {
278        self.provenance
279    }
280
281    /// Number of rows the measure is defined over.
282    pub fn n_rows(&self) -> usize {
283        self.weights.len()
284    }
285
286    /// Deterministic **systematic-resampling** enrichment ordering.
287    ///
288    /// Returns a length-`count` vector of row indices drawn `∝ weights`, using
289    /// low-variance systematic resampling with a fixed, *index-derived* jitter —
290    /// there is **no clock randomness**; the same `(measure, count, seed)`
291    /// always yields the same ordering. Behaviorally-live rows therefore appear
292    /// with multiplicity proportional to their Fisher mass, so a rare-but-live
293    /// feature's rows are oversampled relative to uniform.
294    ///
295    /// Systematic resampling places `count` equally spaced pointers
296    /// `(j + u) / count`, `j = 0..count`, against the cumulative weight CDF and
297    /// emits the row each pointer lands in. The single offset `u ∈ [0, 1)` is a
298    /// `splitmix64`-hash of `seed` (deterministic), giving an unbiased draw
299    /// whose per-row expected count is `count · weights[row]` while guaranteeing
300    /// every weight-`≥ 1/count` row appears at least once (the recall property
301    /// the rare-feature control asserts).
302    ///
303    /// The uniform fallback reproduces an even, deterministic round-robin over
304    /// all rows — i.e. plain attention to every row, today's behavior.
305    ///
306    /// This ordering is consumed **only** by a discovery/seeding pass. The rows
307    /// it names carry their ordinary, unmodified per-row objective.
308    pub fn enrichment_order(&self, count: usize, seed: u64) -> Vec<usize> {
309        let n = self.weights.len();
310        if n == 0 || count == 0 {
311            return Vec::new();
312        }
313
314        // Deterministic offset u ∈ [0, 1) from the seed (index-/seed-derived,
315        // never the clock). 53-bit mantissa for an exact double in [0, 1).
316        let u = {
317            let bits = splitmix64_hash(seed ^ ENRICHMENT_SALT);
318            let mantissa = (bits >> 11) as f64; // top 53 bits
319            mantissa / ((1_u64 << 53) as f64)
320        };
321
322        // Cumulative distribution over rows. `weights` already sums to 1; guard
323        // the last bucket to exactly 1.0 against round-off so every pointer
324        // lands in a valid row.
325        let mut cdf = vec![0.0_f64; n];
326        let mut acc = 0.0_f64;
327        for i in 0..n {
328            acc += self.weights[i];
329            cdf[i] = acc;
330        }
331        cdf[n - 1] = 1.0;
332
333        let mut out = Vec::with_capacity(count);
334        let step = 1.0 / count as f64;
335        let mut cursor = 0usize;
336        for j in 0..count {
337            let pointer = (j as f64 + u) * step;
338            // Advance the CDF cursor to the first bucket whose cumulative mass
339            // covers the pointer. Monotone in `j`, so this is one linear sweep.
340            while cursor < n - 1 && pointer > cdf[cursor] {
341                cursor += 1;
342            }
343            out.push(cursor);
344        }
345        out
346    }
347
348}
349
350/// A designed importance subsample with honest Horvitz–Thompson likelihood
351/// weights — what a frontier fit sums over instead of the full corpus
352/// (#987 / #973). Produced by `RowSamplingMeasure::designed_subsample`.
353#[derive(Clone, Debug)]
354pub struct DesignedRowSample {
355    /// Provenance of the measure that shaped the design (uniform fallback or
356    /// Fisher mass), echoed for consumer certification.
357    pub provenance: MeasureProvenance,
358    /// Selected row indices, ascending.
359    pub rows: Vec<usize>,
360    /// Per-selected-row likelihood weight `1 / π_i`, aligned with `rows`.
361    /// Multiplying row `i`'s loss term by this makes the subsampled criterion
362    /// unbiased for the full-corpus criterion.
363    pub likelihood_weights: Vec<f64>,
364    /// `Σ π_i` — the design's expected sample size (≈ the requested budget;
365    /// Madow selection realizes `⌊·⌋` or `⌈·⌉` of it).
366    pub expected_size: f64,
367}
368
369impl DesignedRowSample {
370    /// Number of rows actually selected.
371    pub fn len(&self) -> usize {
372        self.rows.len()
373    }
374
375    pub fn is_empty(&self) -> bool {
376        self.rows.is_empty()
377    }
378
379}
380
381/// A **certified** designed subsample (#1012): the rows that certify BOTH
382/// evidence halves within the target `eps`, their deterministic BSS /
383/// sensitivity weights, and the [`CoresetCertificate`] a race consumer gates
384/// the verdict transfer against. Produced by
385/// `RowSamplingMeasure::designed_subsample_certified`.
386#[derive(Clone, Debug)]
387pub struct CertifiedRowSample {
388    /// Provenance of the measure that shaped the design.
389    pub provenance: MeasureProvenance,
390    /// Selected row indices, ascending (union of the spectral and sensitivity
391    /// coresets).
392    pub rows: Vec<usize>,
393    /// Per-selected-row weight aligned with `rows`: the BSS spectral weight
394    /// where the row was chosen for the log-determinant half, else the
395    /// Horvitz–Thompson scale-up for a likelihood-only row.
396    pub weights: Vec<f64>,
397    /// The certificate bounding the worst-case evidence transfer error. Feed
398    /// [`CoresetCertificate::race_transfer_margin`] to the race consumer's
399    /// margin gate.
400    pub certificate: CoresetCertificate,
401}
402
403impl CertifiedRowSample {
404    pub fn len(&self) -> usize {
405        self.rows.len()
406    }
407
408    pub fn is_empty(&self) -> bool {
409        self.rows.is_empty()
410    }
411
412    /// The race-transfer margin a consumer must clear before inheriting the
413    /// full-corpus verdict from this coreset — the shared #1011/#1012 seam.
414    pub fn race_transfer_margin(&self) -> f64 {
415        self.certificate.race_transfer_margin()
416    }
417}
418
419/// Salt mixed into the enrichment seed so the offset hash is distinct from any
420/// other `splitmix64_hash` use of the same numeric seed elsewhere in the crate.
421const ENRICHMENT_SALT: u64 = 0x980E_1C45_F00D_AC70;
422
423/// Per-row Fisher mass `tr(M_n)` from the metric's criterion-facing traces.
424///
425/// The traces are recorded at metric construction (un-floored), so the solver
426/// `δ` never enters the measure — consistent with the `RowMetric` #747
427/// discipline, and irrelevant anyway because the measure feeds no criterion.
428/// Pure read; touches nothing.
429pub fn per_row_fisher_mass(metric: &RowMetric) -> Vec<f64> {
430    metric.row_traces().to_vec()
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use ndarray::Array2;
437    use std::sync::Arc;
438
439    fn factors_from_rows(rows: &[Vec<f64>], p: usize, rank: usize) -> Arc<Array2<f64>> {
440        let n = rows.len();
441        let mut u = Array2::<f64>::zeros((n, p * rank));
442        for (r, row) in rows.iter().enumerate() {
443            for (c, &v) in row.iter().enumerate() {
444                u[[r, c]] = v;
445            }
446        }
447        Arc::new(u)
448    }
449
450    #[test]
451    fn all_zero_mass_degrades_to_uniform() {
452        let rows = vec![vec![0.0], vec![0.0], vec![0.0]];
453        let u = factors_from_rows(&rows, 1, 1);
454        let metric = RowMetric::output_fisher(u, 1, 1).expect("of");
455        let measure = RowSamplingMeasure::from_metric(&metric);
456        assert_eq!(measure.provenance(), MeasureProvenance::Uniform);
457        for &w in measure.weights() {
458            assert!((w - 1.0 / 3.0).abs() < 1e-12);
459        }
460    }
461
462    #[test]
463    fn enrichment_order_is_deterministic() {
464        let rows = vec![vec![1.0], vec![3.0], vec![1.0]];
465        let u = factors_from_rows(&rows, 1, 1);
466        let metric = RowMetric::output_fisher(u, 1, 1).expect("of");
467        let measure = RowSamplingMeasure::from_metric(&metric);
468        let a = measure.enrichment_order(20, 7);
469        let b = measure.enrichment_order(20, 7);
470        assert_eq!(a, b, "same seed must give identical ordering");
471        let c = measure.enrichment_order(20, 8);
472        // Different seed ⇒ (generally) different ordering, but same length.
473        assert_eq!(c.len(), 20);
474    }
475
476    #[test]
477    fn enrichment_oversamples_loud_row() {
478        // Row 1 has 9x the mass of rows 0 and 2.
479        let rows = vec![vec![1.0], vec![3.0], vec![1.0]];
480        let u = factors_from_rows(&rows, 1, 1);
481        let metric = RowMetric::output_fisher(u, 1, 1).expect("of");
482        let measure = RowSamplingMeasure::from_metric(&metric);
483        let count = 110;
484        let order = measure.enrichment_order(count, 1);
485        let loud = order.iter().filter(|&&r| r == 1).count();
486        let quiet0 = order.iter().filter(|&&r| r == 0).count();
487        // Expected: 9/11 of 110 = 90 for the loud row, 10 each for the quiet.
488        assert!(
489            loud > quiet0 * 5,
490            "loud row must be oversampled: loud={loud} quiet0={quiet0}"
491        );
492    }
493
494}