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}