gam-sae 0.3.153

Sparse-autoencoder latent-manifold terms for the gam penalized-likelihood engine
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! Fixed-K sparse, minibatched SAE trainer (#1026, "collapsed linear lane").
//!
//! This is an **additive, standalone** path that makes very large dictionaries
//! (`K` up to tens of thousands) tractable, where the exact-REML / Arrow-Schur
//! dense joint manifold solver in [`crate::manifold`] is the wrong
//! engine: that solver carries a dense `N×K` latent state, `N×K×P` sensitivity
//! tensors, `K²N` penalty couplings, and a joint Newton over all `K` outer
//! parameters. None of that survives `K ≈ 32_000`.
//!
//! The collapsed linear lane instead trains a dictionary by alternating
//! minimisation with **no dense `N×K` object anywhere**:
//!
//! 1. **route** — for each row, score it against the whole dictionary in
//!    `K`-tiles (`scoring`) and keep only the top-`s` atoms online, so the
//!    `N×K` score matrix is produced one tile at a time and discarded;
//! 2. **codes** — solve the small `s×s` active-set least-squares system per row
//!    (`codes`), giving a fixed-width sparse code `(indices, codes)`;
//! 3. **decoder** — accumulate the sparse normal equations (method-of-optimal
//!    -directions / sparse GEMM) and refresh each atom (`update`);
//! 4. **project** — re-unit-norm every atom so the code scale is identified.
//!
//! All heavy state is FP32. The only dense `K`-sized objects are the decoder
//! itself (`K×P`) and the per-atom `P×P`/scalar accumulators — never `N×K`.
//!
//! The exact manifold engine is **untouched**: it remains the certification /
//! small-`K` path. This module is reached only through its own public entry
//! [`fit_sparse_dictionary`] (and the `gamfit` Python facade that wraps it).

mod block;
mod block_chart;
mod block_scoring_gpu;
mod block_stream;
mod codes;
mod cofit;
mod coordinate;
#[cfg(target_os = "linux")]
mod decoder_gpu;
mod scoring;
#[cfg(target_os = "linux")]
mod scoring_gpu;
mod split_lr_fdr;
mod stream;
mod update;

#[cfg(test)]
mod tests;

pub use block::{
    BlockSeedPolicy, BlockSparseConfig, BlockSparseConvergence, BlockSparseFit,
    BlockSparseFitError, block_gates, block_projections_row, block_sparse_dictionary_block_coords,
    block_sparse_dictionary_lift_block, block_sparse_dictionary_project_residual,
    block_sparse_dictionary_transform, coordinate_partition_frames, fit_block_sparse_dictionary,
    fit_block_sparse_dictionary_with_seed, reconstruct_block_sparse_rows, reconstruct_row,
    route_row_blocks, row_loss,
};
pub use block_chart::{
    BlockChartComposeConfig, BlockChartComposeResult, BlockChartRecord, BlockSeedManifest,
    BlockSeedManifestConfig, BlockSeedRecord, CHART_FDR_ALPHA, ChartEvidence, MdlFeaturizerRow,
    block_sparse_dictionary_firings, block_sparse_dictionary_seed_manifest,
    compose_block_coordinate_charts,
};
pub use block_scoring_gpu::{
    BlockRoutePath, block_gate_block_cpu, block_gate_row_cpu, route_blocks_cpu,
};
#[cfg(target_os = "linux")]
pub use block_scoring_gpu::{DEVICE_BLOCK_GATE_MIN_ELEMS, route_blocks_required};
pub use block_stream::{
    BlockEpochStats, BlockShardStats, BlockSparseStreamArtifact, BlockSparseStreamState,
};
pub use codes::SparseCode;
pub use cofit::{CofitConfig, CofitReport, CofitRound, cofit_block_and_curved};
pub use coordinate::{
    BlockCoordinateReport, BlockMeasureCoordinateReport, FiringCoordinate, MeasureSpikeCoordinate,
    MeasureValuedCode, block_firing_coordinates, block_measure_valued_codes,
    block_route_firing_coordinates, explained_variance_from_reconstruction,
    harmonic_firing_coordinates, harmonic_measure_coordinates, harmonic_route_firing_coordinates,
    reconstruct_measure_valued_rows, reconstruct_single_coordinate_rows, recover_measure_from_code,
};
pub use scoring::{ScoreRoutePath, ScoreRouteResult, ScoreRouteStats, TileScorer, top_s_online};
#[cfg(target_os = "linux")]
pub use scoring_gpu::{
    DEVICE_SCORE_BLOCK_MIN_ELEMS, ScoreBlockPath, score_block_cpu, score_block_required,
};
pub use split_lr_fdr::{
    FdrCertificate, crossfit_ui_log_evalue, family_fdr_certificate, shell_vs_ring_log_evalue,
};
pub use stream::{EpochStats, ShardStats, SparseDictArtifact, SparseDictStreamState};
pub use update::{
    DecoderSolveStats, LinearBlockRemlStats, SparseDictionaryError, linear_block_reml_stats,
    linear_shared_rho_fs_step,
};

use ndarray::{Array2, ArrayView2};

/// Shared (NOT per-atom) hyper-parameters for the collapsed linear lane.
///
/// The whole point of the sparse trainer is that `K` is too large to carry a
/// per-atom smoothing parameter / Newton state; every knob here is a single
/// scalar shared across the entire dictionary.
#[derive(Clone, Copy, Debug)]
pub struct SparseDictConfig {
    /// Dictionary width `K` (number of atoms).
    pub n_atoms: usize,
    /// Active budget `s`: how many atoms may fire per row (`top_s`). This is the
    /// shared routing-sparsity hyper-parameter.
    pub active: usize,
    /// Minibatch size (rows per route→code→accumulate step). The decoder is
    /// refreshed once per full epoch from the accumulated sparse normal
    /// equations, so this only bounds peak working set, not the solution.
    pub minibatch: usize,
    /// Number of full passes over the data.
    pub max_epochs: usize,
    /// Column tile width used when scoring rows against the dictionary. Score
    /// tiles of shape `minibatch × tile` are formed and discarded; the `N×K`
    /// score matrix is never materialised.
    pub score_tile: usize,
    /// Shared ridge on the per-row active-set code solve (Tikhonov on the
    /// `s×s` Gram). Identifies the codes when active atoms are collinear.
    pub code_ridge: f32,
    /// Shared ridge on the per-atom decoder refresh (method-of-optimal
    /// -directions normal equations). Keeps a thinly-used atom well posed.
    pub decoder_ridge: f32,
    /// Relative explained-variance improvement below which training stops.
    pub tolerance: f64,
    /// Per-fit score routing residency contract. `Required` is fail-closed: a
    /// high-`K` route that cannot run on the CUDA score-block path returns an
    /// error instead of silently scoring on the CPU.
    pub score_mode: gam_gpu::GpuPolicy,
}

impl SparseDictConfig {
    /// Construct a config for a `K`-atom dictionary, leaving every other knob at
    /// its shared default.
    pub fn new(n_atoms: usize) -> Self {
        Self {
            n_atoms,
            ..Self::default()
        }
    }
}

impl Default for SparseDictConfig {
    fn default() -> Self {
        Self {
            n_atoms: 1,
            active: 1,
            minibatch: 512,
            max_epochs: 30,
            score_tile: 4096,
            code_ridge: 1.0e-6,
            decoder_ridge: 1.0e-6,
            tolerance: 1.0e-6,
            score_mode: gam_gpu::GpuPolicy::Auto,
        }
    }
}

/// Result of a collapsed-linear-lane fit.
///
/// The routing is stored fixed-width and **sparse**: `indices[N, s]` /
/// `codes[N, s]`. There is deliberately no dense `N×K` assignment matrix —
/// reconstructing it would defeat the purpose of the lane.
#[derive(Clone, Debug)]
pub struct SparseDictFit {
    /// Decoder, `K×P`, unit-norm rows (one atom per row).
    pub decoder: Array2<f32>,
    /// Active atom indices per row, `N×s` (column `j` of row `i` is the `j`-th
    /// active atom for that row). Rows with fewer than `s` live atoms pad with
    /// repeated indices whose matching code is zero.
    pub indices: Array2<u32>,
    /// Sparse codes per row, `N×s`, aligned with [`Self::indices`].
    pub codes: Array2<f32>,
    /// Final held-in explained variance (`1 − RSS/TSS`).
    pub explained_variance: f64,
    /// Number of epochs actually run.
    pub epochs: usize,
    /// Inner-alternation and outer-REML fixed-point certificate. A
    /// [`SparseDictFit`] is constructed only when every residual below is at or
    /// below its matching tolerance.
    pub convergence: SparseDictConvergence,
    /// Active budget `s` actually used (`min(active, K)`).
    pub active: usize,
    /// Aggregate CPU/GPU scoring counters over every route pass in the fit.
    pub score_route_stats: ScoreRouteStats,
    /// Decoder refresh percolation/CG certificate from the final MOD update.
    pub decoder_solve_stats: DecoderSolveStats,
}

/// Checkable convergence evidence attached to every [`SparseDictFit`].
#[derive(Clone, Copy, Debug)]
pub struct SparseDictConvergence {
    /// Absolute held-in EV change over the final inner alternation.
    pub inner_ev_residual: f64,
    /// Configured inner EV tolerance.
    pub inner_tolerance: f64,
    /// Gauge-invariant decoder displacement under one full inner update map.
    pub decoder_residual: f64,
    /// Full-map, gauge-invariant decoder fixed-point threshold.
    pub decoder_tolerance: f64,
    /// Relative sparse-code/reconstruction displacement under one full inner map.
    pub routing_residual: f64,
    /// Full-map routing fixed-point threshold.
    pub routing_tolerance: f64,
    /// REML fixed-point residual `|ρ_new - ρ| / ρ`.
    pub outer_rho_residual: f64,
    /// Estimator-derived REML fixed-point tolerance.
    pub outer_tolerance: f64,
    /// Evidence-selected shared ridge.
    pub selected_rho: f64,
    /// Full inner fits evaluated by the outer REML schedule.
    pub outer_iterations: usize,
    /// Residual-row birth proposals that fired in the final inner transition.
    ///
    /// A positive count is compatible with an open certificate only when
    /// [`Self::support_saturated`] is true: those births replace live atoms on a
    /// fixed-cardinality support manifold instead of expanding model structure.
    pub accepted_births: usize,
    /// Largest live-atom cardinality reached during the final inner fit.
    pub live_atom_high_water: usize,
    /// Whether live-support cardinality set no new high for the full saturation
    /// confirmation window. This is reported independently of the EV plateau;
    /// both are required to return an open fit while births keep swapping (#2400).
    pub support_saturated: bool,
    /// Whether the inner fit reached the ABSOLUTE fixed point (EV, decoder AND
    /// routing residuals all within tolerance). `false` marks a **best-effort**
    /// fit returned at `K` above the intrinsic rank, where the `>rank` spurious
    /// support directions rotate freely in the equivalent-optima manifold and the
    /// routing residual legitimately cannot close (#2275) — the objective (EV) has
    /// plateaued but the discrete routing keeps churning. Convergence is decided by
    /// the gauge-invariant EV plateau, so both certified and open fits are returned;
    /// only a still-climbing objective (or a failed linear subsolve) is a genuine
    /// non-convergence error. Mirrors
    /// `super::block::BlockSparseConvergence::certified`.
    pub certified: bool,
}

impl SparseDictConvergence {
    /// A certificate whose every residual is exactly zero against a positive
    /// tolerance — i.e. a trivially converged fixed point. Used to mint
    /// [`SparseDictFit`] values from fixed, hand-authored routings (downstream
    /// consumers read the routing, not the fixed-point history).
    pub fn trivially_converged() -> Self {
        Self {
            inner_ev_residual: 0.0,
            inner_tolerance: 1e-6,
            decoder_residual: 0.0,
            decoder_tolerance: 1e-6,
            routing_residual: 0.0,
            routing_tolerance: 1e-6,
            outer_rho_residual: 0.0,
            outer_tolerance: 1e-6,
            selected_rho: f64::INFINITY,
            outer_iterations: 0,
            accepted_births: 0,
            live_atom_high_water: 0,
            support_saturated: false,
            certified: true,
        }
    }
}

impl SparseDictFit {
    /// Dense reconstruction `N×P` of the training rows from the sparse routing.
    ///
    /// This *does* allocate an `N×P` array (the data size, not `N×K`); it exists
    /// for diagnostics / EV checks, not as part of the trainer's hot loop.
    pub fn reconstruct(&self) -> Array2<f32> {
        reconstruct_sparse_rows(self.decoder.view(), self.indices.view(), self.codes.view())
            .expect("SparseDictFit stores internally validated routing")
    }
}

pub fn reconstruct_sparse_rows(
    decoder: ArrayView2<'_, f32>,
    indices: ArrayView2<'_, u32>,
    codes: ArrayView2<'_, f32>,
) -> Result<Array2<f32>, String> {
    if indices.dim() != codes.dim() {
        return Err(format!(
            "reconstruct_sparse_rows: indices shape {:?} does not match codes shape {:?}",
            indices.dim(),
            codes.dim()
        ));
    }
    let n = indices.nrows();
    let p = decoder.ncols();
    let mut out = Array2::<f32>::zeros((n, p));
    for i in 0..n {
        for j in 0..indices.ncols() {
            let atom = indices[[i, j]] as usize;
            if atom >= decoder.nrows() {
                return Err(format!(
                    "reconstruct_sparse_rows: atom index {atom} out of range 0..{}",
                    decoder.nrows()
                ));
            }
            let code = codes[[i, j]];
            if code == 0.0 {
                continue;
            }
            let row = decoder.row(atom);
            for c in 0..p {
                out[[i, c]] += code * row[c];
            }
        }
    }
    Ok(out)
}

/// Out-of-sample sparse-dictionary encode plus route-dispatch diagnostics.
#[derive(Clone, Debug)]
pub struct SparseDictTransform {
    /// Active atom indices per row, `M×active`.
    pub indices: Array2<u32>,
    /// Sparse codes per row, `M×active`.
    pub codes: Array2<f32>,
    /// CPU/GPU scoring counters for this transform route.
    pub score_route_stats: ScoreRouteStats,
}

/// Out-of-sample encode: route held-out rows `x` (`M×P`, f32) against a frozen
/// sparse dictionary `decoder` (`K×P`) and solve the per-row active-set ridge
/// codes, returning fixed-width `(indices, codes)` each `M×active`. This is the
/// OOS `transform` step for a fitted sparse dictionary — the tiled routing and
/// the active-set least squares both live in the Rust core, and the route step
/// uses the same GPU-dispatched high-`K` scorer as fitting.
pub fn sparse_dictionary_transform(
    x: ArrayView2<'_, f32>,
    decoder: ArrayView2<'_, f32>,
    active: usize,
    score_tile: usize,
    code_ridge: f32,
) -> Result<(Array2<u32>, Array2<f32>), String> {
    let transform = sparse_dictionary_transform_with_mode(
        x,
        decoder,
        active,
        score_tile,
        code_ridge,
        gam_gpu::global_policy(),
    )?;
    Ok((transform.indices, transform.codes))
}

/// Out-of-sample encode with an explicit score routing mode and route counters.
///
/// This is the Rust-native high-`K` T1 transform surface: callers that require
/// GPU scoring pass [`gam_gpu::GpuPolicy::Required`] and inspect
/// [`SparseDictTransform::score_route_stats`] to verify device engagement.
pub fn sparse_dictionary_transform_with_mode(
    x: ArrayView2<'_, f32>,
    decoder: ArrayView2<'_, f32>,
    active: usize,
    score_tile: usize,
    code_ridge: f32,
    score_mode: gam_gpu::GpuPolicy,
) -> Result<SparseDictTransform, String> {
    let k = decoder.nrows();
    if k == 0 {
        return Err("sparse_dictionary_transform: dictionary has no atoms".to_string());
    }
    if x.ncols() != decoder.ncols() {
        return Err(format!(
            "sparse_dictionary_transform: X has P={} columns but the decoder has P={}",
            x.ncols(),
            decoder.ncols()
        ));
    }
    let s = active.min(k).max(1);
    let scorer = TileScorer::new(s, score_tile.max(1));
    let routed = scorer.route_minibatch_with_mode(x, decoder, score_mode)?;
    let mut score_route_stats = ScoreRouteStats::default();
    score_route_stats.record_result(&routed);
    let m = x.nrows();
    let mut indices = Array2::<u32>::zeros((m, s));
    let mut codes = Array2::<f32>::zeros((m, s));
    for (row_idx, active_pairs) in routed.selections.iter().enumerate() {
        let code = codes::solve_row_codes(x.row(row_idx), decoder, active_pairs, s, code_ridge);
        for j in 0..s {
            indices[[row_idx, j]] = code.indices[j];
            codes[[row_idx, j]] = code.codes[j];
        }
    }
    Ok(SparseDictTransform {
        indices,
        codes,
        score_route_stats,
    })
}

/// Fit a fixed-`K` sparse minibatched linear dictionary to `x` (`N×P`).
///
/// This is the public entry of the collapsed linear lane. It never forms a
/// dense `N×K` object: scoring is tiled, routing is fixed-width sparse, and the
/// decoder is refreshed from accumulated sparse normal equations.
///
/// The two ridge fields must name one shared starting value. That value is only
/// the warm start for the evidence-selected shared `ρ`; every public fit runs the
/// outer REML fixed-point schedule and returns only after both inner and outer
/// certificates are satisfied.
pub fn fit_sparse_dictionary(
    x: ArrayView2<'_, f32>,
    config: &SparseDictConfig,
) -> Result<SparseDictFit, SparseDictionaryError> {
    update::run_linear_reml_schedule(x, config)
}