Skip to main content

antecedent_data/
categorical.rs

1//! Dictionary categoricals and explicit contrasts (ADR 0003).
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5#![allow(clippy::cast_precision_loss)]
6
7use std::sync::Arc;
8
9use antecedent_core::CategoryDomainId;
10
11use crate::column::ValidityBitmap;
12use crate::error::DataError;
13
14/// Dictionary-encoded category code (`u32`). Never treated as a magnitude.
15#[repr(transparent)]
16#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
17pub struct CategoryCode(u32);
18
19impl CategoryCode {
20    /// Construct from raw code.
21    #[must_use]
22    pub const fn from_raw(raw: u32) -> Self {
23        Self(raw)
24    }
25
26    /// Underlying code.
27    #[must_use]
28    pub const fn raw(self) -> u32 {
29        self.0
30    }
31}
32
33/// One level in a category domain.
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct CategoryLevel {
36    /// Stable level label.
37    pub label: Arc<str>,
38}
39
40/// Policy for unseen category codes at inference time.
41#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
42pub enum UnknownCategoryPolicy {
43    /// Fail when an unknown code appears.
44    Fail,
45    /// Map to a declared `Other` level (must exist in the domain).
46    MapToOther {
47        /// Code of the Other level.
48        other: CategoryCode,
49    },
50}
51
52/// Immutable category domain.
53#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct CategoryDomain {
55    /// Domain id within a schema registry.
56    pub id: CategoryDomainId,
57    /// Ordered levels.
58    pub levels: Arc<[CategoryLevel]>,
59    /// Whether levels are ordered (ordinal).
60    pub ordered: bool,
61    /// Optional default reference level for treatment coding.
62    pub reference: Option<CategoryCode>,
63    /// Unknown-code policy.
64    pub unknown_policy: UnknownCategoryPolicy,
65}
66
67impl CategoryDomain {
68    /// Construct a domain.
69    ///
70    /// # Errors
71    ///
72    /// Empty levels, invalid reference, or invalid Other mapping.
73    pub fn try_new(
74        id: CategoryDomainId,
75        levels: impl Into<Arc<[CategoryLevel]>>,
76        ordered: bool,
77        reference: Option<CategoryCode>,
78        unknown_policy: UnknownCategoryPolicy,
79    ) -> Result<Self, DataError> {
80        let levels = levels.into();
81        if levels.is_empty() {
82            return Err(DataError::InvalidValidity {
83                message: "category domain requires at least one level",
84            });
85        }
86        let n = u32::try_from(levels.len())
87            .map_err(|_| DataError::InvalidValidity { message: "too many category levels" })?;
88        if let Some(r) = reference {
89            if r.raw() >= n {
90                return Err(DataError::InvalidValidity { message: "reference code out of range" });
91            }
92        }
93        if let UnknownCategoryPolicy::MapToOther { other } = unknown_policy {
94            if other.raw() >= n {
95                return Err(DataError::InvalidValidity { message: "Other code out of range" });
96            }
97        }
98        Ok(Self { id, levels, ordered, reference, unknown_policy })
99    }
100
101    /// Number of levels.
102    #[must_use]
103    pub fn len(&self) -> usize {
104        self.levels.len()
105    }
106
107    /// Whether empty (always false after construction).
108    #[must_use]
109    pub fn is_empty(&self) -> bool {
110        self.levels.is_empty()
111    }
112}
113
114/// Owned categorical column (codes + domain + validity).
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub struct CategoricalColumn {
117    /// Variable id (dense).
118    pub id: antecedent_core::VariableId,
119    /// Codes.
120    pub codes: Arc<[CategoryCode]>,
121    /// Validity (missing ≠ a category).
122    pub validity: ValidityBitmap,
123    /// Domain.
124    pub domain: Arc<CategoryDomain>,
125}
126
127impl CategoricalColumn {
128    /// Construct a categorical column.
129    ///
130    /// Out-of-range codes fail under [`UnknownCategoryPolicy::Fail`] and are
131    /// remapped to the domain's `Other` level under
132    /// [`UnknownCategoryPolicy::MapToOther`].
133    ///
134    /// # Errors
135    ///
136    /// Length mismatch or out-of-range codes under Fail policy.
137    pub fn try_new(
138        id: antecedent_core::VariableId,
139        codes: impl Into<Arc<[CategoryCode]>>,
140        validity: ValidityBitmap,
141        domain: Arc<CategoryDomain>,
142    ) -> Result<Self, DataError> {
143        let codes = codes.into();
144        if validity.len() != codes.len() {
145            return Err(DataError::LengthMismatch {
146                expected: codes.len(),
147                actual: validity.len(),
148                context: "categorical validity",
149            });
150        }
151        let n_levels = u32::try_from(domain.len()).map_err(|_| DataError::InvalidArgument {
152            message: "category domain too large for u32 codes".into(),
153        })?;
154        let mut remapped: Option<Vec<CategoryCode>> = None;
155        for (i, code) in codes.iter().enumerate() {
156            if !validity.is_valid(i) {
157                continue;
158            }
159            if code.raw() >= n_levels {
160                match domain.unknown_policy {
161                    UnknownCategoryPolicy::Fail => {
162                        return Err(DataError::InvalidValidity {
163                            message: "unknown category code under Fail policy",
164                        });
165                    }
166                    UnknownCategoryPolicy::MapToOther { other } => {
167                        // `other` is validated in-range at domain construction.
168                        remapped.get_or_insert_with(|| codes.to_vec())[i] = other;
169                    }
170                }
171            }
172        }
173        let codes = remapped.map_or(codes, Arc::from);
174        Ok(Self { id, codes, validity, domain })
175    }
176
177    /// Row count.
178    #[must_use]
179    pub fn len(&self) -> usize {
180        self.codes.len()
181    }
182
183    /// Whether empty.
184    #[must_use]
185    pub fn is_empty(&self) -> bool {
186        self.codes.is_empty()
187    }
188
189    /// Borrowed view.
190    #[must_use]
191    pub fn as_view(&self) -> CategoricalView<'_> {
192        CategoricalView { codes: &self.codes, validity: &self.validity, domain: &self.domain }
193    }
194}
195
196/// Borrowed categorical view.
197#[derive(Clone, Copy, Debug)]
198pub struct CategoricalView<'a> {
199    /// Codes.
200    pub codes: &'a [CategoryCode],
201    /// Validity.
202    pub validity: &'a ValidityBitmap,
203    /// Domain.
204    pub domain: &'a CategoryDomain,
205}
206
207/// Explicit contrast coding.
208#[derive(Clone, Debug, PartialEq)]
209pub enum Contrast {
210    /// Treatment coding relative to a reference level.
211    Treatment {
212        /// Reference category.
213        reference: CategoryCode,
214    },
215    /// Sum-to-zero.
216    SumToZero,
217    /// Helmert.
218    Helmert,
219    /// Polynomial.
220    Polynomial,
221    /// Full-rank indicator (no drop).
222    FullRankIndicator,
223    /// Caller-supplied contrast matrix (`levels × columns`).
224    Custom(ContrastMatrix),
225}
226
227/// Dense contrast matrix in column-major order.
228#[derive(Clone, Debug, PartialEq)]
229pub struct ContrastMatrix {
230    /// Number of levels (rows).
231    pub n_levels: usize,
232    /// Number of generated columns.
233    pub n_columns: usize,
234    /// Column-major entries.
235    pub values: Arc<[f64]>,
236}
237
238impl ContrastMatrix {
239    /// Construct a matrix.
240    ///
241    /// # Errors
242    ///
243    /// Length mismatch.
244    pub fn try_new(
245        n_levels: usize,
246        n_columns: usize,
247        values: impl Into<Arc<[f64]>>,
248    ) -> Result<Self, DataError> {
249        let values = values.into();
250        if values.len()
251            != n_levels
252                .checked_mul(n_columns)
253                .ok_or(DataError::InvalidValidity { message: "contrast shape overflow" })?
254        {
255            return Err(DataError::LengthMismatch {
256                expected: n_levels * n_columns,
257                actual: values.len(),
258                context: "contrast matrix",
259            });
260        }
261        Ok(Self { n_levels, n_columns, values })
262    }
263}
264
265/// Compile a contrast into a design matrix for valid rows.
266///
267/// Builds the contrast matrix for all contrast variants used by /1 design
268/// compilation (`Treatment`, `SumToZero`, `Helmert`, `Polynomial`,
269/// `FullRankIndicator`, `Custom`).
270///
271/// # Errors
272///
273/// Unsupported domain/contrast combination or invalid reference.
274pub fn compile_contrast_matrix(
275    domain: &CategoryDomain,
276    contrast: &Contrast,
277) -> Result<ContrastMatrix, DataError> {
278    let k = domain.len();
279    match contrast {
280        Contrast::FullRankIndicator => {
281            let mut values = vec![0.0; k * k];
282            for i in 0..k {
283                values[i + i * k] = 1.0;
284            }
285            ContrastMatrix::try_new(k, k, values)
286        }
287        Contrast::Treatment { reference } => {
288            if reference.raw() as usize >= k {
289                return Err(DataError::InvalidValidity {
290                    message: "treatment reference out of range",
291                });
292            }
293            let cols = k - 1;
294            let mut values = vec![0.0; k * cols];
295            let mut col = 0usize;
296            for level in 0..k {
297                if level == reference.raw() as usize {
298                    continue;
299                }
300                values[level + col * k] = 1.0;
301                col += 1;
302            }
303            ContrastMatrix::try_new(k, cols, values)
304        }
305        Contrast::Custom(m) => {
306            if m.n_levels != k {
307                return Err(DataError::LengthMismatch {
308                    expected: k,
309                    actual: m.n_levels,
310                    context: "custom contrast levels",
311                });
312            }
313            Ok(m.clone())
314        }
315        Contrast::SumToZero => {
316            // k levels → k-1 columns; last level is -1 on every column.
317            let cols = k - 1;
318            let mut values = vec![0.0; k * cols];
319            for c in 0..cols {
320                values[c + c * k] = 1.0;
321                values[(k - 1) + c * k] = -1.0;
322            }
323            ContrastMatrix::try_new(k, cols, values)
324        }
325        Contrast::Helmert => {
326            // Forward Helmert: each column contrasts a level with the mean of subsequent levels.
327            let cols = k - 1;
328            let mut values = vec![0.0; k * cols];
329            for c in 0..cols {
330                let remaining = k - c;
331                let weight = 1.0 / remaining as f64;
332                values[c + c * k] = 1.0 - weight;
333                for r in (c + 1)..k {
334                    values[r + c * k] = -weight;
335                }
336            }
337            ContrastMatrix::try_new(k, cols, values)
338        }
339        Contrast::Polynomial => {
340            if !domain.ordered {
341                return Err(DataError::InvalidValidity {
342                    message: "polynomial contrasts require an ordered domain",
343                });
344            }
345            // Orthogonal polynomial contrasts on equally spaced scores 1..=k
346            // (R's `contr.poly`): Vandermonde basis, then modified Gram-Schmidt
347            // of each degree against all lower degrees (including the constant),
348            // then unit-length normalization.
349            let cols = k - 1;
350            let mut basis = vec![0.0; k * k];
351            for d in 0..k {
352                for r in 0..k {
353                    basis[r + d * k] = f64::from(
354                        u32::try_from(r).map_err(|_| DataError::InvalidArgument {
355                            message: "polynomial row index exceeds u32".into(),
356                        })? + 1,
357                    )
358                    .powi(i32::try_from(d).map_err(|_| {
359                        DataError::InvalidArgument {
360                            message: "polynomial degree does not fit i32".into(),
361                        }
362                    })?);
363                }
364            }
365            for d in 0..k {
366                for p in 0..d {
367                    // Lower columns are already unit-norm, so the projection
368                    // coefficient is a plain dot product.
369                    let dot: f64 = (0..k).map(|r| basis[r + d * k] * basis[r + p * k]).sum();
370                    for r in 0..k {
371                        basis[r + d * k] -= dot * basis[r + p * k];
372                    }
373                }
374                let norm: f64 = (0..k).map(|r| basis[r + d * k].powi(2)).sum::<f64>().sqrt();
375                if norm > 0.0 {
376                    for r in 0..k {
377                        basis[r + d * k] /= norm;
378                    }
379                }
380            }
381            // Drop the constant column: degrees 1..k-1.
382            ContrastMatrix::try_new(k, cols, basis[k..].to_vec())
383        }
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use antecedent_core::{CategoryDomainId, VariableId};
391
392    fn domain() -> Arc<CategoryDomain> {
393        Arc::new(
394            CategoryDomain::try_new(
395                CategoryDomainId::from_raw(0),
396                Arc::<[CategoryLevel]>::from(vec![
397                    CategoryLevel { label: Arc::from("a") },
398                    CategoryLevel { label: Arc::from("b") },
399                    CategoryLevel { label: Arc::from("c") },
400                ]),
401                false,
402                Some(CategoryCode::from_raw(0)),
403                UnknownCategoryPolicy::Fail,
404            )
405            .unwrap(),
406        )
407    }
408
409    #[test]
410    fn treatment_contrast_drops_reference() {
411        let d = domain();
412        let m = compile_contrast_matrix(
413            &d,
414            &Contrast::Treatment { reference: CategoryCode::from_raw(0) },
415        )
416        .unwrap();
417        assert_eq!(m.n_levels, 3);
418        assert_eq!(m.n_columns, 2);
419    }
420
421    #[test]
422    fn sum_to_zero_and_helmert_shapes() {
423        let d = domain();
424        let s = compile_contrast_matrix(&d, &Contrast::SumToZero).unwrap();
425        assert_eq!((s.n_levels, s.n_columns), (3, 2));
426        let h = compile_contrast_matrix(&d, &Contrast::Helmert).unwrap();
427        assert_eq!((h.n_levels, h.n_columns), (3, 2));
428    }
429
430    #[test]
431    fn polynomial_requires_ordered_domain() {
432        let d = domain();
433        let err = compile_contrast_matrix(&d, &Contrast::Polynomial).unwrap_err();
434        assert!(matches!(err, DataError::InvalidValidity { .. }));
435        let ordered = Arc::new(
436            CategoryDomain::try_new(
437                CategoryDomainId::from_raw(0),
438                Arc::<[CategoryLevel]>::from(vec![
439                    CategoryLevel { label: Arc::from("a") },
440                    CategoryLevel { label: Arc::from("b") },
441                    CategoryLevel { label: Arc::from("c") },
442                ]),
443                true,
444                Some(CategoryCode::from_raw(0)),
445                UnknownCategoryPolicy::Fail,
446            )
447            .unwrap(),
448        );
449        let m = compile_contrast_matrix(&ordered, &Contrast::Polynomial).unwrap();
450        assert_eq!(m.n_columns, 2);
451    }
452
453    fn ordered_domain(k: usize) -> Arc<CategoryDomain> {
454        let levels: Vec<CategoryLevel> =
455            (0..k).map(|i| CategoryLevel { label: Arc::from(format!("l{i}")) }).collect();
456        Arc::new(
457            CategoryDomain::try_new(
458                CategoryDomainId::from_raw(0),
459                Arc::<[CategoryLevel]>::from(levels),
460                true,
461                None,
462                UnknownCategoryPolicy::Fail,
463            )
464            .unwrap(),
465        )
466    }
467
468    #[test]
469    fn polynomial_matches_contr_poly_for_k3() {
470        let m = compile_contrast_matrix(&ordered_domain(3), &Contrast::Polynomial).unwrap();
471        let expected = [
472            -std::f64::consts::FRAC_1_SQRT_2,
473            0.0,
474            std::f64::consts::FRAC_1_SQRT_2,
475            0.408_248_290_463_863,
476            -0.816_496_580_927_726,
477            0.408_248_290_463_863,
478        ];
479        for (got, want) in m.values.iter().zip(expected) {
480            assert!((got - want).abs() < 1e-12, "got {got}, want {want}");
481        }
482    }
483
484    #[test]
485    fn polynomial_columns_orthonormal_for_k4_and_k5() {
486        for k in [4usize, 5] {
487            let m = compile_contrast_matrix(&ordered_domain(k), &Contrast::Polynomial).unwrap();
488            assert_eq!((m.n_levels, m.n_columns), (k, k - 1));
489            for a in 0..m.n_columns {
490                let norm: f64 = (0..k).map(|r| m.values[r + a * k].powi(2)).sum();
491                assert!((norm - 1.0).abs() < 1e-12, "k={k} column {a} norm {norm}");
492                // Orthogonal to the constant (columns sum to zero).
493                let sum: f64 = (0..k).map(|r| m.values[r + a * k]).sum();
494                assert!(sum.abs() < 1e-12, "k={k} column {a} sum {sum}");
495                for b in (a + 1)..m.n_columns {
496                    let dot: f64 = (0..k).map(|r| m.values[r + a * k] * m.values[r + b * k]).sum();
497                    assert!(dot.abs() < 1e-12, "k={k} columns {a},{b} dot {dot}");
498                }
499            }
500        }
501    }
502
503    #[test]
504    fn map_to_other_remaps_out_of_range_codes() {
505        let d = Arc::new(
506            CategoryDomain::try_new(
507                CategoryDomainId::from_raw(0),
508                Arc::<[CategoryLevel]>::from(vec![
509                    CategoryLevel { label: Arc::from("a") },
510                    CategoryLevel { label: Arc::from("b") },
511                    CategoryLevel { label: Arc::from("other") },
512                ]),
513                false,
514                None,
515                UnknownCategoryPolicy::MapToOther { other: CategoryCode::from_raw(2) },
516            )
517            .unwrap(),
518        );
519        let col = CategoricalColumn::try_new(
520            VariableId::from_raw(0),
521            Arc::<[CategoryCode]>::from(vec![
522                CategoryCode::from_raw(1),
523                CategoryCode::from_raw(9),
524                CategoryCode::from_raw(0),
525            ]),
526            ValidityBitmap::all_valid(3),
527            d,
528        )
529        .unwrap();
530        assert_eq!(col.codes[0], CategoryCode::from_raw(1));
531        assert_eq!(col.codes[1], CategoryCode::from_raw(2));
532        assert_eq!(col.codes[2], CategoryCode::from_raw(0));
533        // Every stored code now indexes the domain safely.
534        assert!(col.codes.iter().all(|c| (c.raw() as usize) < col.domain.len()));
535    }
536
537    #[test]
538    fn categorical_rejects_unknown_under_fail() {
539        let d = domain();
540        let err = CategoricalColumn::try_new(
541            VariableId::from_raw(0),
542            Arc::<[CategoryCode]>::from(vec![CategoryCode::from_raw(9)]),
543            ValidityBitmap::all_valid(1),
544            d,
545        )
546        .unwrap_err();
547        assert!(matches!(err, DataError::InvalidValidity { .. }));
548    }
549
550    #[test]
551    fn missing_skips_code_validation() {
552        let d = domain();
553        let bytes = vec![0u8];
554        // invalid bit 0
555        let col = CategoricalColumn::try_new(
556            VariableId::from_raw(0),
557            Arc::<[CategoryCode]>::from(vec![CategoryCode::from_raw(9)]),
558            ValidityBitmap::from_bytes(bytes, 1).unwrap(),
559            d,
560        )
561        .unwrap();
562        assert_eq!(col.len(), 1);
563        assert!(!col.validity.is_valid(0));
564    }
565}