Skip to main content

leviathan/
infer.rs

1//! `leviathan init`: profile a sample of the data and propose a mapping.
2//!
3//! The heuristics are deliberately conservative: a group or filter is only
4//! proposed when the evidence is clear, and the generated file lists every
5//! other field with its statistics so a person (or an agent) can adjust it.
6
7use std::collections::{HashMap, HashSet};
8use std::fmt::Write as _;
9use std::path::Path;
10
11use anyhow::{Result, bail};
12use serde::Serialize;
13use serde_json::Value;
14
15use crate::config::{About, Config, Fields, SourceConfig};
16use crate::source::{self, Item, Source};
17use crate::text::normalize_date;
18
19const DISTINCT_CAP: usize = 5_000;
20
21#[derive(Debug, Clone, Default, Serialize)]
22pub struct FieldStats {
23    pub path: String,
24    /// Records where the field has at least one non-empty value.
25    pub present: usize,
26    pub values: usize,
27    pub strings: usize,
28    pub numbers: usize,
29    pub bools: usize,
30    pub in_array: bool,
31    pub distinct: usize,
32    #[serde(skip_serializing_if = "std::ops::Not::not")]
33    pub distinct_capped: bool,
34    pub avg_chars: f64,
35    pub avg_words: f64,
36    pub max_chars: usize,
37    /// Share of values that parse as dates.
38    pub date_share: f64,
39    #[serde(skip)]
40    seen: HashSet<String>,
41    #[serde(skip)]
42    chars: usize,
43    #[serde(skip)]
44    words: usize,
45    #[serde(skip)]
46    dates: usize,
47    #[serde(skip)]
48    order: usize,
49}
50
51#[derive(Debug, Serialize)]
52pub struct Profile {
53    pub sampled: usize,
54    pub skipped: usize,
55    pub fields: Vec<FieldStats>,
56}
57
58impl Profile {
59    fn get(&self, path: &str) -> Option<&FieldStats> {
60        self.fields.iter().find(|f| f.path == path)
61    }
62}
63
64/// Read up to `limit` records across `sources` and profile every path.
65pub fn profile(sources: &[Source], sql: Option<&str>, limit: usize) -> Result<Profile> {
66    let mut stats: HashMap<String, FieldStats> = HashMap::new();
67    let (mut sampled, mut skipped) = (0usize, 0usize);
68    for s in sources {
69        if sampled >= limit {
70            break;
71        }
72        source::read(s, sql, &mut |item| {
73            match item {
74                Item::Record { value, .. } => {
75                    let mut present = HashSet::new();
76                    walk(&value, String::new(), false, &mut stats, &mut present);
77                    for p in present {
78                        if let Some(f) = stats.get_mut(&p) {
79                            f.present += 1;
80                        }
81                    }
82                    sampled += 1;
83                }
84                Item::Bad { .. } => skipped += 1,
85            }
86            Ok(sampled < limit)
87        })?;
88    }
89    if sampled == 0 {
90        bail!("no readable records in the sources ({skipped} unusable)");
91    }
92    let mut fields: Vec<FieldStats> = stats
93        .into_values()
94        .map(|mut f| {
95            f.distinct = f.seen.len();
96            let n = f.values.max(1) as f64;
97            f.avg_chars = (f.chars as f64 / n * 10.0).round() / 10.0;
98            f.avg_words = (f.words as f64 / n * 10.0).round() / 10.0;
99            f.date_share = (f.dates as f64 / n * 100.0).round() / 100.0;
100            f
101        })
102        .collect();
103    fields.sort_by_key(|f| f.order);
104    Ok(Profile { sampled, skipped, fields })
105}
106
107fn walk(
108    value: &Value,
109    path: String,
110    in_array: bool,
111    stats: &mut HashMap<String, FieldStats>,
112    present: &mut HashSet<String>,
113) {
114    match value {
115        Value::Object(map) => {
116            for (k, v) in map {
117                let child = if path.is_empty() { k.clone() } else { format!("{path}.{k}") };
118                walk(v, child, in_array, stats, present);
119            }
120        }
121        Value::Array(items) => {
122            let child = if path.is_empty() { "[]".to_string() } else { format!("{path}[]") };
123            for item in items {
124                walk(item, child.clone(), true, stats, present);
125            }
126        }
127        Value::Null => {}
128        scalar => {
129            let text = match scalar {
130                Value::String(s) => s.trim().to_string(),
131                other => other.to_string(),
132            };
133            if text.is_empty() {
134                return;
135            }
136            let order = stats.len();
137            let f = stats.entry(path.clone()).or_insert_with(|| FieldStats {
138                path: path.clone(),
139                order,
140                ..Default::default()
141            });
142            f.values += 1;
143            f.in_array |= in_array;
144            match scalar {
145                Value::String(_) => f.strings += 1,
146                Value::Number(_) => f.numbers += 1,
147                _ => f.bools += 1,
148            }
149            let chars = text.chars().count();
150            f.chars += chars;
151            f.max_chars = f.max_chars.max(chars);
152            f.words += text.split_whitespace().count();
153            if (matches!(scalar, Value::String(_)) || text.len() >= 9) && normalize_date(&text).is_some() {
154                f.dates += 1;
155            }
156            if f.seen.len() < DISTINCT_CAP {
157                f.seen.insert(text);
158            } else if !f.seen.contains(&text) {
159                f.distinct_capped = true;
160            }
161            present.insert(path);
162        }
163    }
164}
165
166/// Lowercase words of a field name: `Ticket ID`, `ticket_id` and `ticketId`
167/// all give `["ticket", "id"]`.
168fn name_words(name: &str) -> Vec<String> {
169    let mut out = Vec::new();
170    for part in name.split(|c: char| !c.is_alphanumeric()).filter(|p| !p.is_empty()) {
171        let mut word = String::new();
172        let mut prev_lower = false;
173        for c in part.chars() {
174            if c.is_uppercase() && prev_lower {
175                out.push(std::mem::take(&mut word));
176            }
177            prev_lower = c.is_lowercase() || c.is_numeric();
178            word.extend(c.to_lowercase());
179        }
180        out.push(word);
181    }
182    out
183}
184
185fn split_path(path: &str) -> (&str, &str) {
186    let path = path.trim_end_matches("[]");
187    match path.rsplit_once('.') {
188        Some((parent, leaf)) => (parent, leaf.trim_end_matches("[]")),
189        None => ("", path),
190    }
191}
192
193fn name_score(path: &str, hints: &[&str]) -> usize {
194    let (parent, leaf) = split_path(path);
195    let name = leaf.to_lowercase();
196    let words = name_words(leaf);
197    let parent = name_words(split_path(parent).1);
198    hints
199        .iter()
200        .position(|h| name == *h || words.iter().any(|w| w == h) || (parent.len() == 1 && parent[0] == *h))
201        .map(|i| hints.len() - i)
202        .unwrap_or(0)
203}
204
205/// Words of a group field's own name without a trailing id word.
206fn base_words(leaf: &str) -> Vec<String> {
207    let mut w = name_words(leaf);
208    if w.len() > 1 && w.last().is_some_and(|l| ID_HINTS.contains(&l.as_str())) {
209        w.pop();
210    }
211    w
212}
213
214const ID_HINTS: &[&str] = &["id", "uuid", "guid", "key", "pk", "number", "ref"];
215const DATE_HINTS: &[&str] = &[
216    "date",
217    "timestamp",
218    "time",
219    "created",
220    "opened",
221    "reported",
222    "started",
223    "occurred",
224    "updated",
225    "modified",
226    "closed",
227    "resolved",
228    "completed",
229    "finished",
230    "at",
231];
232const TITLE_HINTS: &[&str] = &[
233    "title",
234    "subject",
235    "summary",
236    "headline",
237    "name",
238    "problem",
239    "issue",
240    "reason",
241    "message",
242    "description",
243];
244const GROUP_HINTS: &[&str] = &[
245    "asset",
246    "machine",
247    "equipment",
248    "device",
249    "host",
250    "hostname",
251    "server",
252    "node",
253    "service",
254    "app",
255    "application",
256    "customer",
257    "account",
258    "client",
259    "tenant",
260    "user",
261    "project",
262    "repo",
263    "repository",
264    "team",
265    "site",
266    "store",
267    "location",
268    "product",
269    "sku",
270    "vehicle",
271    "patient",
272    "component",
273];
274const FILTER_HINTS: &[&str] = &[
275    "status",
276    "state",
277    "type",
278    "kind",
279    "category",
280    "priority",
281    "severity",
282    "level",
283    "tags",
284    "tag",
285    "labels",
286    "label",
287    "stage",
288    "channel",
289    "source",
290    "region",
291    "env",
292    "environment",
293    "crew",
294    "team",
295    "shop",
296];
297
298/// A proposed mapping plus one line of reasoning per chosen field.
299#[derive(Debug, Serialize)]
300pub struct Proposal {
301    pub config: Config,
302    pub reasons: Vec<(String, String)>,
303    pub profile: Profile,
304}
305
306pub fn propose(profile: Profile, sources: &[Source]) -> Proposal {
307    let n = profile.sampled;
308    let share = |f: &FieldStats| f.present as f64 / n as f64;
309    let mut reasons: Vec<(String, String)> = Vec::new();
310    let mut taken: HashSet<String> = HashSet::new();
311
312    let id = profile
313        .fields
314        .iter()
315        .filter(|f| {
316            !f.in_array && f.bools == 0 && share(f) >= 0.99 && f.distinct == f.values && !f.distinct_capped
317        })
318        .filter(|f| f.avg_chars <= 64.0 && f.date_share < 0.5)
319        .max_by_key(|f| (name_score(&f.path, ID_HINTS), usize::MAX - f.path.len()))
320        .filter(|f| name_score(&f.path, ID_HINTS) > 0 || f.path.split('.').count() == 1)
321        .map(|f| {
322            reasons.push(("id".into(), format!("unique in the sample ({} of {n})", f.values)));
323            f.path.clone()
324        });
325    if let Some(p) = &id {
326        taken.insert(p.clone());
327    }
328
329    let dates: Vec<String> = {
330        let mut c: Vec<&FieldStats> = profile
331            .fields
332            .iter()
333            .filter(|f| !f.in_array && f.date_share >= 0.9 && share(f) >= 0.3 && !taken.contains(&f.path))
334            .collect();
335        c.sort_by_key(|f| std::cmp::Reverse((share(f) >= 0.95, name_score(&f.path, DATE_HINTS), f.present)));
336        c.iter().take(2).map(|f| f.path.clone()).collect()
337    };
338    if let Some(first) = dates.first() {
339        let f = profile.get(first).expect("profiled");
340        reasons.push(("date".into(), format!("{:.0}% of values parse as dates", f.date_share * 100.0)));
341    }
342    taken.extend(dates.iter().cloned());
343
344    let group = profile
345        .fields
346        .iter()
347        .filter(|f| !f.in_array && f.bools == 0 && share(f) >= 0.5 && !taken.contains(&f.path))
348        .filter(|f| f.distinct >= 2 && !f.distinct_capped && (f.distinct as f64) <= (n as f64 / 4.0).max(2.0))
349        .filter(|f| f.avg_chars <= 64.0 && f.date_share < 0.5)
350        .filter(|f| name_score(&f.path, GROUP_HINTS) > 0 || group_name_for(&profile, &f.path).is_some())
351        .max_by_key(|f| {
352            (name_score(&f.path, GROUP_HINTS), group_name_for(&profile, &f.path).is_some(), f.present)
353        })
354        .map(|f| f.path.clone());
355    let group_name = group.as_deref().and_then(|g| group_name_for(&profile, g));
356    if let Some(g) = &group {
357        let f = profile.get(g).expect("profiled");
358        reasons.push(("group".into(), format!("{} distinct values in {n} records", f.distinct)));
359        taken.insert(g.clone());
360    }
361    if let Some(gn) = &group_name {
362        taken.insert(gn.clone());
363    }
364
365    let title = profile
366        .fields
367        .iter()
368        .filter(|f| !f.in_array && f.strings > 0 && share(f) >= 0.5 && !taken.contains(&f.path))
369        .filter(|f| (8.0..=240.0).contains(&f.avg_chars) && f.avg_words >= 2.0 && f.distinct * 3 >= f.values)
370        .max_by_key(|f| {
371            (name_score(&f.path, TITLE_HINTS), f.present, (1000.0 - (f.avg_chars - 60.0).abs()) as i64)
372        })
373        .map(|f| f.path.clone());
374    if let Some(t) = &title {
375        let f = profile.get(t).expect("profiled");
376        reasons.push(("title".into(), format!("short text, avg {:.0} chars", f.avg_chars)));
377        taken.insert(t.clone());
378    }
379
380    let mut filters: Vec<&FieldStats> = profile
381        .fields
382        .iter()
383        .filter(|f| !taken.contains(&f.path) && share(f) >= 0.2)
384        .filter(|f| f.distinct >= 2 && f.distinct <= 50 && f.distinct < f.values && !f.distinct_capped)
385        .filter(|f| f.avg_chars <= 40.0)
386        .filter(|f| f.date_share < 0.5 && (f.strings > 0 || f.bools > 0))
387        .collect();
388    filters.sort_by_key(|f| std::cmp::Reverse((name_score(&f.path, FILTER_HINTS), f.present)));
389    let filters: Vec<String> = filters.iter().take(8).map(|f| f.path.clone()).collect();
390    if !filters.is_empty() {
391        reasons.push(("filters".into(), "few distinct values (2 to 50)".into()));
392    }
393    taken.extend(filters.iter().cloned());
394
395    let text: Vec<String> = profile
396        .fields
397        .iter()
398        .filter(|f| !taken.contains(&f.path) && f.strings > 0 && share(f) >= 0.02)
399        .filter(|f| f.avg_chars >= 20.0 || f.avg_words >= 3.0)
400        .map(|f| f.path.clone())
401        .collect();
402    if !text.is_empty() {
403        reasons.push(("text".into(), "free text: avg 20+ chars or 3+ words".into()));
404    }
405
406    let record = sources
407        .first()
408        .and_then(|s| Path::new(&s.label()).file_stem().map(|n| n.to_string_lossy().into_owned()))
409        .map(|stem| noun_from_stem(&stem))
410        .unwrap_or_else(|| "record".into());
411    let group_noun = group.as_deref().map(noun_from_path).unwrap_or_else(|| "group".into());
412
413    let config = Config {
414        about: About { name: None, description: None, record, group: group_noun },
415        source: SourceConfig {
416            paths: sources.iter().map(|s| s.path.display().to_string()).collect(),
417            ..Default::default()
418        },
419        fields: Fields {
420            id,
421            title: title.into_iter().collect(),
422            text,
423            group,
424            group_name,
425            date: dates,
426            display: filters.iter().take(3).cloned().collect(),
427            filters,
428            empty_values: Vec::new(),
429        },
430        rank: Default::default(),
431    };
432    Proposal { config, reasons, profile }
433}
434
435/// A sibling that names the group: `customer.id` -> `customer.name`,
436/// `customer_id` -> `customer_name`, `Customer` -> `Customer Name`.
437fn group_name_for(profile: &Profile, group: &str) -> Option<String> {
438    const NAME_WORDS: &[&str] = &["name", "title", "label", "display"];
439    let (parent, leaf) = split_path(group);
440    let base = base_words(leaf);
441    let id_only = base.len() == 1 && ID_HINTS.contains(&base[0].as_str());
442    profile
443        .fields
444        .iter()
445        .filter(|f| f.path != group && !f.in_array && f.strings > 0 && split_path(&f.path).0 == parent)
446        .filter_map(|f| {
447            let w = name_words(split_path(&f.path).1);
448            let rest = if id_only && !parent.is_empty() {
449                w.as_slice()
450            } else if w.len() > base.len() && w.starts_with(&base) {
451                &w[base.len()..]
452            } else {
453                return None;
454            };
455            let rank = NAME_WORDS.iter().position(|n| rest.first().is_some_and(|r| r == n))?;
456            (rest.len() <= 2).then_some((rank, f.path.clone()))
457        })
458        .min()
459        .map(|(_, p)| p)
460}
461
462fn noun_from_stem(stem: &str) -> String {
463    let words = name_words(stem).join(" ");
464    if words.chars().count() < 3
465        || ["data", "export", "dump", "records", "rows", "items", "stdin", "db", "database"]
466            .contains(&words.as_str())
467    {
468        return "record".into();
469    }
470    match words.strip_suffix("ies") {
471        Some(s) => format!("{s}y"),
472        None => words.strip_suffix('s').unwrap_or(&words).to_string(),
473    }
474}
475
476fn noun_from_path(path: &str) -> String {
477    let (parent, leaf) = split_path(path);
478    let mut words = base_words(leaf);
479    if words.len() == 1 && ID_HINTS.contains(&words[0].as_str()) && !parent.is_empty() {
480        words = base_words(split_path(parent).1);
481    }
482    if words.is_empty() { "group".into() } else { words.join(" ") }
483}
484
485fn q(s: &str) -> String {
486    toml::Value::String(s.to_string()).to_string()
487}
488
489fn list(v: &[String]) -> String {
490    format!("[{}]", v.iter().map(|s| q(s)).collect::<Vec<_>>().join(", "))
491}
492
493/// The proposal as a commented `leviathan.toml`.
494pub fn to_toml(p: &Proposal) -> String {
495    let c = &p.config;
496    let f = &c.fields;
497    let why = |key: &str| {
498        p.reasons.iter().find(|(k, _)| k == key).map(|(_, r)| format!("  # {r}")).unwrap_or_default()
499    };
500    let opt = |key: &str, v: &Option<String>| match v {
501        Some(v) => format!("{key} = {}{}\n", q(v), why(key)),
502        None => format!("# {key} = \"\"\n"),
503    };
504    let mut out = String::new();
505    let _ = writeln!(
506        out,
507        "# leviathan.toml, proposed by `leviathan init` from {} sampled records.\n\
508         # Review it, then run `leviathan index`. Every field is a path into a record:\n\
509         # `a.b` for nesting, `items[].name` for every element of an array.\n",
510        p.profile.sampled
511    );
512    let _ = writeln!(out, "[about]");
513    let _ = writeln!(out, "# name = \"what this dataset is\"");
514    let _ = writeln!(out, "record = {}   # noun for one record, used in output", q(&c.about.record));
515    let _ = writeln!(out, "group = {}    # noun for one group\n", q(&c.about.group));
516    let _ = writeln!(out, "[source]");
517    let _ = writeln!(out, "paths = {}", list(&c.source.paths));
518    let _ = writeln!(out, "# format = \"auto\"   # auto | jsonl | json | csv | tsv | sqlite");
519    let _ = writeln!(out, "# sql = \"SELECT * FROM table\"   # SQLite sources\n");
520    let _ = writeln!(out, "[fields]");
521    out.push_str(&opt("id", &f.id));
522    if f.id.is_none() {
523        let _ = writeln!(
524            out,
525            "# (no unique field found: records are numbered <file>:<line>, and upsert is disabled)"
526        );
527    }
528    out.push_str(&match f.title.first() {
529        Some(t) => format!("title = {}{}\n", q(t), why("title")),
530        None => "# title = \"\"\n".into(),
531    });
532    let _ = writeln!(
533        out,
534        "text = {}{}",
535        list(&f.text),
536        if f.text.is_empty() { "  # empty = every string".to_string() } else { why("text") }
537    );
538    out.push_str(&opt("group", &f.group));
539    out.push_str(&opt("group_name", &f.group_name));
540    let _ = writeln!(out, "date = {}{}", list(&f.date), why("date"));
541    let _ = writeln!(out, "filters = {}{}", list(&f.filters), why("filters"));
542    let _ = writeln!(out, "display = {}   # shown on every result card", list(&f.display));
543    let _ = writeln!(out, "empty_values = []   # placeholders to ignore, e.g. [\"n/a\", \"-\", \"done\"]\n");
544    let _ = writeln!(out, "[rank]");
545    let _ = writeln!(out, "title_weight = 2.0");
546    let _ = writeln!(out, "# Favor records that are filled in or in a given state:");
547    let _ = writeln!(out, "# [[rank.boost]]\n# field = \"resolution\"\n# weight = 0.15");
548    let _ = writeln!(out, "# [[rank.boost]]\n# field = \"status\"\n# equals = \"closed\"\n# weight = 0.03\n");
549    let _ = writeln!(out, "# Fields seen in the sample (path: type, present, distinct, avg chars):");
550    for s in p.profile.fields.iter().take(60) {
551        let kind = match (s.strings > 0, s.numbers > 0, s.bools > 0) {
552            (true, _, _) if s.date_share >= 0.9 => "date",
553            (true, _, _) => "text",
554            (_, true, _) => "number",
555            _ => "bool",
556        };
557        let distinct = if s.distinct_capped { format!("{}+", s.distinct) } else { s.distinct.to_string() };
558        let _ = writeln!(
559            out,
560            "#   {}: {kind}, {:.0}%, {distinct} distinct, {:.0}",
561            s.path,
562            s.present as f64 / p.profile.sampled as f64 * 100.0,
563            s.avg_chars
564        );
565    }
566    if p.profile.fields.len() > 60 {
567        let _ = writeln!(out, "#   ... {} more", p.profile.fields.len() - 60);
568    }
569    out
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use std::io::Write;
576
577    #[test]
578    fn proposes_a_sensible_mapping_for_tickets() {
579        let dir = tempfile::tempdir().unwrap();
580        let path = dir.path().join("tickets.jsonl");
581        let mut f = std::fs::File::create(&path).unwrap();
582        let statuses = ["open", "closed", "pending"];
583        for i in 0..300 {
584            let customer = i % 12;
585            writeln!(
586                f,
587                "{}",
588                serde_json::json!({
589                    "ticket_id": format!("T-{i}"),
590                    "subject": format!("Cannot log in to portal number {i}"),
591                    "body": format!("Customer reports that after the update the login page loops back, attempt {i}."),
592                    "customer": {"id": format!("C{customer}"), "name": format!("Customer {customer}")},
593                    "status": statuses[i % 3],
594                    "created_at": format!("2024-03-{:02}T10:00:00Z", i % 28 + 1),
595                    "tags": ["web", if i % 2 == 0 { "sso" } else { "billing" }],
596                })
597            )
598            .unwrap();
599        }
600        drop(f);
601        let sources = source::discover(&[path], crate::config::Format::Auto).unwrap();
602        let p = propose(profile(&sources, None, 1000).unwrap(), &sources);
603        let fields = &p.config.fields;
604        assert_eq!(fields.id.as_deref(), Some("ticket_id"));
605        assert_eq!(fields.title, ["subject"]);
606        assert_eq!(fields.group.as_deref(), Some("customer.id"));
607        assert_eq!(fields.group_name.as_deref(), Some("customer.name"));
608        assert_eq!(fields.date, ["created_at"]);
609        assert!(fields.filters.contains(&"status".to_string()));
610        assert!(fields.filters.contains(&"tags[]".to_string()));
611        assert_eq!(fields.text, ["body"]);
612        assert_eq!(p.config.about.record, "ticket");
613        assert_eq!(p.config.about.group, "customer");
614        let toml_text = to_toml(&p);
615        let parsed: Config = toml::from_str(&toml_text).unwrap();
616        assert_eq!(parsed.fields, *fields);
617    }
618
619    #[test]
620    fn spreadsheet_headers_are_matched_by_words() {
621        assert_eq!(name_words("Ticket ID"), ["ticket", "id"]);
622        assert_eq!(name_words("customerName"), ["customer", "name"]);
623        assert_eq!(noun_from_path("Customer ID"), "customer");
624        assert_eq!(noun_from_path("asset.id"), "asset");
625
626        let dir = tempfile::tempdir().unwrap();
627        let path = dir.path().join("orders.csv");
628        let mut f = std::fs::File::create(&path).unwrap();
629        writeln!(f, "Order ID,Customer ID,Customer Name,Status,Order Date,Notes").unwrap();
630        for i in 0..200 {
631            let c = i % 9;
632            writeln!(
633                f,
634                "O{i},C{c},Customer {c},{},3/{}/2024,Package arrived late and the box was damaged {i}",
635                ["new", "shipped"][i % 2],
636                i % 28 + 1
637            )
638            .unwrap();
639        }
640        drop(f);
641        let sources = source::discover(&[path], crate::config::Format::Auto).unwrap();
642        let p = propose(profile(&sources, None, 1000).unwrap(), &sources);
643        let fields = &p.config.fields;
644        assert_eq!(fields.id.as_deref(), Some("Order ID"));
645        assert_eq!(fields.group.as_deref(), Some("Customer ID"));
646        assert_eq!(fields.group_name.as_deref(), Some("Customer Name"));
647        assert_eq!(fields.date, ["Order Date"]);
648        assert_eq!(fields.filters, ["Status"]);
649        assert_eq!((p.config.about.record.as_str(), p.config.about.group.as_str()), ("order", "customer"));
650    }
651}