Skip to main content

rich_data/
infer.rs

1//! Column type inference (#267), on request and with its evidence.
2//!
3//! [`Inferrer::infer`] reads every cell of every column and counts what each
4//! parses as: an integer, a float, a boolean (`true`/`false`, any case), a
5//! date (`YYYY-MM-DD`), a timestamp (a date, `T` or a space, `HH:MM`, optional
6//! seconds and fraction, optional `Z` or offset), or a null token (by default
7//! an empty cell, `null`, `NULL`, `NA`, `N/A`). A column is the narrowest
8//! type every non-null cell parses as (integers widen to floats, dates to
9//! timestamps), `null` when every cell is null, and `text` otherwise; the
10//! [`Evidence`] says how many cells parsed as each type, so a column that is
11//! text because of one stray cell is easy to spot. An
12//! [`override`](Inferrer::override_type) fixes a column's type whatever its
13//! cells say.
14//!
15//! Nothing here runs unless asked: `rich --csv` never infers, and a reader's
16//! cells stay as written until [`Inference::apply`] converts them.
17//!
18//! ```
19//! use rich_data::infer::{InferredType, Inferrer};
20//! use rich_data::{Rows, Value};
21//!
22//! let mut rows = Rows::new(["id", "when", "note"]);
23//! rows.push(["1".into(), "2026-10-06".into(), "".into()]);
24//! rows.push(["2".into(), "2026-10-06T09:30:00Z".into(), "n/a".into()]);
25//! rows.push(["NA".into(), "2026-10-07".into(), "7".into()]);
26//!
27//! let inference = Inferrer::new().infer(&rows);
28//! let types: Vec<InferredType> = inference.columns().iter().map(|c| c.data_type()).collect();
29//! assert_eq!(types, [InferredType::Integer, InferredType::Timestamp, InferredType::Text]);
30//! let note = &inference.columns()[2];
31//! assert_eq!((note.evidence().nulls, note.evidence().integers), (1, 1));
32//! assert_eq!(note.summary(), "text: 1 of 2 values parse as integer, 1 null");
33//!
34//! inference.apply(&mut rows);
35//! assert_eq!(rows.rows()[2][0], Value::Null);
36//! assert_eq!(rows.rows()[1][0], Value::Int(2));
37//! ```
38
39use std::collections::HashMap;
40use std::fmt;
41
42use rich::{Console, ConsoleOptions, Justify, Renderable, Segment, Table};
43
44use crate::{DataType, Field, Rows, Schema, Value};
45
46/// A column's inferred type.
47#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
48pub enum InferredType {
49    /// Every cell is null.
50    Null,
51    /// `true` or `false`.
52    Boolean,
53    /// Whole numbers that fit in an `i64`.
54    Integer,
55    /// Numbers.
56    Float,
57    /// `YYYY-MM-DD` dates.
58    Date,
59    /// Dates with a time.
60    Timestamp,
61    /// Anything else.
62    Text,
63}
64
65impl InferredType {
66    /// The lowercase name.
67    pub fn name(self) -> &'static str {
68        match self {
69            InferredType::Null => "null",
70            InferredType::Boolean => "boolean",
71            InferredType::Integer => "integer",
72            InferredType::Float => "float",
73            InferredType::Date => "date",
74            InferredType::Timestamp => "timestamp",
75            InferredType::Text => "text",
76        }
77    }
78
79    /// The schema type: `null` is [`DataType::Unknown`], `text` is
80    /// [`DataType::String`].
81    pub fn data_type(self) -> DataType {
82        match self {
83            InferredType::Null => DataType::Unknown,
84            InferredType::Boolean => DataType::Boolean,
85            InferredType::Integer => DataType::Integer,
86            InferredType::Float => DataType::Float,
87            InferredType::Date => DataType::Date,
88            InferredType::Timestamp => DataType::timestamp(),
89            InferredType::Text => DataType::String,
90        }
91    }
92}
93
94impl fmt::Display for InferredType {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        f.write_str(self.name())
97    }
98}
99
100/// How many of a column's cells parsed as each type. A cell counts for
101/// every type it parses as: `7` is an integer and a float, a date is also a
102/// timestamp.
103#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
104pub struct Evidence {
105    /// Every cell.
106    pub cells: usize,
107    /// Null tokens.
108    pub nulls: usize,
109    /// Booleans.
110    pub booleans: usize,
111    /// Integers.
112    pub integers: usize,
113    /// Numbers (integers included).
114    pub floats: usize,
115    /// Dates.
116    pub dates: usize,
117    /// Timestamps (dates included).
118    pub timestamps: usize,
119}
120
121impl Evidence {
122    /// The non-null cells.
123    pub fn values(&self) -> usize {
124        self.cells - self.nulls
125    }
126
127    /// How many non-null cells parse as `data_type`.
128    pub fn parsed(&self, data_type: InferredType) -> usize {
129        match data_type {
130            InferredType::Null => self.nulls,
131            InferredType::Boolean => self.booleans,
132            InferredType::Integer => self.integers,
133            InferredType::Float => self.floats,
134            InferredType::Date => self.dates,
135            InferredType::Timestamp => self.timestamps,
136            InferredType::Text => self.values(),
137        }
138    }
139
140    /// The narrowest type every non-null cell parses as.
141    pub fn best(&self) -> InferredType {
142        let values = self.values();
143        if values == 0 {
144            InferredType::Null
145        } else if self.booleans == values {
146            InferredType::Boolean
147        } else if self.integers == values {
148            InferredType::Integer
149        } else if self.floats == values {
150            InferredType::Float
151        } else if self.dates == values {
152            InferredType::Date
153        } else if self.timestamps == values {
154            InferredType::Timestamp
155        } else {
156            InferredType::Text
157        }
158    }
159
160    /// The typed reading most cells support, for a text column: `(type,
161    /// count)`, or `None` when no cell parses as anything.
162    pub fn closest(&self) -> Option<(InferredType, usize)> {
163        [
164            (InferredType::Integer, self.integers),
165            (InferredType::Float, self.floats),
166            (InferredType::Boolean, self.booleans),
167            (InferredType::Date, self.dates),
168            (InferredType::Timestamp, self.timestamps),
169        ]
170        .into_iter()
171        .filter(|(_, n)| *n > 0)
172        // The first of the largest: integers before floats, dates before
173        // timestamps.
174        .fold(
175            None,
176            |best: Option<(InferredType, usize)>, (t, n)| match best {
177                Some((_, m)) if m >= n => best,
178                _ => Some((t, n)),
179            },
180        )
181    }
182}
183
184/// One column's inferred type and the evidence for it.
185#[derive(Clone, Debug, PartialEq, Eq)]
186pub struct ColumnInference {
187    name: String,
188    data_type: InferredType,
189    evidence: Evidence,
190    overridden: bool,
191}
192
193impl ColumnInference {
194    /// The column name.
195    pub fn name(&self) -> &str {
196        &self.name
197    }
198
199    /// The type: inferred, or the override.
200    pub fn data_type(&self) -> InferredType {
201        self.data_type
202    }
203
204    /// The counts behind it.
205    pub fn evidence(&self) -> &Evidence {
206        &self.evidence
207    }
208
209    /// Whether the type was given, not inferred.
210    pub fn is_overridden(&self) -> bool {
211        self.overridden
212    }
213
214    /// The evidence in words: `integer: 298 of 300 values, 2 null`,
215    /// `text: 12 of 13 values parse as float`, or `date (override): 3 of 4
216    /// values`.
217    pub fn summary(&self) -> String {
218        let e = &self.evidence;
219        let mut out = self.data_type.name().to_string();
220        if self.overridden {
221            out.push_str(" (override)");
222        }
223        let values = e.values();
224        let plural = |n: usize| if n == 1 { "value" } else { "values" };
225        match self.data_type {
226            InferredType::Null => out.push_str(&format!(": {} null", e.nulls)),
227            InferredType::Text if !self.overridden => match e.closest() {
228                Some((t, n)) => out.push_str(&format!(
229                    ": {n} of {values} {} parse as {t}",
230                    plural(values)
231                )),
232                None => out.push_str(&format!(": {values} {}", plural(values))),
233            },
234            t => out.push_str(&format!(": {} of {values} {}", e.parsed(t), plural(values))),
235        }
236        if e.nulls > 0 && self.data_type != InferredType::Null {
237            out.push_str(&format!(", {} null", e.nulls));
238        }
239        out
240    }
241}
242
243/// The null tokens [`Inferrer::new`] starts with: an empty cell, `null`,
244/// `NULL`, `NA` and `N/A`.
245pub const DEFAULT_NULL_TOKENS: [&str; 5] = ["", "null", "NULL", "NA", "N/A"];
246
247/// Infers column types. See the [module docs](self).
248#[derive(Clone, Debug)]
249pub struct Inferrer {
250    nulls: Vec<String>,
251    overrides: HashMap<String, InferredType>,
252}
253
254impl Default for Inferrer {
255    fn default() -> Self {
256        Inferrer {
257            nulls: DEFAULT_NULL_TOKENS.map(String::from).to_vec(),
258            overrides: HashMap::new(),
259        }
260    }
261}
262
263impl Inferrer {
264    /// The default null tokens and no overrides.
265    pub fn new() -> Self {
266        Inferrer::default()
267    }
268
269    /// Replace the null tokens (compared after trimming whitespace).
270    pub fn null_tokens(mut self, tokens: impl IntoIterator<Item = impl Into<String>>) -> Self {
271        self.nulls = tokens.into_iter().map(Into::into).collect();
272        self
273    }
274
275    /// Give the column called `column` this type, whatever its cells say.
276    pub fn override_type(mut self, column: impl Into<String>, data_type: InferredType) -> Self {
277        self.overrides.insert(column.into(), data_type);
278        self
279    }
280
281    /// Infer every column of `rows`.
282    pub fn infer(&self, rows: &Rows) -> Inference {
283        let columns = rows
284            .columns()
285            .iter()
286            .enumerate()
287            .map(|(index, name)| {
288                let mut evidence = Evidence::default();
289                for value in rows.column(index) {
290                    self.count(value, &mut evidence);
291                }
292                let (data_type, overridden) = match self.overrides.get(name) {
293                    Some(&t) => (t, true),
294                    None => (evidence.best(), false),
295                };
296                ColumnInference {
297                    name: name.clone(),
298                    data_type,
299                    evidence,
300                    overridden,
301                }
302            })
303            .collect();
304        Inference {
305            columns,
306            nulls: self.nulls.clone(),
307        }
308    }
309
310    fn count(&self, value: &Value, e: &mut Evidence) {
311        e.cells += 1;
312        match value {
313            Value::Null => e.nulls += 1,
314            Value::Int(_) => {
315                e.integers += 1;
316                e.floats += 1;
317            }
318            Value::Float(_) => e.floats += 1,
319            Value::Str(_) | Value::Text(_) => {
320                let text = value.plain();
321                let text = text.trim();
322                if is_null(&self.nulls, text) {
323                    e.nulls += 1;
324                    return;
325                }
326                if parse_bool(text).is_some() {
327                    e.booleans += 1;
328                }
329                if parse_int(text).is_some() {
330                    e.integers += 1;
331                }
332                if parse_float(text).is_some() {
333                    e.floats += 1;
334                }
335                if is_date(text) {
336                    e.dates += 1;
337                    e.timestamps += 1;
338                } else if is_timestamp(text) {
339                    e.timestamps += 1;
340                }
341            }
342        }
343    }
344}
345
346fn is_null(tokens: &[String], text: &str) -> bool {
347    tokens.iter().any(|t| t == text)
348}
349
350/// Every column's inferred type, from [`Inferrer::infer`].
351#[derive(Clone, Debug, PartialEq, Eq)]
352pub struct Inference {
353    columns: Vec<ColumnInference>,
354    nulls: Vec<String>,
355}
356
357impl Inference {
358    /// One entry per column, in order.
359    pub fn columns(&self) -> &[ColumnInference] {
360        &self.columns
361    }
362
363    /// The schema these types describe: a field per column, nullable when
364    /// any cell was null.
365    pub fn schema(&self) -> Schema {
366        Schema::new(self.columns.iter().map(|c| {
367            Field::new(c.name.clone(), c.data_type.data_type()).nullable(c.evidence.nulls > 0)
368        }))
369    }
370
371    /// Convert `rows`' cells to their columns' types and attach
372    /// [`schema`](Self::schema): null tokens become [`Value::Null`], integer
373    /// columns [`Value::Int`], float columns [`Value::Float`]. Booleans,
374    /// dates and timestamps stay as written ([`Value`] has no variant for
375    /// them) but are typed in the schema. A cell that does not parse as its
376    /// column's (overridden) type is left as it was.
377    pub fn apply(&self, rows: &mut Rows) {
378        let types: Vec<InferredType> = self.columns.iter().map(|c| c.data_type).collect();
379        for row in rows.rows_mut() {
380            for (cell, &data_type) in row.iter_mut().zip(&types) {
381                if data_type == InferredType::Text {
382                    continue;
383                }
384                let converted = match &*cell {
385                    Value::Str(_) | Value::Text(_) => {
386                        let plain = cell.plain();
387                        let text = plain.trim();
388                        if is_null(&self.nulls, text) {
389                            Some(Value::Null)
390                        } else {
391                            match data_type {
392                                InferredType::Integer => parse_int(text).map(Value::Int),
393                                InferredType::Float => parse_float(text).map(Value::Float),
394                                _ => None,
395                            }
396                        }
397                    }
398                    Value::Int(n) if data_type == InferredType::Float => {
399                        Some(Value::Float(*n as f64))
400                    }
401                    _ => None,
402                };
403                if let Some(converted) = converted {
404                    *cell = converted;
405                }
406            }
407        }
408        rows.set_schema(Some(self.schema()));
409    }
410}
411
412/// A table of column, type and evidence.
413impl Renderable for Inference {
414    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
415        self.to_table().rich_render(console, options)
416    }
417
418    fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
419        self.to_table().measure(console, options)
420    }
421}
422
423impl Inference {
424    /// The table this renders as: column, type, parsed, nulls.
425    pub fn to_table(&self) -> Table {
426        let mut table = Table::new();
427        table.add_column("column");
428        table.add_column("type");
429        table.add_column_justify("parsed", Justify::Right);
430        table.add_column_justify("nulls", Justify::Right);
431        for c in &self.columns {
432            let mut kind = c.data_type.name().to_string();
433            if c.overridden {
434                kind.push_str(" (override)");
435            }
436            let parsed = match c.data_type {
437                InferredType::Null => String::new(),
438                InferredType::Text => match c.evidence.closest() {
439                    Some((t, n)) if !c.overridden => format!("{n}/{} {t}", c.evidence.values()),
440                    _ => format!("{}/{}", c.evidence.values(), c.evidence.values()),
441                },
442                t => format!("{}/{}", c.evidence.parsed(t), c.evidence.values()),
443            };
444            table.add_row_text(vec![
445                rich::Text::new(c.name.clone()),
446                rich::Text::new(kind),
447                rich::Text::new(parsed),
448                rich::Text::new(c.evidence.nulls.to_string()),
449            ]);
450        }
451        table
452    }
453}
454
455/// `true` or `false`, in any case.
456pub fn parse_bool(text: &str) -> Option<bool> {
457    if text.eq_ignore_ascii_case("true") {
458        Some(true)
459    } else if text.eq_ignore_ascii_case("false") {
460        Some(false)
461    } else {
462        None
463    }
464}
465
466/// An optional sign and ASCII digits that fit an `i64`.
467pub fn parse_int(text: &str) -> Option<i64> {
468    let digits = text.strip_prefix(['+', '-']).unwrap_or(text);
469    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
470        return None;
471    }
472    text.parse().ok()
473}
474
475/// A decimal number: an optional sign, digits with an optional point, and
476/// an optional exponent. `inf` and `nan` are text.
477pub fn parse_float(text: &str) -> Option<f64> {
478    let body = text.strip_prefix(['+', '-']).unwrap_or(text);
479    let (mantissa, exponent) = match body.find(['e', 'E']) {
480        Some(at) => (&body[..at], Some(&body[at + 1..])),
481        None => (body, None),
482    };
483    let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
484    let digits = |s: &str| s.bytes().all(|b| b.is_ascii_digit());
485    if whole.len() + fraction.len() == 0 || !digits(whole) || !digits(fraction) {
486        return None;
487    }
488    if let Some(exponent) = exponent {
489        let exponent = exponent.strip_prefix(['+', '-']).unwrap_or(exponent);
490        if exponent.is_empty() || !digits(exponent) {
491            return None;
492        }
493    }
494    text.parse().ok()
495}
496
497/// `YYYY-MM-DD`, a real calendar date.
498pub fn is_date(text: &str) -> bool {
499    let b = text.as_bytes();
500    if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
501        return false;
502    }
503    let number = |s: &[u8]| -> Option<u32> {
504        s.iter().try_fold(0u32, |n, &c| {
505            c.is_ascii_digit().then(|| n * 10 + u32::from(c - b'0'))
506        })
507    };
508    let (Some(year), Some(month), Some(day)) = (number(&b[..4]), number(&b[5..7]), number(&b[8..]))
509    else {
510        return false;
511    };
512    let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
513    let days = match month {
514        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
515        4 | 6 | 9 | 11 => 30,
516        2 if leap => 29,
517        2 => 28,
518        _ => return false,
519    };
520    (1..=days).contains(&day)
521}
522
523/// A date, `T` or a space, `HH:MM`, optional `:SS` and fraction, and an
524/// optional `Z` or `±HH:MM` / `±HHMM` offset.
525pub fn is_timestamp(text: &str) -> bool {
526    if text.len() < 16 || !text.is_char_boundary(10) || !is_date(&text[..10]) {
527        return false;
528    }
529    let rest = &text.as_bytes()[10..];
530    if rest[0] != b'T' && rest[0] != b't' && rest[0] != b' ' {
531        return false;
532    }
533    let mut time = &rest[1..];
534    let two = |s: &[u8], max: u32| -> bool {
535        s.len() >= 2
536            && s[0].is_ascii_digit()
537            && s[1].is_ascii_digit()
538            && u32::from(s[0] - b'0') * 10 + u32::from(s[1] - b'0') <= max
539    };
540    if !(two(time, 23) && time.get(2) == Some(&b':') && two(&time[3..], 59)) {
541        return false;
542    }
543    time = &time[5..];
544    if time.first() == Some(&b':') {
545        if !two(&time[1..], 60) {
546            return false;
547        }
548        time = &time[3..];
549        if time.first() == Some(&b'.') {
550            let digits = time[1..].iter().take_while(|c| c.is_ascii_digit()).count();
551            if digits == 0 {
552                return false;
553            }
554            time = &time[1 + digits..];
555        }
556    }
557    match time {
558        [] | [b'Z'] | [b'z'] => true,
559        [b'+' | b'-', h1, h2, b':', m1, m2] | [b'+' | b'-', h1, h2, m1, m2] => {
560            two(&[*h1, *h2], 23) && two(&[*m1, *m2], 59)
561        }
562        _ => false,
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    #[test]
571    fn numbers_parse_strictly() {
572        assert_eq!(parse_int("-42"), Some(-42));
573        assert_eq!(parse_int("+7"), Some(7));
574        assert_eq!(parse_int("1.0"), None);
575        assert_eq!(parse_int("99999999999999999999"), None);
576        assert_eq!(parse_float("99999999999999999999"), Some(1e20));
577        assert_eq!(parse_float(".5"), Some(0.5));
578        assert_eq!(parse_float("5."), Some(5.0));
579        assert_eq!(parse_float("-1.5e-3"), Some(-0.0015));
580        for text in ["inf", "NaN", ".", "-", "1e", "1_000", "0x10", " 1", "1.2.3"] {
581            assert_eq!(parse_float(text), None, "{text}");
582        }
583        assert_eq!(parse_bool("TRUE"), Some(true));
584        assert_eq!(parse_bool("yes"), None);
585    }
586
587    #[test]
588    fn dates_and_timestamps_are_checked() {
589        assert!(is_date("2024-02-29"));
590        assert!(!is_date("2023-02-29"));
591        assert!(!is_date("2024-13-01"));
592        assert!(!is_date("2024-1-01"));
593        for ok in [
594            "2024-02-29T23:59",
595            "2024-02-29 23:59:60",
596            "2024-02-29T00:00:00.123456Z",
597            "2024-02-29T00:00:00+05:30",
598            "2024-02-29T00:00-0800",
599        ] {
600            assert!(is_timestamp(ok), "{ok}");
601        }
602        for bad in [
603            "2024-02-29",
604            "2024-02-29T24:00",
605            "2024-02-29T10:00:00.",
606            "2024-02-29T10:00+5",
607            "2024-02-29X10:00",
608        ] {
609            assert!(!is_timestamp(bad), "{bad}");
610        }
611    }
612
613    #[test]
614    fn columns_take_the_narrowest_type_and_overrides_win() {
615        let mut rows = Rows::new(["n", "x", "b", "empty", "mixed"]);
616        rows.push([
617            "1".into(),
618            "1".into(),
619            "true".into(),
620            "".into(),
621            Value::Int(3),
622        ]);
623        rows.push([
624            "2".into(),
625            "2.5".into(),
626            "False".into(),
627            "NA".into(),
628            "x".into(),
629        ]);
630        let inference = Inferrer::new()
631            .override_type("n", InferredType::Float)
632            .infer(&rows);
633        let types: Vec<InferredType> = inference.columns().iter().map(|c| c.data_type()).collect();
634        assert_eq!(
635            types,
636            [
637                InferredType::Float,
638                InferredType::Float,
639                InferredType::Boolean,
640                InferredType::Null,
641                InferredType::Text
642            ]
643        );
644        assert!(inference.columns()[0].is_overridden());
645        assert_eq!(
646            inference.columns()[0].summary(),
647            "float (override): 2 of 2 values"
648        );
649        assert_eq!(inference.columns()[3].summary(), "null: 2 null");
650        let schema = inference.schema();
651        assert!(schema.fields()[3].is_nullable());
652        assert!(!schema.fields()[0].is_nullable());
653
654        inference.apply(&mut rows);
655        assert_eq!(rows.rows()[0][0], Value::Float(1.0));
656        assert_eq!(rows.rows()[1][1], Value::Float(2.5));
657        assert_eq!(rows.rows()[1][2], Value::from("False"));
658        assert_eq!(rows.rows()[1][3], Value::Null);
659        assert_eq!(rows.rows()[0][4], Value::Int(3));
660        assert_eq!(
661            rows.schema().unwrap().fields()[2].data_type(),
662            &DataType::Boolean
663        );
664    }
665
666    #[test]
667    fn the_report_is_a_table() {
668        let mut rows = Rows::new(["id"]);
669        rows.push(["1".into()]);
670        rows.push(["x".into()]);
671        let inference = Inferrer::new().infer(&rows);
672        let out = Console::builder()
673            .width(40)
674            .build()
675            .render_export(&inference);
676        assert!(
677            out.contains("│ id     │ text │ 1/2 integer │     0 │"),
678            "{out}"
679        );
680    }
681}