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    #[must_use]
271    pub fn null_tokens(mut self, tokens: impl IntoIterator<Item = impl Into<String>>) -> Self {
272        self.nulls = tokens.into_iter().map(Into::into).collect();
273        self
274    }
275
276    /// Give the column called `column` this type, whatever its cells say.
277    #[must_use]
278    pub fn override_type(mut self, column: impl Into<String>, data_type: InferredType) -> Self {
279        self.overrides.insert(column.into(), data_type);
280        self
281    }
282
283    /// Infer every column of `rows`.
284    pub fn infer(&self, rows: &Rows) -> Inference {
285        let columns = rows
286            .columns()
287            .iter()
288            .enumerate()
289            .map(|(index, name)| {
290                let mut evidence = Evidence::default();
291                for value in rows.column(index) {
292                    self.count(value, &mut evidence);
293                }
294                let (data_type, overridden) = match self.overrides.get(name) {
295                    Some(&t) => (t, true),
296                    None => (evidence.best(), false),
297                };
298                ColumnInference {
299                    name: name.clone(),
300                    data_type,
301                    evidence,
302                    overridden,
303                }
304            })
305            .collect();
306        Inference {
307            columns,
308            nulls: self.nulls.clone(),
309        }
310    }
311
312    fn count(&self, value: &Value, e: &mut Evidence) {
313        e.cells += 1;
314        match value {
315            Value::Null => e.nulls += 1,
316            Value::Int(_) => {
317                e.integers += 1;
318                e.floats += 1;
319            }
320            Value::Float(_) => e.floats += 1,
321            Value::Str(_) | Value::Text(_) => {
322                let text = value.plain();
323                let text = text.trim();
324                if is_null(&self.nulls, text) {
325                    e.nulls += 1;
326                    return;
327                }
328                if parse_bool(text).is_some() {
329                    e.booleans += 1;
330                }
331                if parse_int(text).is_some() {
332                    e.integers += 1;
333                }
334                if parse_float(text).is_some() {
335                    e.floats += 1;
336                }
337                if is_date(text) {
338                    e.dates += 1;
339                    e.timestamps += 1;
340                } else if is_timestamp(text) {
341                    e.timestamps += 1;
342                }
343            }
344        }
345    }
346}
347
348fn is_null(tokens: &[String], text: &str) -> bool {
349    tokens.iter().any(|t| t == text)
350}
351
352/// Every column's inferred type, from [`Inferrer::infer`].
353#[derive(Clone, Debug, PartialEq, Eq)]
354pub struct Inference {
355    columns: Vec<ColumnInference>,
356    nulls: Vec<String>,
357}
358
359impl Inference {
360    /// One entry per column, in order.
361    pub fn columns(&self) -> &[ColumnInference] {
362        &self.columns
363    }
364
365    /// The schema these types describe: a field per column, nullable when
366    /// any cell was null.
367    pub fn schema(&self) -> Schema {
368        Schema::new(self.columns.iter().map(|c| {
369            Field::new(c.name.clone(), c.data_type.data_type()).nullable(c.evidence.nulls > 0)
370        }))
371    }
372
373    /// Convert `rows`' cells to their columns' types and attach
374    /// [`schema`](Self::schema): null tokens become [`Value::Null`], integer
375    /// columns [`Value::Int`], float columns [`Value::Float`]. Booleans,
376    /// dates and timestamps stay as written ([`Value`] has no variant for
377    /// them) but are typed in the schema. A cell that does not parse as its
378    /// column's (overridden) type is left as it was.
379    pub fn apply(&self, rows: &mut Rows) {
380        let types: Vec<InferredType> = self.columns.iter().map(|c| c.data_type).collect();
381        for row in rows.rows_mut() {
382            for (cell, &data_type) in row.iter_mut().zip(&types) {
383                if data_type == InferredType::Text {
384                    continue;
385                }
386                let converted = match &*cell {
387                    Value::Str(_) | Value::Text(_) => {
388                        let plain = cell.plain();
389                        let text = plain.trim();
390                        if is_null(&self.nulls, text) {
391                            Some(Value::Null)
392                        } else {
393                            match data_type {
394                                InferredType::Integer => parse_int(text).map(Value::Int),
395                                InferredType::Float => parse_float(text).map(Value::Float),
396                                _ => None,
397                            }
398                        }
399                    }
400                    Value::Int(n) if data_type == InferredType::Float => {
401                        Some(Value::Float(*n as f64))
402                    }
403                    _ => None,
404                };
405                if let Some(converted) = converted {
406                    *cell = converted;
407                }
408            }
409        }
410        rows.set_schema(Some(self.schema()));
411    }
412}
413
414/// A table of column, type and evidence.
415impl Renderable for Inference {
416    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
417        self.to_table().rich_render(console, options)
418    }
419
420    fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
421        self.to_table().measure(console, options)
422    }
423}
424
425impl Inference {
426    /// The table this renders as: column, type, parsed, nulls.
427    pub fn to_table(&self) -> Table {
428        let mut table = Table::new();
429        table.add_column("column");
430        table.add_column("type");
431        table.add_column_justify("parsed", Justify::Right);
432        table.add_column_justify("nulls", Justify::Right);
433        for c in &self.columns {
434            let mut kind = c.data_type.name().to_string();
435            if c.overridden {
436                kind.push_str(" (override)");
437            }
438            let parsed = match c.data_type {
439                InferredType::Null => String::new(),
440                InferredType::Text => match c.evidence.closest() {
441                    Some((t, n)) if !c.overridden => format!("{n}/{} {t}", c.evidence.values()),
442                    _ => format!("{}/{}", c.evidence.values(), c.evidence.values()),
443                },
444                t => format!("{}/{}", c.evidence.parsed(t), c.evidence.values()),
445            };
446            table.add_row_text(vec![
447                rich::Text::new(c.name.clone()),
448                rich::Text::new(kind),
449                rich::Text::new(parsed),
450                rich::Text::new(c.evidence.nulls.to_string()),
451            ]);
452        }
453        table
454    }
455}
456
457/// `true` or `false`, in any case.
458pub fn parse_bool(text: &str) -> Option<bool> {
459    if text.eq_ignore_ascii_case("true") {
460        Some(true)
461    } else if text.eq_ignore_ascii_case("false") {
462        Some(false)
463    } else {
464        None
465    }
466}
467
468/// An optional sign and ASCII digits that fit an `i64`.
469pub fn parse_int(text: &str) -> Option<i64> {
470    let digits = text.strip_prefix(['+', '-']).unwrap_or(text);
471    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
472        return None;
473    }
474    text.parse().ok()
475}
476
477/// A decimal number: an optional sign, digits with an optional point, and
478/// an optional exponent. `inf` and `nan` are text.
479pub fn parse_float(text: &str) -> Option<f64> {
480    let body = text.strip_prefix(['+', '-']).unwrap_or(text);
481    let (mantissa, exponent) = match body.find(['e', 'E']) {
482        Some(at) => (&body[..at], Some(&body[at + 1..])),
483        None => (body, None),
484    };
485    let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
486    let digits = |s: &str| s.bytes().all(|b| b.is_ascii_digit());
487    if whole.len() + fraction.len() == 0 || !digits(whole) || !digits(fraction) {
488        return None;
489    }
490    if let Some(exponent) = exponent {
491        let exponent = exponent.strip_prefix(['+', '-']).unwrap_or(exponent);
492        if exponent.is_empty() || !digits(exponent) {
493            return None;
494        }
495    }
496    text.parse().ok()
497}
498
499/// `YYYY-MM-DD`, a real calendar date.
500pub fn is_date(text: &str) -> bool {
501    let b = text.as_bytes();
502    if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
503        return false;
504    }
505    let number = |s: &[u8]| -> Option<u32> {
506        s.iter().try_fold(0u32, |n, &c| {
507            c.is_ascii_digit().then(|| n * 10 + u32::from(c - b'0'))
508        })
509    };
510    let (Some(year), Some(month), Some(day)) = (number(&b[..4]), number(&b[5..7]), number(&b[8..]))
511    else {
512        return false;
513    };
514    let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
515    let days = match month {
516        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
517        4 | 6 | 9 | 11 => 30,
518        2 if leap => 29,
519        2 => 28,
520        _ => return false,
521    };
522    (1..=days).contains(&day)
523}
524
525/// A date, `T` or a space, `HH:MM`, optional `:SS` and fraction, and an
526/// optional `Z` or `±HH:MM` / `±HHMM` offset.
527pub fn is_timestamp(text: &str) -> bool {
528    if text.len() < 16 || !text.is_char_boundary(10) || !is_date(&text[..10]) {
529        return false;
530    }
531    let rest = &text.as_bytes()[10..];
532    if rest[0] != b'T' && rest[0] != b't' && rest[0] != b' ' {
533        return false;
534    }
535    let mut time = &rest[1..];
536    let two = |s: &[u8], max: u32| -> bool {
537        s.len() >= 2
538            && s[0].is_ascii_digit()
539            && s[1].is_ascii_digit()
540            && u32::from(s[0] - b'0') * 10 + u32::from(s[1] - b'0') <= max
541    };
542    if !(two(time, 23) && time.get(2) == Some(&b':') && two(&time[3..], 59)) {
543        return false;
544    }
545    time = &time[5..];
546    if time.first() == Some(&b':') {
547        if !two(&time[1..], 60) {
548            return false;
549        }
550        time = &time[3..];
551        if time.first() == Some(&b'.') {
552            let digits = time[1..].iter().take_while(|c| c.is_ascii_digit()).count();
553            if digits == 0 {
554                return false;
555            }
556            time = &time[1 + digits..];
557        }
558    }
559    match time {
560        [] | [b'Z'] | [b'z'] => true,
561        [b'+' | b'-', h1, h2, b':', m1, m2] | [b'+' | b'-', h1, h2, m1, m2] => {
562            two(&[*h1, *h2], 23) && two(&[*m1, *m2], 59)
563        }
564        _ => false,
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn numbers_parse_strictly() {
574        assert_eq!(parse_int("-42"), Some(-42));
575        assert_eq!(parse_int("+7"), Some(7));
576        assert_eq!(parse_int("1.0"), None);
577        assert_eq!(parse_int("99999999999999999999"), None);
578        assert_eq!(parse_float("99999999999999999999"), Some(1e20));
579        assert_eq!(parse_float(".5"), Some(0.5));
580        assert_eq!(parse_float("5."), Some(5.0));
581        assert_eq!(parse_float("-1.5e-3"), Some(-0.0015));
582        for text in ["inf", "NaN", ".", "-", "1e", "1_000", "0x10", " 1", "1.2.3"] {
583            assert_eq!(parse_float(text), None, "{text}");
584        }
585        assert_eq!(parse_bool("TRUE"), Some(true));
586        assert_eq!(parse_bool("yes"), None);
587    }
588
589    #[test]
590    fn dates_and_timestamps_are_checked() {
591        assert!(is_date("2024-02-29"));
592        assert!(!is_date("2023-02-29"));
593        assert!(!is_date("2024-13-01"));
594        assert!(!is_date("2024-1-01"));
595        for ok in [
596            "2024-02-29T23:59",
597            "2024-02-29 23:59:60",
598            "2024-02-29T00:00:00.123456Z",
599            "2024-02-29T00:00:00+05:30",
600            "2024-02-29T00:00-0800",
601        ] {
602            assert!(is_timestamp(ok), "{ok}");
603        }
604        for bad in [
605            "2024-02-29",
606            "2024-02-29T24:00",
607            "2024-02-29T10:00:00.",
608            "2024-02-29T10:00+5",
609            "2024-02-29X10:00",
610        ] {
611            assert!(!is_timestamp(bad), "{bad}");
612        }
613    }
614
615    #[test]
616    fn columns_take_the_narrowest_type_and_overrides_win() {
617        let mut rows = Rows::new(["n", "x", "b", "empty", "mixed"]);
618        rows.push([
619            "1".into(),
620            "1".into(),
621            "true".into(),
622            "".into(),
623            Value::Int(3),
624        ]);
625        rows.push([
626            "2".into(),
627            "2.5".into(),
628            "False".into(),
629            "NA".into(),
630            "x".into(),
631        ]);
632        let inference = Inferrer::new()
633            .override_type("n", InferredType::Float)
634            .infer(&rows);
635        let types: Vec<InferredType> = inference.columns().iter().map(|c| c.data_type()).collect();
636        assert_eq!(
637            types,
638            [
639                InferredType::Float,
640                InferredType::Float,
641                InferredType::Boolean,
642                InferredType::Null,
643                InferredType::Text
644            ]
645        );
646        assert!(inference.columns()[0].is_overridden());
647        assert_eq!(
648            inference.columns()[0].summary(),
649            "float (override): 2 of 2 values"
650        );
651        assert_eq!(inference.columns()[3].summary(), "null: 2 null");
652        let schema = inference.schema();
653        assert!(schema.fields()[3].is_nullable());
654        assert!(!schema.fields()[0].is_nullable());
655
656        inference.apply(&mut rows);
657        assert_eq!(rows.rows()[0][0], Value::Float(1.0));
658        assert_eq!(rows.rows()[1][1], Value::Float(2.5));
659        assert_eq!(rows.rows()[1][2], Value::from("False"));
660        assert_eq!(rows.rows()[1][3], Value::Null);
661        assert_eq!(rows.rows()[0][4], Value::Int(3));
662        assert_eq!(
663            rows.schema().unwrap().fields()[2].data_type(),
664            &DataType::Boolean
665        );
666    }
667
668    #[test]
669    fn the_report_is_a_table() {
670        let mut rows = Rows::new(["id"]);
671        rows.push(["1".into()]);
672        rows.push(["x".into()]);
673        let inference = Inferrer::new().infer(&rows);
674        let out = Console::builder()
675            .width(40)
676            .build()
677            .render_export(&inference);
678        assert!(
679            out.contains("│ id     │ text │ 1/2 integer │     0 │"),
680            "{out}"
681        );
682    }
683}