1#![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#[repr(transparent)]
16#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
17pub struct CategoryCode(u32);
18
19impl CategoryCode {
20 #[must_use]
22 pub const fn from_raw(raw: u32) -> Self {
23 Self(raw)
24 }
25
26 #[must_use]
28 pub const fn raw(self) -> u32 {
29 self.0
30 }
31}
32
33#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct CategoryLevel {
36 pub label: Arc<str>,
38}
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
42pub enum UnknownCategoryPolicy {
43 Fail,
45 MapToOther {
47 other: CategoryCode,
49 },
50}
51
52#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct CategoryDomain {
55 pub id: CategoryDomainId,
57 pub levels: Arc<[CategoryLevel]>,
59 pub ordered: bool,
61 pub reference: Option<CategoryCode>,
63 pub unknown_policy: UnknownCategoryPolicy,
65}
66
67impl CategoryDomain {
68 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 #[must_use]
103 pub fn len(&self) -> usize {
104 self.levels.len()
105 }
106
107 #[must_use]
109 pub fn is_empty(&self) -> bool {
110 self.levels.is_empty()
111 }
112}
113
114#[derive(Clone, Debug, Eq, PartialEq)]
116pub struct CategoricalColumn {
117 pub id: antecedent_core::VariableId,
119 pub codes: Arc<[CategoryCode]>,
121 pub validity: ValidityBitmap,
123 pub domain: Arc<CategoryDomain>,
125}
126
127impl CategoricalColumn {
128 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 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 #[must_use]
179 pub fn len(&self) -> usize {
180 self.codes.len()
181 }
182
183 #[must_use]
185 pub fn is_empty(&self) -> bool {
186 self.codes.is_empty()
187 }
188
189 #[must_use]
191 pub fn as_view(&self) -> CategoricalView<'_> {
192 CategoricalView { codes: &self.codes, validity: &self.validity, domain: &self.domain }
193 }
194}
195
196#[derive(Clone, Copy, Debug)]
198pub struct CategoricalView<'a> {
199 pub codes: &'a [CategoryCode],
201 pub validity: &'a ValidityBitmap,
203 pub domain: &'a CategoryDomain,
205}
206
207#[derive(Clone, Debug, PartialEq)]
209pub enum Contrast {
210 Treatment {
212 reference: CategoryCode,
214 },
215 SumToZero,
217 Helmert,
219 Polynomial,
221 FullRankIndicator,
223 Custom(ContrastMatrix),
225}
226
227#[derive(Clone, Debug, PartialEq)]
229pub struct ContrastMatrix {
230 pub n_levels: usize,
232 pub n_columns: usize,
234 pub values: Arc<[f64]>,
236}
237
238impl ContrastMatrix {
239 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
265pub 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 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 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 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 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 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 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 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 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}