Skip to main content

zsfm_tabicl/infer/
mod.rs

1//! TabICL v2 inference engine — classification only, single pass (no ensembling), no >10-class
2//! mixed-radix/hierarchical path. Architecture: three stacked transformers —
3//!
4//! 1. **Column embedding**: each (grouped) feature column is embedded independently by a
5//!    shared 3-block Set Transformer (`InducedSelfAttentionBlock`: learned inducing points
6//!    cross-attend to the *training* rows only, then the full column cross-attends back to
7//!    the refined inducing points — `O(n)` instead of `O(n²)`), with the training targets
8//!    folded in beforehand ("target-aware") and a learned query-aware elementwise attention
9//!    scale ("SSMax") on the first attention stage only.
10//! 2. **Row interaction**: per row, the H (grouped) feature-column embeddings plus 4 learned
11//!    CLS tokens attend to each other (non-interleaved RoPE over the H+4 position index); the
12//!    final block reads out via CLS-tokens-as-query cross-attention, concatenated into one
13//!    `embed_dim * 4` row representation.
14//! 2. **In-context learning**: training targets are folded into their rows' representations,
15//!    then a 12-block transformer (SSMax on every block) lets query rows attend to training
16//!    rows only, followed by a 2-layer decoder head.
17//!
18//! Regression (`quantile_dist.py`'s monotonic quantile-distribution head) and the >10-class
19//! mixed-radix/hierarchical classification path are out of scope — see the crate's `Cargo.toml`
20//! description.
21
22use std::io::{BufReader, Read, Seek};
23use std::path::Path;
24
25use anyhow::{Context, Result};
26use candle_core::quantized::gguf_file;
27use candle_core::{DType, Device, Tensor};
28
29use crate::config::TabIclConfig;
30
31const LN_EPS: f64 = 1e-5;
32
33// ---------------------------------------------------------------------------
34// Weight structs
35// ---------------------------------------------------------------------------
36
37struct SsmaxW {
38    base_fc1_w: Tensor,
39    base_fc1_b: Tensor,
40    base_fc2_w: Tensor,
41    base_fc2_b: Tensor,
42    query_fc1_w: Tensor,
43    query_fc1_b: Tensor,
44    query_fc2_w: Tensor,
45    query_fc2_b: Tensor,
46}
47
48struct AttnW {
49    in_proj_w: Tensor,
50    in_proj_b: Tensor,
51    out_proj_w: Tensor,
52    out_proj_b: Tensor,
53    ssmax: Option<SsmaxW>,
54}
55
56struct BlockW {
57    norm1_w: Tensor,
58    norm1_b: Tensor,
59    norm2_w: Tensor,
60    norm2_b: Tensor,
61    attn: AttnW,
62    fc1_w: Tensor,
63    fc1_b: Tensor,
64    fc2_w: Tensor,
65    fc2_b: Tensor,
66}
67
68struct IsabW {
69    ind_vectors: Tensor,
70    attn1: BlockW,
71    attn2: BlockW,
72}
73
74struct ColEmbedderW {
75    in_linear_w: Tensor,
76    in_linear_b: Tensor,
77    y_encoder_w: Tensor,
78    y_encoder_b: Tensor,
79    blocks: Vec<IsabW>,
80}
81
82struct RowInteractorW {
83    cls_tokens: Tensor,
84    blocks: Vec<BlockW>,
85    out_ln_w: Tensor,
86    out_ln_b: Tensor,
87    rope_freqs: Vec<f32>,
88}
89
90struct IclPredictorW {
91    y_encoder_w: Tensor,
92    y_encoder_b: Tensor,
93    blocks: Vec<BlockW>,
94    ln_w: Tensor,
95    ln_b: Tensor,
96    decoder_fc1_w: Tensor,
97    decoder_fc1_b: Tensor,
98    decoder_fc2_w: Tensor,
99    decoder_fc2_b: Tensor,
100}
101
102pub struct TabIclModel {
103    device: Device,
104    config: TabIclConfig,
105    col: ColEmbedderW,
106    row: RowInteractorW,
107    icl: IclPredictorW,
108}
109
110// ---------------------------------------------------------------------------
111// GGUF loading (original, dotted PyTorch tensor names — this checkpoint was converted via the
112// *generic* `zsfm convert` path, which passes names through unchanged, so there's no
113// crate-specific tensor_map/convert step).
114// ---------------------------------------------------------------------------
115
116fn load_t(content: &gguf_file::Content, reader: &mut (impl Read + Seek), name: &str, device: &Device) -> Result<Tensor> {
117    zsfm_nn::load_tensor(content, reader, name, device, DType::F32)
118}
119
120fn load_attn(
121    content: &gguf_file::Content,
122    reader: &mut (impl Read + Seek),
123    prefix: &str,
124    device: &Device,
125    has_ssmax: bool,
126) -> Result<AttnW> {
127    let mut t = |s: &str| load_t(content, reader, &format!("{prefix}.{s}"), device);
128    let in_proj_w = t("in_proj_weight")?;
129    let in_proj_b = t("in_proj_bias")?;
130    let out_proj_w = t("out_proj.weight")?;
131    let out_proj_b = t("out_proj.bias")?;
132    let ssmax = if has_ssmax {
133        Some(SsmaxW {
134            base_fc1_w: t("ssmax_layer.base_mlp.0.weight")?,
135            base_fc1_b: t("ssmax_layer.base_mlp.0.bias")?,
136            base_fc2_w: t("ssmax_layer.base_mlp.2.weight")?,
137            base_fc2_b: t("ssmax_layer.base_mlp.2.bias")?,
138            query_fc1_w: t("ssmax_layer.query_mlp.0.weight")?,
139            query_fc1_b: t("ssmax_layer.query_mlp.0.bias")?,
140            query_fc2_w: t("ssmax_layer.query_mlp.2.weight")?,
141            query_fc2_b: t("ssmax_layer.query_mlp.2.bias")?,
142        })
143    } else {
144        None
145    };
146    Ok(AttnW { in_proj_w, in_proj_b, out_proj_w, out_proj_b, ssmax })
147}
148
149fn load_block(
150    content: &gguf_file::Content,
151    reader: &mut (impl Read + Seek),
152    prefix: &str,
153    device: &Device,
154    has_ssmax: bool,
155) -> Result<BlockW> {
156    let norm1_w = load_t(content, reader, &format!("{prefix}.norm1.weight"), device)?;
157    let norm1_b = load_t(content, reader, &format!("{prefix}.norm1.bias"), device)?;
158    let norm2_w = load_t(content, reader, &format!("{prefix}.norm2.weight"), device)?;
159    let norm2_b = load_t(content, reader, &format!("{prefix}.norm2.bias"), device)?;
160    let attn = load_attn(content, reader, &format!("{prefix}.attn"), device, has_ssmax)?;
161    let fc1_w = load_t(content, reader, &format!("{prefix}.linear1.weight"), device)?;
162    let fc1_b = load_t(content, reader, &format!("{prefix}.linear1.bias"), device)?;
163    let fc2_w = load_t(content, reader, &format!("{prefix}.linear2.weight"), device)?;
164    let fc2_b = load_t(content, reader, &format!("{prefix}.linear2.bias"), device)?;
165    Ok(BlockW { norm1_w, norm1_b, norm2_w, norm2_b, attn, fc1_w, fc1_b, fc2_w, fc2_b })
166}
167
168impl TabIclModel {
169    pub fn load(gguf_path: &Path, config: TabIclConfig) -> Result<Self> {
170        let device = Device::Cpu;
171        let file = std::fs::File::open(gguf_path).with_context(|| format!("open {}", gguf_path.display()))?;
172        let mut reader = BufReader::with_capacity(zsfm_gguf::READ_BUF_CAPACITY, file);
173        let content = gguf_file::Content::read(&mut reader).context("parse GGUF header")?;
174
175        let col_in_linear_w = load_t(&content, &mut reader, "col_embedder.in_linear.weight", &device)?;
176        let col_in_linear_b = load_t(&content, &mut reader, "col_embedder.in_linear.bias", &device)?;
177        let col_y_w = load_t(&content, &mut reader, "col_embedder.y_encoder.weight", &device)?;
178        let col_y_b = load_t(&content, &mut reader, "col_embedder.y_encoder.bias", &device)?;
179
180        let mut col_blocks = Vec::with_capacity(config.col_num_blocks);
181        for n in 0..config.col_num_blocks {
182            let p = format!("col_embedder.tf_col.blocks.{n}");
183            let ind_vectors = load_t(&content, &mut reader, &format!("{p}.ind_vectors"), &device)?;
184            let attn1 = load_block(&content, &mut reader, &format!("{p}.multihead_attn1"), &device, true)?;
185            let attn2 = load_block(&content, &mut reader, &format!("{p}.multihead_attn2"), &device, false)?;
186            col_blocks.push(IsabW { ind_vectors, attn1, attn2 });
187        }
188
189        let cls_tokens = load_t(&content, &mut reader, "row_interactor.cls_tokens", &device)?;
190        let row_out_ln_w = load_t(&content, &mut reader, "row_interactor.out_ln.weight", &device)?;
191        let row_out_ln_b = load_t(&content, &mut reader, "row_interactor.out_ln.bias", &device)?;
192        let rope_freqs_t = load_t(&content, &mut reader, "row_interactor.tf_row.rope.freqs", &device)?;
193        let rope_freqs: Vec<f32> = rope_freqs_t.flatten_all()?.to_vec1()?;
194
195        let mut row_blocks = Vec::with_capacity(config.row_num_blocks);
196        for n in 0..config.row_num_blocks {
197            let p = format!("row_interactor.tf_row.blocks.{n}");
198            row_blocks.push(load_block(&content, &mut reader, &p, &device, false)?);
199        }
200
201        let icl_y_w = load_t(&content, &mut reader, "icl_predictor.y_encoder.weight", &device)?;
202        let icl_y_b = load_t(&content, &mut reader, "icl_predictor.y_encoder.bias", &device)?;
203        let icl_ln_w = load_t(&content, &mut reader, "icl_predictor.ln.weight", &device)?;
204        let icl_ln_b = load_t(&content, &mut reader, "icl_predictor.ln.bias", &device)?;
205        let decoder_fc1_w = load_t(&content, &mut reader, "icl_predictor.decoder.0.weight", &device)?;
206        let decoder_fc1_b = load_t(&content, &mut reader, "icl_predictor.decoder.0.bias", &device)?;
207        let decoder_fc2_w = load_t(&content, &mut reader, "icl_predictor.decoder.2.weight", &device)?;
208        let decoder_fc2_b = load_t(&content, &mut reader, "icl_predictor.decoder.2.bias", &device)?;
209
210        let mut icl_blocks = Vec::with_capacity(config.icl_num_blocks);
211        for n in 0..config.icl_num_blocks {
212            let p = format!("icl_predictor.tf_icl.blocks.{n}");
213            icl_blocks.push(load_block(&content, &mut reader, &p, &device, true)?);
214        }
215
216        Ok(Self {
217            device,
218            config,
219            col: ColEmbedderW {
220                in_linear_w: col_in_linear_w,
221                in_linear_b: col_in_linear_b,
222                y_encoder_w: col_y_w,
223                y_encoder_b: col_y_b,
224                blocks: col_blocks,
225            },
226            row: RowInteractorW {
227                cls_tokens,
228                blocks: row_blocks,
229                out_ln_w: row_out_ln_w,
230                out_ln_b: row_out_ln_b,
231                rope_freqs,
232            },
233            icl: IclPredictorW {
234                y_encoder_w: icl_y_w,
235                y_encoder_b: icl_y_b,
236                blocks: icl_blocks,
237                ln_w: icl_ln_w,
238                ln_b: icl_ln_b,
239                decoder_fc1_w,
240                decoder_fc1_b,
241                decoder_fc2_w,
242                decoder_fc2_b,
243            },
244        })
245    }
246
247    // -----------------------------------------------------------------------
248    // Prediction
249    // -----------------------------------------------------------------------
250
251    /// Zero-shot classification. `y_support` are class indices `0..n_classes` (must satisfy
252    /// `n_classes <= 10` — the >10-class mixed-radix/hierarchical path is out of scope).
253    /// Returns probabilities `[n_query][n_classes]`.
254    pub fn predict_classification(
255        &self,
256        x_support: &[Vec<f32>],
257        y_support: &[usize],
258        x_query: &[Vec<f32>],
259        n_classes: usize,
260    ) -> Result<Vec<Vec<f32>>> {
261        anyhow::ensure!(n_classes <= self.config.max_classes, "n_classes must be <= {}", self.config.max_classes);
262        let train_size = x_support.len();
263        let mut all_rows = x_support.to_vec();
264        all_rows.extend_from_slice(x_query);
265        let t = all_rows.len();
266        let h = all_rows[0].len();
267
268        let processed = preprocess_x(&all_rows, train_size);
269        let group_size = self.config.feature_group_size;
270        let grouped = feature_group_same(&processed, h, group_size);
271
272        let mut flat = vec![0f32; h * t * group_size];
273        for (row_idx, row) in grouped.iter().enumerate() {
274            for (col_idx, group) in row.iter().enumerate() {
275                let base = col_idx * t * group_size + row_idx * group_size;
276                flat[base..base + group_size].copy_from_slice(group);
277            }
278        }
279        let x_t = Tensor::from_vec(flat, (h, t, group_size), &self.device)?;
280
281        let y_onehot_col = self.onehot_linear(y_support, &self.col.y_encoder_w, &self.col.y_encoder_b)?;
282        let col_out = self.col_embed_forward(&x_t, &y_onehot_col, train_size)?;
283        let row_out = self.row_interact_forward(&col_out, train_size)?;
284        let y_onehot_icl = self.onehot_linear(y_support, &self.icl.y_encoder_w, &self.icl.y_encoder_b)?;
285        let logits = self.icl_forward(&row_out, &y_onehot_icl, train_size, n_classes)?;
286
287        let flat_logits: Vec<f32> = logits.flatten_all()?.to_vec1()?;
288        let n_query = t - train_size;
289        const TEMPERATURE: f32 = 0.9;
290        let mut result = Vec::with_capacity(n_query);
291        for i in 0..n_query {
292            let row = &flat_logits[i * n_classes..(i + 1) * n_classes];
293            let scaled: Vec<f32> = row.iter().map(|&v| v / TEMPERATURE).collect();
294            result.push(softmax(&scaled));
295        }
296        Ok(result)
297    }
298
299    fn onehot_linear(&self, y: &[usize], w: &Tensor, b: &Tensor) -> Result<Tensor> {
300        let n = y.len();
301        let num_classes = w.dim(1)?;
302        let mut onehot = vec![0f32; n * num_classes];
303        for (i, &c) in y.iter().enumerate() {
304            onehot[i * num_classes + c] = 1.0;
305        }
306        let x = Tensor::from_vec(onehot, (n, num_classes), &self.device)?;
307        zsfm_nn::linear_bias(&x, w, b).map_err(anyhow::Error::from)
308    }
309
310    /// `x_grouped`: `[H, T, group_size]`. `y_onehot`: `[train_size, embed_dim]` (already
311    /// one-hot + linear-projected). Returns column embeddings `[H, T, embed_dim]`.
312    fn col_embed_forward(&self, x_grouped: &Tensor, y_onehot: &Tensor, train_size: usize) -> Result<Tensor> {
313        let src = zsfm_nn::linear_bias(x_grouped, &self.col.in_linear_w, &self.col.in_linear_b)?;
314        let t = src.dim(1)?;
315        let train_part = src.narrow(1, 0, train_size)?.broadcast_add(&y_onehot.unsqueeze(0)?)?;
316        let mut src = if train_size < t {
317            Tensor::cat(&[&train_part, &src.narrow(1, train_size, t - train_size)?], 1)?
318        } else {
319            train_part
320        };
321
322        let h = src.dim(0)?;
323        for isab in &self.col.blocks {
324            src = isab_forward(&src, isab, self.config.col_nhead, train_size, h)?;
325        }
326        Ok(src)
327    }
328
329    /// `col_embeddings`: `[H, T, embed_dim]`. Returns row representations `[T, icl_dim]`.
330    fn row_interact_forward(&self, col_embeddings: &Tensor, _train_size: usize) -> Result<Tensor> {
331        let h = col_embeddings.dim(0)?;
332        let t = col_embeddings.dim(1)?;
333        let dim = self.config.embed_dim;
334        let n_cls = self.config.row_num_cls;
335
336        let feat = col_embeddings.permute((1, 0, 2))?.contiguous()?; // (T, H, dim)
337        let cls = self.row.cls_tokens.unsqueeze(0)?.expand((t, n_cls, dim))?.contiguous()?;
338        let mut seq = Tensor::cat(&[&cls, &feat], 1)?; // (T, H+C, dim)
339
340        let max_len = h + n_cls;
341        let (cos, sin) = self.row_rope_table(max_len)?;
342
343        let n_blocks = self.row.blocks.len();
344        for (i, blk) in self.row.blocks.iter().enumerate() {
345            if i + 1 == n_blocks {
346                let cls_q = seq.narrow(1, 0, n_cls)?;
347                seq = mha_block(&cls_q, &seq, &seq, blk, self.config.row_nhead, Some((&cos, &sin)), 0)?;
348            } else {
349                seq = mha_block(&seq, &seq, &seq, blk, self.config.row_nhead, Some((&cos, &sin)), 0)?;
350            }
351        }
352
353        let out = zsfm_nn::layer_norm(&seq, &self.row.out_ln_w, &self.row.out_ln_b, LN_EPS)?; // (T, C, dim)
354        out.reshape((t, n_cls * dim)).map_err(anyhow::Error::from)
355    }
356
357    fn row_rope_table(&self, max_len: usize) -> Result<(Tensor, Tensor)> {
358        let half = self.row.rope_freqs.len();
359        let mut cos = vec![0f32; max_len * half * 2];
360        let mut sin = vec![0f32; max_len * half * 2];
361        for p in 0..max_len {
362            for i in 0..half {
363                let angle = p as f32 * self.row.rope_freqs[i];
364                let (s, c) = angle.sin_cos();
365                cos[p * 2 * half + i] = c;
366                cos[p * 2 * half + half + i] = c;
367                sin[p * 2 * half + i] = s;
368                sin[p * 2 * half + half + i] = s;
369            }
370        }
371        let cos_t = Tensor::from_vec(cos, (max_len, 2 * half), &self.device)?;
372        let sin_t = Tensor::from_vec(sin, (max_len, 2 * half), &self.device)?;
373        Ok((cos_t, sin_t))
374    }
375
376    /// `row_reprs`: `[T, icl_dim]`. `y_onehot`: `[train_size, icl_dim]`. Returns logits
377    /// `[n_query, n_classes]` (already sliced to the query rows and the first `n_classes`
378    /// output columns).
379    fn icl_forward(&self, row_reprs: &Tensor, y_onehot: &Tensor, train_size: usize, n_classes: usize) -> Result<Tensor> {
380        let t = row_reprs.dim(0)?;
381        let train_part = row_reprs.narrow(0, 0, train_size)?.broadcast_add(y_onehot)?;
382        let r = if train_size < t {
383            Tensor::cat(&[&train_part, &row_reprs.narrow(0, train_size, t - train_size)?], 0)?
384        } else {
385            train_part
386        };
387        let mut r = r.unsqueeze(0)?; // (1, T, icl_dim) -- single table
388
389        for blk in &self.icl.blocks {
390            let kv = r.narrow(1, 0, train_size)?;
391            r = mha_block(&r, &kv, &kv, blk, self.config.icl_nhead, None, train_size)?;
392        }
393        let r = r.squeeze(0)?; // (T, icl_dim)
394        let r = zsfm_nn::layer_norm(&r, &self.icl.ln_w, &self.icl.ln_b, LN_EPS)?;
395        let h = zsfm_nn::linear_bias(&r, &self.icl.decoder_fc1_w, &self.icl.decoder_fc1_b)?.gelu_erf()?;
396        let logits = zsfm_nn::linear_bias(&h, &self.icl.decoder_fc2_w, &self.icl.decoder_fc2_b)?; // (T, max_classes)
397        logits.narrow(0, train_size, t - train_size)?.narrow(1, 0, n_classes).map_err(anyhow::Error::from)
398    }
399}
400
401/// One `InducedSelfAttentionBlock`: learned inducing points cross-attend to the *training*
402/// rows (`attn1`, SSMax on this stage only), then the full column cross-attends back to the
403/// refined inducing points (`attn2`). `src`: `[H, T, dim]`.
404fn isab_forward(src: &Tensor, isab: &IsabW, n_heads: usize, train_size: usize, n_h: usize) -> Result<Tensor> {
405    let num_inds = isab.ind_vectors.dim(0)?;
406    let dim = isab.ind_vectors.dim(1)?;
407    let ind = isab.ind_vectors.unsqueeze(0)?.expand((n_h, num_inds, dim))?.contiguous()?;
408
409    let kv_train = src.narrow(1, 0, train_size)?;
410    let hidden = mha_block(&ind, &kv_train, &kv_train, &isab.attn1, n_heads, None, train_size)?;
411    mha_block(src, &hidden, &hidden, &isab.attn2, n_heads, None, num_inds)
412}
413
414/// One pre-norm `MultiheadAttentionBlock`: LN -> MHA (combined in-proj, optional RoPE, optional
415/// SSMax) -> residual -> LN -> GELU-FFN -> residual. `q_in`/`k_in`/`v_in` are pre-selected by the
416/// caller (always independently LayerNorm'd here — mathematically identical to the reference's
417/// "reuse if same tensor" optimization, since LayerNorm is a pure deterministic function).
418fn mha_block(
419    q_in: &Tensor,
420    k_in: &Tensor,
421    v_in: &Tensor,
422    blk: &BlockW,
423    n_heads: usize,
424    rope_cos_sin: Option<(&Tensor, &Tensor)>,
425    ssmax_n: usize,
426) -> Result<Tensor> {
427    let q_normed = zsfm_nn::layer_norm(q_in, &blk.norm1_w, &blk.norm1_b, LN_EPS)?;
428    let k_normed = zsfm_nn::layer_norm(k_in, &blk.norm1_w, &blk.norm1_b, LN_EPS)?;
429    let v_normed = zsfm_nn::layer_norm(v_in, &blk.norm1_w, &blk.norm1_b, LN_EPS)?;
430    let attn_out = mha_combined(&q_normed, &k_normed, &v_normed, &blk.attn, n_heads, rope_cos_sin, ssmax_n)?;
431    let x = (q_in + attn_out)?;
432    let ff_in = zsfm_nn::layer_norm(&x, &blk.norm2_w, &blk.norm2_b, LN_EPS)?;
433    let ff = zsfm_nn::linear_bias(&zsfm_nn::linear_bias(&ff_in, &blk.fc1_w, &blk.fc1_b)?.gelu_erf()?, &blk.fc2_w, &blk.fc2_b)?;
434    Ok((x + ff)?)
435}
436
437/// Combined-in-projection multi-head attention (`nn.MultiheadAttention`-style: one packed
438/// `in_proj_weight`/`bias` split into Q/K/V thirds). `q_in`/`k_in`/`v_in`: `[batch, seq, dim]`.
439/// RoPE (applied to Q/K, row interactor only) and SSMax (applied to Q, column/ICL stages only)
440/// never co-occur in this model.
441fn mha_combined(
442    q_in: &Tensor,
443    k_in: &Tensor,
444    v_in: &Tensor,
445    attn: &AttnW,
446    n_heads: usize,
447    rope_cos_sin: Option<(&Tensor, &Tensor)>,
448    ssmax_n: usize,
449) -> Result<Tensor> {
450    let dim = q_in.dim(2)?;
451    let wq = attn.in_proj_w.narrow(0, 0, dim)?;
452    let wk = attn.in_proj_w.narrow(0, dim, dim)?;
453    let wv = attn.in_proj_w.narrow(0, 2 * dim, dim)?;
454    let bq = attn.in_proj_b.narrow(0, 0, dim)?;
455    let bk = attn.in_proj_b.narrow(0, dim, dim)?;
456    let bv = attn.in_proj_b.narrow(0, 2 * dim, dim)?;
457
458    let q = zsfm_nn::linear_bias(q_in, &wq, &bq)?;
459    let k = zsfm_nn::linear_bias(k_in, &wk, &bk)?;
460    let v = zsfm_nn::linear_bias(v_in, &wv, &bv)?;
461
462    let batch = q.dim(0)?;
463    let q_len = q.dim(1)?;
464    let k_len = k.dim(1)?;
465    let head_dim = dim / n_heads;
466
467    let q = q.reshape((batch, q_len, n_heads, head_dim))?.permute((0, 2, 1, 3))?.contiguous()?;
468    let k = k.reshape((batch, k_len, n_heads, head_dim))?.permute((0, 2, 1, 3))?.contiguous()?;
469    let v = v.reshape((batch, k_len, n_heads, head_dim))?.permute((0, 2, 1, 3))?.contiguous()?;
470
471    let (q, k) = if let Some((cos, sin)) = rope_cos_sin {
472        (apply_rope(&q, cos, sin)?, apply_rope(&k, cos, sin)?)
473    } else {
474        (q, k)
475    };
476
477    let q = if let Some(ssmax) = &attn.ssmax {
478        apply_ssmax(&q, ssmax, ssmax_n, n_heads, head_dim)?
479    } else {
480        q
481    };
482
483    let scale = 1.0 / (head_dim as f64).sqrt();
484    let scores = (q.matmul(&k.transpose(2, 3)?)? * scale)?;
485    let probs = candle_nn::ops::softmax_last_dim(&scores)?;
486    let out = probs.matmul(&v)?; // (batch, h, q_len, hd)
487    let out = out.permute((0, 2, 1, 3))?.contiguous()?.reshape((batch, q_len, dim))?;
488    zsfm_nn::linear_bias(&out, &attn.out_proj_w, &attn.out_proj_b).map_err(anyhow::Error::from)
489}
490
491/// Non-interleaved RoPE (`rotate_half_contiguous`: split into first/second halves, negate and
492/// swap). `x`: `[batch, heads, seq, head_dim]`. `cos`/`sin`: `[max_len, head_dim]`, narrowed
493/// here to `x`'s own sequence length (so a Q slice starting at position 0 — e.g. the CLS-token
494/// readout query — gets the matching leading rows of the table).
495fn apply_rope(x: &Tensor, cos: &Tensor, sin: &Tensor) -> Result<Tensor> {
496    let seq = x.dim(2)?;
497    let head_dim = x.dim(3)?;
498    let half = head_dim / 2;
499    let cos = cos.narrow(0, 0, seq)?.unsqueeze(0)?.unsqueeze(0)?; // (1,1,seq,hd)
500    let sin = sin.narrow(0, 0, seq)?.unsqueeze(0)?.unsqueeze(0)?;
501    let x1 = x.narrow(3, 0, half)?;
502    let x2 = x.narrow(3, half, half)?;
503    let rotated = Tensor::cat(&[&x2.neg()?, &x1], 3)?;
504    Ok((x.broadcast_mul(&cos)? + rotated.broadcast_mul(&sin)?)?)
505}
506
507/// Query-aware elementwise SSMax: `q * base_mlp(log n) * (1 + tanh(query_mlp(q)))`.
508/// `q`: `[batch, n_heads, seq, head_dim]`.
509fn apply_ssmax(q: &Tensor, ssmax: &SsmaxW, n: usize, n_heads: usize, head_dim: usize) -> Result<Tensor> {
510    let device = q.device();
511    let logn = (n.max(1) as f32).ln();
512    let logn_t = Tensor::from_vec(vec![logn], (1, 1), device)?;
513    let base = zsfm_nn::linear_bias(&logn_t, &ssmax.base_fc1_w, &ssmax.base_fc1_b)?.gelu_erf()?;
514    let base = zsfm_nn::linear_bias(&base, &ssmax.base_fc2_w, &ssmax.base_fc2_b)?; // (1, n_heads*head_dim)
515    let base = base.reshape((1, n_heads, 1, head_dim))?;
516
517    let (batch, seq) = (q.dim(0)?, q.dim(2)?);
518    let q_flat = q.reshape((batch * n_heads * seq, head_dim))?;
519    let qm = zsfm_nn::linear_bias(&q_flat, &ssmax.query_fc1_w, &ssmax.query_fc1_b)?.gelu_erf()?;
520    let qm = zsfm_nn::linear_bias(&qm, &ssmax.query_fc2_w, &ssmax.query_fc2_b)?;
521    let modulation = (qm.tanh()? + 1.0)?.reshape((batch, n_heads, seq, head_dim))?;
522
523    let scales = base.broadcast_mul(&modulation)?;
524    Ok(q.broadcast_mul(&scales)?)
525}
526
527fn softmax(logits: &[f32]) -> Vec<f32> {
528    let max = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
529    let exps: Vec<f32> = logits.iter().map(|&v| (v - max).exp()).collect();
530    let sum: f32 = exps.iter().sum();
531    exps.into_iter().map(|v| v / sum).collect()
532}
533
534// ---------------------------------------------------------------------------
535// Preprocessing (mean-impute + standardize, fit on support/applied to both — same scope
536// decision as Mitra/TabDPT; TabICL's own sklearn wrapper's fuller ensembling/normalizer-choice
537// pipeline is not replicated)
538// ---------------------------------------------------------------------------
539
540fn preprocess_x(rows: &[Vec<f32>], train_size: usize) -> Vec<Vec<f32>> {
541    let n_feat = rows[0].len();
542    let mut impute_mean = vec![0f32; n_feat];
543    for f in 0..n_feat {
544        let mut sum = 0f64;
545        let mut count = 0usize;
546        for row in &rows[..train_size] {
547            if !row[f].is_nan() {
548                sum += row[f] as f64;
549                count += 1;
550            }
551        }
552        impute_mean[f] = if count > 0 { (sum / count as f64) as f32 } else { 0.0 };
553    }
554    let imputed: Vec<Vec<f32>> = rows
555        .iter()
556        .map(|row| row.iter().enumerate().map(|(f, &v)| if v.is_nan() { impute_mean[f] } else { v }).collect())
557        .collect();
558
559    let mut mean = vec![0f32; n_feat];
560    let mut std = vec![0f32; n_feat];
561    for f in 0..n_feat {
562        let m: f32 = imputed[..train_size].iter().map(|r| r[f]).sum::<f32>() / train_size as f32;
563        let var: f32 = imputed[..train_size].iter().map(|r| (r[f] - m).powi(2)).sum::<f32>() / train_size as f32;
564        mean[f] = m;
565        std[f] = if var == 0.0 { 1.0 } else { var.sqrt() };
566    }
567
568    imputed.iter().map(|row| row.iter().enumerate().map(|(f, &v)| (v - mean[f]) / std[f]).collect()).collect()
569}
570
571/// Circular-permutation feature grouping (`col_feature_group="same"`): group `g`'s values are
572/// the input features at offsets `2^0, 2^1, .., 2^(size-1)` past `g` (mod `H`) — note this does
573/// *not* include feature `g` itself, only its power-of-two-shifted neighbors.
574fn feature_group_same(rows: &[Vec<f32>], h: usize, size: usize) -> Vec<Vec<Vec<f32>>> {
575    rows.iter()
576        .map(|row| {
577            (0..h)
578                .map(|g| (0..size).map(|k| row[(g + (1usize << k)) % h]).collect())
579                .collect()
580        })
581        .collect()
582}