Skip to main content

rich_data/
profile.rs

1//! Data profiles (#343, #344, #345, #346): what every column of a CSV, JSON
2//! Lines or any other [`RowSource`] holds, in one bounded pass.
3//!
4//! A [`Profile`] gives each column its inferred type ([`infer`](crate::infer)),
5//! null count and rate, distinct values, statistics ([`stats`](crate::stats))
6//! and a distribution: a histogram (from `rich_ext`'s
7//! [`chart::Histogram`](rich_ext::chart::Histogram)) for a numeric column, the
8//! most common values for any other. A missing-value map shows where the
9//! nulls are: the rows in order, bucketed, against the columns, drawn as a
10//! [`chart::Heatmap`](rich_ext::chart::Heatmap). The model serialises to JSON
11//! (`serde`), and renders as a heading, a table of the columns, each
12//! distribution and the map.
13//!
14//! **Bounded.** A [`Profiler`] reads rows one at a time and keeps at most
15//! [`ProfileOptions::sample`] of them (default [`DEFAULT_SAMPLE`]): a uniform
16//! reservoir sample (Algorithm R) drawn with a fixed seed, so the same input
17//! always gives the same profile. Types, distinct values, statistics and
18//! distributions describe the sample; the row count, every null count and
19//! the missing-value map count every row (the map holds at most
20//! [`ProfileOptions::buckets`] buckets, merging neighbours as rows arrive).
21//! [`Profile::sampled`] says whether the sample is all of it, and the
22//! heading prints the sample size. Memory is the sample plus the map, however
23//! long the input.
24//!
25//! Nulls are [`Value::Null`] and the null tokens (by default
26//! [`DEFAULT_NULL_TOKENS`]: an empty cell, `null`, `NULL`, `NA`, `N/A`).
27//!
28//! ```
29//! use rich::Console;
30//! use rich_data::profile::Profile;
31//! use rich_data::{csv::CsvReader, RowSource};
32//!
33//! let text = "service,p99\nweb,120\napi,35\ndb,\nweb,80\n";
34//! let profile = Profile::from_source(
35//!     CsvReader::new().header(true).source(text.as_bytes()).unwrap(),
36//!     Default::default(),
37//! )
38//! .unwrap();
39//! assert_eq!(profile.rows(), 4);
40//! assert!(!profile.sampled());
41//! let p99 = profile.column("p99").unwrap();
42//! assert_eq!((p99.data_type.as_str(), p99.nulls), ("integer", 1));
43//! assert_eq!(p99.mean, Some(78.33333333333333));
44//! let service = profile.column("service").unwrap();
45//! assert_eq!(service.distinct, 3);
46//!
47//! let console = Console::builder().width(60).color_system(None).build();
48//! let out = console.render_export(&profile);
49//! assert!(out.starts_with("4 rows, 2 columns\n"));
50//! assert!(out.contains("p99 · integer · 1 null (25%)"));
51//! let json = profile.to_json();
52//! assert_eq!(json["columns"][1]["distribution"]["kind"], "histogram");
53//! ```
54//!
55//! [`DEFAULT_NULL_TOKENS`]: crate::infer::DEFAULT_NULL_TOKENS
56
57use rich::{
58    ColumnOptions, Console, ConsoleOptions, Justify, Renderable, Segment, Style, Table, Text,
59};
60use rich_ext::chart::{BarChart, Heatmap, Histogram};
61use rich_ext::sanitize::sanitize_single_line;
62use serde::{Deserialize, Serialize};
63
64use crate::infer::{parse_float, Inferrer, DEFAULT_NULL_TOKENS};
65use crate::stats::{number, Stats, StatsOptions};
66use crate::{DataError, Row, RowSource, Rows, Value};
67
68/// The rows a profile keeps by default.
69pub const DEFAULT_SAMPLE: usize = 10_000;
70/// The most common values a categorical column shows by default.
71pub const DEFAULT_TOP: usize = 5;
72/// Histogram bins by default.
73pub const DEFAULT_BINS: usize = 10;
74/// Rows of the missing-value map by default.
75pub const DEFAULT_BUCKETS: usize = 20;
76/// The longest value label a distribution writes, in characters.
77const LABEL_CHARS: usize = 24;
78/// Cells for the longest bar of a distribution.
79const BAR_WIDTH: usize = 24;
80
81/// The theme keys a profile draws with, and their defaults. A console theme
82/// that defines a key wins.
83pub const STYLES: &[(&str, &str)] = &[
84    ("profile.title", "bold"),
85    ("profile.column", "bold"),
86    ("profile.type", "cyan"),
87    ("profile.note", "dim"),
88    ("profile.null", "yellow"),
89];
90
91/// The console theme's style for `key`, else its default from [`STYLES`].
92pub(crate) fn style(console: &Console, key: &str, styles: &[(&str, &str)]) -> Style {
93    if let Some(style) = console.theme().get(key) {
94        return style.clone();
95    }
96    styles
97        .iter()
98        .find(|(name, _)| *name == key)
99        .and_then(|(_, spec)| Style::parse(spec).ok())
100        .unwrap_or_default()
101}
102
103/// What a [`Profiler`] keeps and shows.
104#[derive(Clone, Debug, PartialEq)]
105pub struct ProfileOptions {
106    /// The most rows kept for types, statistics and distributions (default
107    /// [`DEFAULT_SAMPLE`], at least 1).
108    pub sample: usize,
109    /// The most common values a categorical column keeps (default
110    /// [`DEFAULT_TOP`]).
111    pub top: usize,
112    /// Histogram bins for a numeric column (default [`DEFAULT_BINS`]).
113    pub bins: usize,
114    /// The most rows of the missing-value map (default [`DEFAULT_BUCKETS`],
115    /// at least 1).
116    pub buckets: usize,
117    /// Only these columns, by name, in this order (default every column).
118    pub columns: Option<Vec<String>>,
119    /// The cells that count as null, compared after trimming (default
120    /// [`DEFAULT_NULL_TOKENS`]).
121    pub nulls: Vec<String>,
122}
123
124impl Default for ProfileOptions {
125    fn default() -> Self {
126        ProfileOptions {
127            sample: DEFAULT_SAMPLE,
128            top: DEFAULT_TOP,
129            bins: DEFAULT_BINS,
130            buckets: DEFAULT_BUCKETS,
131            columns: None,
132            nulls: DEFAULT_NULL_TOKENS.map(String::from).to_vec(),
133        }
134    }
135}
136
137/// One column of a [`Profile`].
138#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
139pub struct ColumnProfile {
140    /// The column name.
141    pub name: String,
142    /// The inferred type's name (`integer`, `float`, `boolean`, `date`,
143    /// `timestamp`, `text`, or `null` when every sampled cell is null).
144    #[serde(rename = "type")]
145    pub data_type: String,
146    /// Null cells, over every row.
147    pub nulls: u64,
148    /// `nulls` over the row count, `0.0` for no rows.
149    pub null_rate: f64,
150    /// Non-null cells in the sample.
151    pub count: usize,
152    /// Distinct non-null values in the sample.
153    pub distinct: usize,
154    /// The smallest value in the sample (numerically for a number column).
155    pub min: Option<String>,
156    /// The largest value in the sample.
157    pub max: Option<String>,
158    /// The mean, for a number column.
159    pub mean: Option<f64>,
160    /// The median, for a number column.
161    pub median: Option<f64>,
162    /// The first quartile, for a number column.
163    pub p25: Option<f64>,
164    /// The third quartile, for a number column.
165    pub p75: Option<f64>,
166    /// How the sampled values are spread.
167    pub distribution: Distribution,
168}
169
170/// How a column's values are spread.
171#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
172#[serde(tag = "kind", rename_all = "snake_case")]
173pub enum Distribution {
174    /// A number column: values counted into bins.
175    Histogram {
176        /// The bins, low to high.
177        bins: Vec<Bin>,
178    },
179    /// Any other column: its most common values.
180    Top {
181        /// The most common values, most common first (ties in text order).
182        values: Vec<TopValue>,
183        /// Non-null cells holding any other value.
184        other: usize,
185    },
186    /// No non-null values.
187    Empty,
188}
189
190/// One histogram bin: `[low, high)`, the last `[low, high]`.
191#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
192pub struct Bin {
193    /// `[low, high)` as the chart writes it.
194    pub label: String,
195    /// The low edge.
196    pub low: f64,
197    /// The high edge.
198    pub high: f64,
199    /// Values in the bin.
200    pub count: usize,
201}
202
203/// One of a column's most common values.
204#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
205pub struct TopValue {
206    /// The value as text.
207    pub value: String,
208    /// How many sampled cells hold it.
209    pub count: usize,
210}
211
212/// Where the nulls are: the rows in order, in buckets of
213/// [`bucket_rows`](Self::bucket_rows), each with its null count per column.
214#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
215pub struct MissingMap {
216    /// Rows per bucket (the last may hold fewer).
217    pub bucket_rows: u64,
218    /// The buckets, first rows first.
219    pub buckets: Vec<MissingBucket>,
220}
221
222/// One bucket of a [`MissingMap`].
223#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
224pub struct MissingBucket {
225    /// The first row, counted from 1.
226    pub first: u64,
227    /// Rows in the bucket.
228    pub rows: u64,
229    /// Null cells per column, in the profile's column order.
230    pub nulls: Vec<u64>,
231}
232
233/// A profile of rows. See the [module docs](self).
234#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
235pub struct Profile {
236    /// What was profiled (a file name), for the heading.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub name: Option<String>,
239    rows: u64,
240    sample: usize,
241    columns: Vec<ColumnProfile>,
242    missing: MissingMap,
243    #[serde(default, skip_serializing_if = "Vec::is_empty")]
244    notes: Vec<String>,
245}
246
247impl Profile {
248    /// A profile of rows in memory, with the default options.
249    pub fn of(rows: &Rows) -> Self {
250        let mut profiler = Profiler::new(rows.columns(), ProfileOptions::default())
251            .expect("every column of the rows exists");
252        for row in rows.rows() {
253            profiler.push(row.clone());
254        }
255        profiler.finish()
256    }
257
258    /// Profile every row of `source`, stopping at the first that cannot be
259    /// read.
260    pub fn from_source(
261        mut source: impl RowSource,
262        options: ProfileOptions,
263    ) -> Result<Self, DataError> {
264        let mut profiler = Profiler::new(source.columns(), options)?;
265        while let Some(row) = source.next_row() {
266            profiler.push(row?);
267        }
268        Ok(profiler.finish())
269    }
270
271    /// Name what was profiled, for the heading.
272    pub fn with_name(mut self, name: impl Into<String>) -> Self {
273        self.name = Some(name.into());
274        self
275    }
276
277    /// Add a note shown under the heading (rows cut to fit, keys seen late).
278    pub fn note(&mut self, note: impl Into<String>) {
279        self.notes.push(note.into());
280    }
281
282    /// Every row read.
283    pub fn rows(&self) -> u64 {
284        self.rows
285    }
286
287    /// The rows the types, statistics and distributions describe.
288    pub fn sample_size(&self) -> usize {
289        self.sample
290    }
291
292    /// Whether the sample is fewer rows than were read.
293    pub fn sampled(&self) -> bool {
294        (self.sample as u64) < self.rows
295    }
296
297    /// One entry per column, in order.
298    pub fn columns(&self) -> &[ColumnProfile] {
299        &self.columns
300    }
301
302    /// The column called `name`.
303    pub fn column(&self, name: &str) -> Option<&ColumnProfile> {
304        self.columns.iter().find(|c| c.name == name)
305    }
306
307    /// Where the nulls are.
308    pub fn missing(&self) -> &MissingMap {
309        &self.missing
310    }
311
312    /// The notes, in order.
313    pub fn notes(&self) -> &[String] {
314        &self.notes
315    }
316
317    /// The profile as JSON: `rows`, `sample`, `sampled`, `columns`,
318    /// `missing`, and `name` and `notes` when set.
319    pub fn to_json(&self) -> serde_json::Value {
320        let mut value = serde_json::to_value(self).unwrap_or_default();
321        if let Some(object) = value.as_object_mut() {
322            object.insert("sampled".into(), self.sampled().into());
323        }
324        value
325    }
326
327    /// The heading: the name, the rows (and the sample, when smaller) and
328    /// the columns.
329    pub fn heading(&self) -> String {
330        let rows = if self.sampled() {
331            format!(
332                "sampled {} of {} rows",
333                grouped(self.sample as u64),
334                grouped(self.rows)
335            )
336        } else {
337            format!(
338                "{} {}",
339                grouped(self.rows),
340                plural(self.rows, "row", "rows")
341            )
342        };
343        let columns = self.columns.len() as u64;
344        let body = format!("{rows}, {columns} {}", plural(columns, "column", "columns"));
345        match &self.name {
346            Some(name) => format!("{}: {body}", sanitize_single_line(name)),
347            None => body,
348        }
349    }
350
351    /// The table of columns: type, nulls, distinct, min, max and mean (the
352    /// median and quartiles are in the model and under each numeric
353    /// column's heading).
354    pub fn to_table(&self) -> Table {
355        self.table(true)
356    }
357
358    /// [`to_table`](Self::to_table), its name column held at the names'
359    /// width (up to 16) when `names_first`.
360    fn table(&self, names_first: bool) -> Table {
361        let mut table = Table::new();
362        // Names give way last.
363        let widest = self
364            .columns
365            .iter()
366            .map(|c| c.name.chars().count())
367            .max()
368            .unwrap_or(0);
369        table.add_column_with(
370            Text::new("column"),
371            ColumnOptions {
372                min_width: names_first.then(|| widest.clamp(6, 16)),
373                ..ColumnOptions::default()
374            },
375        );
376        table.add_column("type");
377        for heading in ["nulls", "distinct", "min", "max", "mean"] {
378            table.add_column_justify(heading, Justify::Right);
379        }
380        for c in &self.columns {
381            let optional = |v: Option<f64>| v.map(number).unwrap_or_default();
382            let text = |v: &Option<String>| v.as_deref().map(label).unwrap_or_default();
383            table.add_row_text(
384                [
385                    sanitize_single_line(&c.name),
386                    c.data_type.clone(),
387                    nulls(c.nulls, c.null_rate),
388                    c.distinct.to_string(),
389                    text(&c.min),
390                    text(&c.max),
391                    optional(c.mean),
392                ]
393                .into_iter()
394                .map(Text::new)
395                .collect(),
396            );
397        }
398        table
399    }
400
401    /// The missing-value map as a heatmap: a row per bucket, a column per
402    /// column, each cell the percentage of the bucket's cells that are null.
403    /// `None` when nothing is null.
404    pub fn missing_heatmap(&self) -> Option<Heatmap> {
405        if self.columns.iter().all(|c| c.nulls == 0) {
406            return None;
407        }
408        let widest = self
409            .columns
410            .iter()
411            .map(|c| c.name.chars().count())
412            .max()
413            .unwrap_or(1);
414        let mut map = Heatmap::new()
415            .columns(self.columns.iter().map(|c| sanitize_single_line(&c.name)))
416            .range(0.0, 100.0)
417            .cell_width((widest + 1).clamp(2, 12));
418        for bucket in &self.missing.buckets {
419            let last = bucket.first + bucket.rows.saturating_sub(1);
420            let label = if bucket.rows <= 1 {
421                bucket.first.to_string()
422            } else {
423                format!("{}-{last}", bucket.first)
424            };
425            let rows = bucket.rows.max(1) as f64;
426            map = map.row(label, bucket.nulls.iter().map(|&n| n as f64 * 100.0 / rows));
427        }
428        Some(map)
429    }
430}
431
432/// A column's distribution as bars: the histogram's bins, or the most
433/// common values.
434fn distribution_chart(distribution: &Distribution) -> Option<BarChart> {
435    let bars: Vec<(String, f64)> = match distribution {
436        Distribution::Histogram { bins } => bins
437            .iter()
438            .map(|b| (b.label.clone(), b.count as f64))
439            .collect(),
440        Distribution::Top { values, .. } => values
441            .iter()
442            .map(|v| (label(&v.value), v.count as f64))
443            .collect(),
444        Distribution::Empty => return None,
445    };
446    Some(BarChart::from_pairs(bars).bar_width(BAR_WIDTH))
447}
448
449impl Renderable for Profile {
450    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
451        let width = options.max_width;
452        if width == 0 {
453            return Vec::new();
454        }
455        let options = options.reset_height();
456        let note = style(console, "profile.note", STYLES);
457        let mut lines: Vec<Vec<Segment>> = Vec::new();
458        let text = |text: Text, indent: usize, lines: &mut Vec<Vec<Segment>>| {
459            block(console, &options, &text, indent, lines);
460        };
461
462        text(
463            Text::styled(self.heading(), style(console, "profile.title", STYLES)),
464            0,
465            &mut lines,
466        );
467        if self.sampled() {
468            text(
469                Text::styled(
470                    "types, distinct values, statistics and distributions are of a uniform \
471                     sample; nulls and the missing-value map count every row",
472                    note.clone(),
473                ),
474                0,
475                &mut lines,
476            );
477        }
478        for line in &self.notes {
479            text(
480                Text::styled(sanitize_single_line(line), note.clone()),
481                0,
482                &mut lines,
483            );
484        }
485        if self.columns.is_empty() {
486            return join(lines);
487        }
488        lines.push(Vec::new());
489        // A name column held wide can push the table past a narrow width;
490        // then the names give way too.
491        let mut table = self.to_table();
492        let drawn = console.render(&table, Some(&options));
493        if Segment::split_lines(&drawn)
494            .iter()
495            .any(|line| line.iter().map(Segment::cell_length).sum::<usize>() > options.max_width)
496        {
497            table = self.table(false);
498        }
499        block(console, &options, &table, 0, &mut lines);
500
501        for c in &self.columns {
502            lines.push(Vec::new());
503            let mut title = Text::styled(
504                sanitize_single_line(&c.name),
505                style(console, "profile.column", STYLES),
506            );
507            title.append(" · ", None);
508            title.append(
509                &c.data_type,
510                Some(style(console, "profile.type", STYLES).into()),
511            );
512            if c.nulls > 0 {
513                title.append(" · ", None);
514                title.append(
515                    &format!(
516                        "{} {} ({})",
517                        grouped(c.nulls),
518                        plural(c.nulls, "null", "nulls"),
519                        percent(c.null_rate)
520                    ),
521                    Some(style(console, "profile.null", STYLES).into()),
522                );
523            }
524            if matches!(c.distribution, Distribution::Histogram { .. }) {
525                let quartiles = [c.p25, c.median, c.p75].map(|q| q.map(number));
526                if let [Some(p25), Some(median), Some(p75)] = quartiles {
527                    title.append(&format!(" · median {median} (p25 {p25}, p75 {p75})"), None);
528                }
529            } else {
530                title.append(&format!(" · {} distinct", grouped(c.distinct as u64)), None);
531            }
532            text(title, 0, &mut lines);
533            match distribution_chart(&c.distribution) {
534                Some(chart) => block(console, &options, &chart, 2, &mut lines),
535                None => text(Text::styled("no values", note.clone()), 2, &mut lines),
536            }
537            if let Distribution::Top { values, other } = &c.distribution {
538                if *other > 0 {
539                    let more = c.distinct.saturating_sub(values.len()) as u64;
540                    text(
541                        Text::styled(
542                            format!(
543                                "+ {} other {} ({} {})",
544                                grouped(more),
545                                plural(more, "value", "values"),
546                                grouped(*other as u64),
547                                plural(*other as u64, "cell", "cells"),
548                            ),
549                            note.clone(),
550                        ),
551                        2,
552                        &mut lines,
553                    );
554                }
555            }
556        }
557
558        lines.push(Vec::new());
559        match self.missing_heatmap() {
560            None => text(
561                Text::styled("no missing values", note.clone()),
562                0,
563                &mut lines,
564            ),
565            Some(map) => {
566                let rows = self.missing.bucket_rows;
567                let per = if rows <= 1 {
568                    "a row each".to_string()
569                } else {
570                    format!("{} rows each", grouped(rows))
571                };
572                text(
573                    Text::styled(
574                        format!("missing values (% null; rows top to bottom, {per})"),
575                        style(console, "profile.column", STYLES),
576                    ),
577                    0,
578                    &mut lines,
579                );
580                block(console, &options, &map, 0, &mut lines);
581            }
582        }
583        join(lines)
584    }
585
586    /// As wide as its widest line at the width given.
587    fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
588        let widest = Segment::split_lines(&self.rich_render(console, options))
589            .iter()
590            .map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
591            .max()
592            .unwrap_or(0);
593        rich::measure::Measurement::new(widest, widest)
594    }
595}
596
597/// Render `renderable` `indent` cells in, adding its lines to `lines`.
598pub(crate) fn block(
599    console: &Console,
600    options: &ConsoleOptions,
601    renderable: &dyn Renderable,
602    indent: usize,
603    lines: &mut Vec<Vec<Segment>>,
604) {
605    let width = options.max_width.saturating_sub(indent).max(1);
606    let rendered = console.render(renderable, Some(&options.update_width(width)));
607    for line in Segment::split_lines(&rendered) {
608        let mut row = Vec::with_capacity(line.len() + 1);
609        if indent > 0 {
610            row.push(Segment::new(" ".repeat(indent), None));
611        }
612        row.extend(line);
613        lines.push(row);
614    }
615}
616
617/// Lines separated by newlines.
618pub(crate) fn join(lines: Vec<Vec<Segment>>) -> Vec<Segment> {
619    let mut out = Vec::new();
620    for (i, line) in lines.into_iter().enumerate() {
621        if i > 0 {
622            out.push(Segment::line());
623        }
624        out.extend(line);
625    }
626    out
627}
628
629/// Profiles rows one at a time in bounded memory. See the
630/// [module docs](self).
631///
632/// ```
633/// use rich_data::profile::{ProfileOptions, Profiler};
634/// use rich_data::Value;
635///
636/// let options = ProfileOptions { sample: 100, ..Default::default() };
637/// let mut profiler = Profiler::new(&["n".to_string()], options).unwrap();
638/// for n in 0..10_000 {
639///     profiler.push(vec![Value::Int(n)]);
640/// }
641/// let profile = profiler.finish();
642/// assert_eq!((profile.rows(), profile.sample_size()), (10_000, 100));
643/// assert!(profile.sampled());
644/// assert!(profile.heading().starts_with("sampled 100 of 10,000 rows"));
645/// ```
646#[derive(Clone, Debug)]
647pub struct Profiler {
648    options: ProfileOptions,
649    names: Vec<String>,
650    /// The source column of each profiled column.
651    indices: Vec<usize>,
652    rows: u64,
653    reservoir: Vec<Row>,
654    rng: u64,
655    nulls: Vec<u64>,
656    bucket_rows: u64,
657    buckets: Vec<(u64, Vec<u64>)>,
658}
659
660impl Profiler {
661    /// A profiler for rows with `columns`. Fails when
662    /// [`ProfileOptions::columns`] names a column there is not.
663    pub fn new(columns: &[String], options: ProfileOptions) -> Result<Self, DataError> {
664        let indices: Vec<usize> = match &options.columns {
665            None => (0..columns.len()).collect(),
666            Some(wanted) => wanted
667                .iter()
668                .map(|name| {
669                    columns.iter().position(|c| c == name).ok_or_else(|| {
670                        DataError::new(format!(
671                            "no column {:?}; the columns are {}",
672                            sanitize_single_line(name),
673                            columns
674                                .iter()
675                                .map(|c| sanitize_single_line(c))
676                                .collect::<Vec<_>>()
677                                .join(", ")
678                        ))
679                    })
680                })
681                .collect::<Result<_, _>>()?,
682        };
683        Ok(Profiler {
684            names: indices.iter().map(|&i| columns[i].clone()).collect(),
685            nulls: vec![0; indices.len()],
686            indices,
687            rows: 0,
688            reservoir: Vec::new(),
689            rng: 0x9E37_79B9_7F4A_7C15,
690            bucket_rows: 1,
691            buckets: Vec::new(),
692            options,
693        })
694    }
695
696    /// Add a row (missing cells are null; extra cells are ignored).
697    pub fn push(&mut self, row: Row) {
698        let mut cells: Row = self
699            .indices
700            .iter()
701            .map(|&i| row.get(i).cloned().unwrap_or(Value::Null))
702            .collect();
703        // The missing-value map: a new bucket when the last is full, and
704        // neighbours merged when there would be too many.
705        if self
706            .buckets
707            .last()
708            .is_none_or(|(rows, _)| *rows >= self.bucket_rows)
709        {
710            if self.buckets.len() >= self.options.buckets.max(1) {
711                self.merge_buckets();
712            }
713            if self
714                .buckets
715                .last()
716                .is_none_or(|(rows, _)| *rows >= self.bucket_rows)
717            {
718                self.buckets.push((0, vec![0; self.indices.len()]));
719            }
720        }
721        let bucket = self.buckets.last_mut().expect("a bucket was just added");
722        bucket.0 += 1;
723        // Null tokens become `Null` in the sample too, so a text column does
724        // not count `NA` as a value.
725        for (column, cell) in cells.iter_mut().enumerate() {
726            if is_null(&self.options.nulls, cell) {
727                self.nulls[column] += 1;
728                bucket.1[column] += 1;
729                *cell = Value::Null;
730            }
731        }
732        // Algorithm R: keep the first `sample` rows, then replace a kept row
733        // with probability sample / rows seen.
734        let sample = self.options.sample.max(1);
735        self.rows += 1;
736        if self.reservoir.len() < sample {
737            self.reservoir.push(cells);
738        } else {
739            let slot = self.next_random() % self.rows;
740            if let Some(kept) = self.reservoir.get_mut(slot as usize) {
741                *kept = cells;
742            }
743        }
744    }
745
746    /// The profile of every row pushed.
747    pub fn finish(self) -> Profile {
748        let mut sample = Rows::new(self.names.clone());
749        let size = self.reservoir.len();
750        for row in self.reservoir {
751            sample.push(row);
752        }
753        let inference = Inferrer::new()
754            .null_tokens(self.options.nulls.iter().cloned())
755            .infer(&sample);
756        inference.apply(&mut sample);
757        let stats = Stats::with_options(
758            &sample,
759            &StatsOptions {
760                quantiles: vec![0.25, 0.75],
761                top: self.options.top,
762            },
763        );
764        let rows = self.rows;
765        let columns = stats
766            .columns()
767            .iter()
768            .zip(inference.columns())
769            .enumerate()
770            .map(|(index, (s, inferred))| {
771                let distribution = if s.count == 0 {
772                    Distribution::Empty
773                } else if s.numeric {
774                    let values = sample.column(index).filter_map(|v| {
775                        v.as_f64()
776                            .or_else(|| parse_float(v.plain().trim()))
777                            .filter(|n| n.is_finite())
778                    });
779                    histogram(Histogram::new(values).bins(self.options.bins.max(1)))
780                } else {
781                    let shown: usize = s.top.iter().map(|(_, n)| n).sum();
782                    Distribution::Top {
783                        values: s
784                            .top
785                            .iter()
786                            .map(|(value, count)| TopValue {
787                                value: value.clone(),
788                                count: *count,
789                            })
790                            .collect(),
791                        other: s.count - shown,
792                    }
793                };
794                let quantile = |q: f64| s.quantiles.iter().find(|(at, _)| *at == q).map(|v| v.1);
795                ColumnProfile {
796                    name: s.name.clone(),
797                    data_type: inferred.data_type().name().to_string(),
798                    nulls: self.nulls[index],
799                    null_rate: if rows == 0 {
800                        0.0
801                    } else {
802                        self.nulls[index] as f64 / rows as f64
803                    },
804                    count: s.count,
805                    distinct: s.distinct,
806                    min: s.min.clone(),
807                    max: s.max.clone(),
808                    mean: s.mean,
809                    median: s.median,
810                    p25: quantile(0.25),
811                    p75: quantile(0.75),
812                    distribution,
813                }
814            })
815            .collect();
816        let mut first = 1;
817        let buckets = self
818            .buckets
819            .into_iter()
820            .map(|(rows, nulls)| {
821                let bucket = MissingBucket { first, rows, nulls };
822                first += rows;
823                bucket
824            })
825            .collect();
826        Profile {
827            name: None,
828            rows,
829            sample: size,
830            columns,
831            missing: MissingMap {
832                bucket_rows: self.bucket_rows,
833                buckets,
834            },
835            notes: Vec::new(),
836        }
837    }
838
839    /// Merge neighbouring buckets, doubling the rows per bucket. Every
840    /// bucket is full, so each new one covers twice the rows, in order; an
841    /// odd last one carries on filling.
842    fn merge_buckets(&mut self) {
843        let merged = self
844            .buckets
845            .chunks(2)
846            .map(|pair| {
847                let mut rows = 0;
848                let mut nulls = vec![0; self.indices.len()];
849                for (r, n) in pair {
850                    rows += r;
851                    for (total, n) in nulls.iter_mut().zip(n) {
852                        *total += n;
853                    }
854                }
855                (rows, nulls)
856            })
857            .collect();
858        self.buckets = merged;
859        self.bucket_rows *= 2;
860    }
861
862    /// SplitMix64: a fixed sequence, so a profile is reproducible.
863    fn next_random(&mut self) -> u64 {
864        self.rng = self.rng.wrapping_add(0x9E37_79B9_7F4A_7C15);
865        let mut z = self.rng;
866        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
867        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
868        z ^ (z >> 31)
869    }
870}
871
872/// Whether `value` is null: [`Value::Null`] or one of the `tokens`.
873fn is_null(tokens: &[String], value: &Value) -> bool {
874    match value {
875        Value::Null => true,
876        Value::Int(_) | Value::Float(_) => false,
877        Value::Str(text) => tokens.iter().any(|t| t == text.trim()),
878        Value::Text(text) => {
879            let plain = text.plain();
880            tokens.iter().any(|t| t == plain.trim())
881        }
882    }
883}
884
885/// A histogram's bins, labelled as its chart labels them.
886fn histogram(histogram: Histogram) -> Distribution {
887    let edges = histogram.edges();
888    let counts = histogram.counts();
889    let chart = histogram.to_bar_chart();
890    let bins = chart
891        .bars()
892        .iter()
893        .zip(counts)
894        .enumerate()
895        .map(|(i, (bar, count))| Bin {
896            label: bar.label.clone(),
897            low: edges[i],
898            high: edges[i + 1],
899            count,
900        })
901        .collect();
902    Distribution::Histogram { bins }
903}
904
905/// A value as a label: one line, at most [`LABEL_CHARS`] characters.
906fn label(value: &str) -> String {
907    let line = sanitize_single_line(value);
908    if line.chars().count() <= LABEL_CHARS {
909        return line;
910    }
911    let mut cut: String = line.chars().take(LABEL_CHARS - 1).collect();
912    cut.push('…');
913    cut
914}
915
916/// `3 (25%)`, or `0`.
917fn nulls(count: u64, rate: f64) -> String {
918    if count == 0 {
919        "0".to_string()
920    } else {
921        format!("{} ({})", grouped(count), percent(rate))
922    }
923}
924
925/// A rate as a percentage: whole numbers from 1%, one decimal below, and
926/// never `0%` or `100%` unless exactly so.
927fn percent(rate: f64) -> String {
928    let p = rate * 100.0;
929    if (99.5..100.0).contains(&p) {
930        return ">99%".to_string();
931    }
932    if p > 0.0 && p < 0.1 {
933        return "<0.1%".to_string();
934    }
935    if p < 1.0 {
936        format!("{}%", number((p * 10.0).round() / 10.0))
937    } else {
938        format!("{}%", p.round())
939    }
940}
941
942/// `1234567` as `1,234,567`.
943fn grouped(n: u64) -> String {
944    let digits = n.to_string();
945    let mut out = String::new();
946    for (i, c) in digits.chars().enumerate() {
947        if i > 0 && (digits.len() - i).is_multiple_of(3) {
948            out.push(',');
949        }
950        out.push(c);
951    }
952    out
953}
954
955fn plural(n: u64, one: &'static str, many: &'static str) -> &'static str {
956    if n == 1 {
957        one
958    } else {
959        many
960    }
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966
967    fn profile(columns: &[&str], rows: &[&[&str]], options: ProfileOptions) -> Profile {
968        let names: Vec<String> = columns.iter().map(|c| c.to_string()).collect();
969        let mut profiler = Profiler::new(&names, options).unwrap();
970        for row in rows {
971            profiler.push(row.iter().map(|c| Value::from(*c)).collect());
972        }
973        profiler.finish()
974    }
975
976    #[test]
977    fn numbers_group_and_rates_round() {
978        assert_eq!(grouped(0), "0");
979        assert_eq!(grouped(999), "999");
980        assert_eq!(grouped(1_000), "1,000");
981        assert_eq!(grouped(1_234_567), "1,234,567");
982        assert_eq!(percent(0.25), "25%");
983        assert_eq!(percent(0.004), "0.4%");
984        assert_eq!(percent(0.000_01), "<0.1%");
985        assert_eq!(percent(0.999), ">99%");
986        assert_eq!(percent(1.0), "100%");
987    }
988
989    #[test]
990    fn the_map_merges_buckets_in_order() {
991        let options = ProfileOptions {
992            buckets: 4,
993            ..Default::default()
994        };
995        let rows: Vec<Vec<&str>> = (0..10)
996            .map(|i| vec![if i % 3 == 0 { "" } else { "x" }])
997            .collect();
998        let rows: Vec<&[&str]> = rows.iter().map(Vec::as_slice).collect();
999        let p = profile(&["a"], &rows, options);
1000        let map = p.missing();
1001        assert_eq!(map.bucket_rows, 4);
1002        let shape: Vec<(u64, u64, Vec<u64>)> = map
1003            .buckets
1004            .iter()
1005            .map(|b| (b.first, b.rows, b.nulls.clone()))
1006            .collect();
1007        // Nulls at rows 1, 4, 7 and 10.
1008        assert_eq!(shape, [(1, 4, vec![2]), (5, 4, vec![1]), (9, 2, vec![1])]);
1009        assert_eq!(p.columns()[0].nulls, 4);
1010    }
1011
1012    #[test]
1013    fn a_large_input_is_sampled_with_exact_nulls() {
1014        let options = ProfileOptions {
1015            sample: 50,
1016            ..Default::default()
1017        };
1018        let names = vec!["n".to_string(), "s".to_string()];
1019        let mut profiler = Profiler::new(&names, options).unwrap();
1020        for i in 0..5_000i64 {
1021            let s = if i % 10 == 0 {
1022                Value::Null
1023            } else {
1024                Value::from("x")
1025            };
1026            profiler.push(vec![Value::Int(i), s]);
1027        }
1028        let p = profiler.finish();
1029        assert_eq!((p.rows(), p.sample_size(), p.sampled()), (5_000, 50, true));
1030        assert_eq!(p.columns()[1].nulls, 500);
1031        assert_eq!(p.columns()[1].null_rate, 0.1);
1032        // The sample spreads over the input, not just its start.
1033        let max: f64 = p.columns()[0].max.as_deref().unwrap().parse().unwrap();
1034        assert!(max > 2_500.0, "{max}");
1035        assert_eq!(p.to_json()["sampled"], true);
1036        assert_eq!(p.to_json()["sample"], 50);
1037    }
1038
1039    #[test]
1040    fn ragged_and_empty_input_never_panic() {
1041        let names = vec!["a".to_string(), "b".to_string()];
1042        let mut profiler = Profiler::new(&names, ProfileOptions::default()).unwrap();
1043        profiler.push(vec![]);
1044        profiler.push(vec![Value::from("1"), Value::from("2"), Value::from("3")]);
1045        let p = profiler.finish();
1046        assert_eq!(p.columns()[0].nulls, 1);
1047        let console = Console::builder().width(30).color_system(None).build();
1048        console.render_export(&p);
1049        let empty = Profiler::new(&[], ProfileOptions::default())
1050            .unwrap()
1051            .finish();
1052        assert_eq!(console.render_export(&empty), "0 rows, 0 columns\n");
1053        let none = Profiler::new(&names, ProfileOptions::default())
1054            .unwrap()
1055            .finish();
1056        assert!(console.render_export(&none).contains("no values"));
1057    }
1058
1059    #[test]
1060    fn unknown_columns_are_named() {
1061        let options = ProfileOptions {
1062            columns: Some(vec!["b".into(), "z".into()]),
1063            ..Default::default()
1064        };
1065        let error = Profiler::new(&["a".into(), "b".into()], options).unwrap_err();
1066        assert_eq!(error.to_string(), "no column \"z\"; the columns are a, b");
1067    }
1068
1069    #[test]
1070    fn the_model_round_trips_through_json() {
1071        let p = profile(
1072            &["k", "v"],
1073            &[&["a", "1"], &["b", "2.5"], &["a", ""]],
1074            ProfileOptions::default(),
1075        )
1076        .with_name("x.csv");
1077        let json = serde_json::to_string(&p).unwrap();
1078        let back: Profile = serde_json::from_str(&json).unwrap();
1079        assert_eq!(back, p);
1080        assert_eq!(p.columns()[1].data_type, "float");
1081        match &p.columns()[0].distribution {
1082            Distribution::Top { values, other } => {
1083                assert_eq!(values[0].value, "a");
1084                assert_eq!((values[0].count, *other), (2, 0));
1085            }
1086            other => panic!("{other:?}"),
1087        }
1088    }
1089}