Skip to main content

omnist/
infer.rs

1//! Schema inference: draft a `record` [`Schema`] that accepts a set of
2//! sample [`Doc`]uments.
3//!
4//! Ported from `~/dev/omnist/omnist/infer.py` (issue #14). Given one or
5//! more sample Documents, [`infer`] drafts a `record` schema that accepts
6//! them:
7//!
8//! * a label present in every sample with count 1 becomes a required field
9//!   (`[1,1]`); absent in some samples -> `[0,1]`; seen more than once ->
10//!   an array (`[min, None]`, permissive on length);
11//! * scalar children become one [`crate::schema::Scalar`] (nullable if any
12//!   sample was `null`). Samples disagreeing on scalar shape raise, except
13//!   `integer`/`number` mixing, which collapses to `number` (the one
14//!   subset relation between scalars -- see `docs/design/model.md`);
15//! * object children become a nested, named `record` (recursively).
16//!
17//! Since the model has no inline records, nested records are given
18//! generated names derived from their label.
19//!
20//! [`infer`] deliberately does **not** auto-normalize: the raw result keeps
21//! a 1:1 correspondence between sample labels and generated record names,
22//! which may therefore contain structurally-identical duplicate records.
23//! Call [`crate::ops::normalize`] on the result where a canonical minimal
24//! schema is wanted (issue #12, itself resolving issues #143/#151 in the
25//! Python reference).
26//!
27//! ## `allow_any` and `AnyFallback`
28//!
29//! [`infer`] keeps its original two-argument signature and always infers
30//! with `allow_any: false` (matching its long-standing behavior). Two
31//! scenarios can't be resolved to one precise type from the samples alone:
32//!
33//! 1. a label whose samples mix objects and scalars;
34//! 2. a label whose scalar samples disagree on kind in a way that isn't the
35//!    integer/number subset relation (e.g. `string` and `boolean` seen
36//!    under the same label).
37//!
38//! With `allow_any: false` (via [`infer`], or [`infer_with_report`] called
39//! that way), both scenarios are a [`SchemaError`]. [`infer_with_report`]
40//! additionally accepts `allow_any: true`, matching Python's
41//! `infer_with_report`/`AnyFallback`: instead of erroring, the field is
42//! opened as [`crate::schema::FieldType::Any`] and one [`AnyFallback`] is
43//! recorded (`location` is `RecordName.label`; `reason` says why).
44//!
45//! ## No native temporal input
46//!
47//! [`crate::document::Scalar`] has no `date`/`time`/`datetime` variant (see
48//! `document.rs`'s module doc), so unlike the Python reference (which can
49//! receive real `datetime.date` sample values), every string sample here
50//! infers as `string` -- never `date`/`time`/`datetime` -- regardless of
51//! its shape. A schema wanting a temporal field has to be authored (or
52//! edited in after inference), not inferred from string-shaped samples.
53//! This is a deliberate architecture consequence of issue #4's Value model,
54//! not a bug.
55
56use indexmap::{IndexMap, IndexSet};
57
58use crate::document::{Doc, Scalar as DocScalar};
59use crate::error::SchemaError;
60use crate::schema::{Field, FieldType, Record, Ref, Scalar, ScalarKind, Schema};
61
62/// A single field [`infer_with_report`] opened as `any` under
63/// `allow_any: true`. `location` reads `RecordName.label`; `reason` says why
64/// the field could not be given a single precise type. Mirrors Python's
65/// `AnyFallback` dataclass.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct AnyFallback {
68    /// The path location of the field opened as `any`.
69    pub location: String,
70    /// Description of why the field fell back to `any`.
71    pub reason: String,
72}
73
74/// Infers a `record` [`Schema`] (rooted at `root_name`) that accepts every
75/// sample in `samples`. Every sample's root must be an object (a record
76/// shape) -- an empty `samples` list, or any sample whose root is a bare
77/// scalar, is a [`SchemaError`]. Always infers with `allow_any: false` --
78/// see [`infer_with_report`] for the `allow_any: true` variant.
79pub fn infer(samples: &[Doc], root_name: &str) -> Result<Schema, SchemaError> {
80    infer_with_report(samples, root_name, false).map(|(schema, _)| schema)
81}
82
83/// Like [`infer`], but also takes `allow_any` and returns every
84/// [`AnyFallback`] recorded along the way (empty when `allow_any` is
85/// `false`, since every ambiguous field is a hard error in that mode
86/// instead). Mirrors Python's `infer_with_report`.
87pub fn infer_with_report(
88    samples: &[Doc],
89    root_name: &str,
90    allow_any: bool,
91) -> Result<(Schema, Vec<AnyFallback>), SchemaError> {
92    if samples.is_empty() {
93        return Err(SchemaError::new(
94            "$",
95            "algebra.infer-no-samples",
96            "cannot infer a schema from zero samples",
97        ));
98    }
99    for s in samples {
100        if s.root().is_leaf() {
101            return Err(SchemaError::new(
102                "$",
103                "algebra.infer-scalar-root",
104                "infer expects object (record) samples at the root",
105            ));
106        }
107    }
108    let mut env: IndexMap<String, Record> = IndexMap::new();
109    let mut used: IndexSet<String> = IndexSet::new();
110    let mut fallbacks: Vec<AnyFallback> = Vec::new();
111    let roots: Vec<_> = samples.iter().map(Doc::root).collect();
112    infer_record(
113        &roots,
114        root_name,
115        &mut env,
116        &mut used,
117        allow_any,
118        &mut fallbacks,
119    )?;
120    let schema = Schema::new(Ref::new(root_name), env)?;
121    Ok((schema, fallbacks))
122}
123
124// Note: unlike the Python reference's `_infer_record`, there is no explicit
125// `depth > MAX_DEPTH` guard here. Every `node` this function ever sees
126// comes from a `Doc` (via `Doc::root()`/`Cursor::edges()`), and `Doc`
127// construction (`crate::document::build_node`/`check_write_depth`) already
128// rejects anything past `MAX_DEPTH` before a `Doc` can exist at all -- so
129// recursing one level per nested record can never itself exceed a bound
130// the input was already forced under. Mirrors `document.rs`'s decision to
131// drop Python's `_check_int_digits` guard rather than carry forward
132// permanently-dead code: a depth check here would have no reachable
133// failing branch to test (confirmed by trying to construct a
134// deeper-than-`MAX_DEPTH` `Doc` sample in `infer::tests` -- `Doc::of` itself
135// errors first, every time).
136fn infer_record(
137    nodes: &[crate::document::Cursor<'_>],
138    name: &str,
139    env: &mut IndexMap<String, Record>,
140    used: &mut IndexSet<String>,
141    allow_any: bool,
142    fallbacks: &mut Vec<AnyFallback>,
143) -> Result<(), SchemaError> {
144    used.insert(name.to_string());
145
146    // Pass 1: every label that appears at all, in first-seen order across
147    // samples (not just within one sample) -- this keeps the result
148    // independent of sample order, per the Python reference's rationale.
149    let mut order: Vec<String> = Vec::new();
150    let mut seen_labels: IndexSet<String> = IndexSet::new();
151    for node in nodes {
152        for label in node.labels() {
153            if seen_labels.insert(label.clone()) {
154                order.push(label);
155            }
156        }
157    }
158
159    // Pass 2: one count per sample for every label (defaulting to 0), plus
160    // the actual child cursors for type inference.
161    let mut children: IndexMap<String, Vec<crate::document::Cursor<'_>>> =
162        order.iter().map(|l| (l.clone(), Vec::new())).collect();
163    let mut per_sample_counts: IndexMap<String, Vec<usize>> =
164        order.iter().map(|l| (l.clone(), Vec::new())).collect();
165    for node in nodes {
166        let edges = node.edges().expect("root already confirmed non-leaf");
167        let mut counts_here: IndexMap<&str, usize> = IndexMap::new();
168        for (label, child) in &edges {
169            *counts_here.entry(label.as_str()).or_insert(0) += 1;
170            children.get_mut(label).unwrap().push(child.clone());
171        }
172        for label in &order {
173            let c = counts_here.get(label.as_str()).copied().unwrap_or(0);
174            per_sample_counts.get_mut(label).unwrap().push(c);
175        }
176    }
177
178    let mut fields: Vec<Field> = Vec::with_capacity(order.len());
179    for label in &order {
180        let counts = &per_sample_counts[label];
181        let lo = *counts.iter().min().unwrap();
182        let hi = *counts.iter().max().unwrap();
183        let (cmin, cmax) = if hi > 1 { (0, None) } else { (lo, Some(1)) };
184        let ty = infer_type(
185            &children[label],
186            label,
187            name,
188            env,
189            used,
190            allow_any,
191            fallbacks,
192        )?;
193        fields.push(Field::new(label.clone(), ty, cmin, cmax)?);
194    }
195    env.insert(name.to_string(), Record::new(fields)?);
196    Ok(())
197}
198
199fn infer_type(
200    child_nodes: &[crate::document::Cursor<'_>],
201    label: &str,
202    record_name: &str,
203    env: &mut IndexMap<String, Record>,
204    used: &mut IndexSet<String>,
205    allow_any: bool,
206    fallbacks: &mut Vec<AnyFallback>,
207) -> Result<FieldType, SchemaError> {
208    let is_obj: Vec<bool> = child_nodes.iter().map(|c| !c.is_leaf()).collect();
209    if is_obj.iter().all(|&b| b) {
210        let rec_name = unique_name(label, used);
211        infer_record(child_nodes, &rec_name, env, used, allow_any, fallbacks)?;
212        return Ok(FieldType::Ref(Ref::new(rec_name)));
213    }
214    if is_obj.iter().any(|&b| b) {
215        if allow_any {
216            fallbacks.push(AnyFallback {
217                location: format!("{record_name}.{label}"),
218                reason: "mixes objects and values".to_string(),
219            });
220            return Ok(FieldType::Any);
221        }
222        return Err(SchemaError::new(
223            format!("$.{label}"),
224            "algebra.infer-mixed-shape",
225            format!("label {label:?} mixes objects and values; cannot infer one type"),
226        ));
227    }
228    // All scalars.
229    let mut names: IndexSet<&'static str> = IndexSet::new();
230    let mut null = false;
231    for c in child_nodes {
232        let v = c.value().expect("scalar node confirmed by is_obj check");
233        // Inlined rather than routed through a separate "value -> kind
234        // name" helper: a helper covering all five `DocScalar` variants
235        // would need an unreachable `Null` arm (this loop already peels
236        // `Null` off first), which is exactly the kind of permanently-dead
237        // branch the porting playbook flags -- matching directly here
238        // keeps every arm real and independently tested (see
239        // `infer::tests` for one sample of each kind, including a `null`
240        // mixed in). A `Str` sample always infers `"string"`, never
241        // `date`/`time`/`datetime` -- those only infer from a genuinely
242        // Date/Time/Datetime-typed sample (issue #105; before that variant
243        // existed, this was structurally impossible for any sample --
244        // `docs/limitations.md`'s prior "infer never infers temporal
245        // kinds" note is now stale for samples sourced from a format that
246        // can produce one, e.g. OML's/TOML's own native temporal grammar).
247        match v {
248            DocScalar::Null => null = true,
249            DocScalar::Bool(_) => {
250                names.insert("boolean");
251            }
252            DocScalar::Int(_) => {
253                names.insert("integer");
254            }
255            DocScalar::Float(_) => {
256                names.insert("number");
257            }
258            DocScalar::Str(_) => {
259                names.insert("string");
260            }
261            DocScalar::Date(_) => {
262                names.insert("date");
263            }
264            DocScalar::Time(_) => {
265                names.insert("time");
266            }
267            DocScalar::Datetime(_) => {
268                names.insert("datetime");
269            }
270        }
271    }
272    if names.contains("number") {
273        names.shift_remove("integer"); // the one subset relation
274    }
275    if names.is_empty() {
276        // No non-null sample observed -- default to (nullable) string.
277        return Ok(FieldType::Scalar(Scalar::new(ScalarKind::String, null)));
278    }
279    if names.len() > 1 {
280        let mut sorted: Vec<&str> = names.into_iter().collect();
281        sorted.sort_unstable();
282        if allow_any {
283            fallbacks.push(AnyFallback {
284                location: format!("{record_name}.{label}"),
285                reason: format!(
286                    "values of more than one scalar kind ({})",
287                    sorted.join(", ")
288                ),
289            });
290            return Ok(FieldType::Any);
291        }
292        return Err(SchemaError::new(
293            format!("$.{label}"),
294            "algebra.infer-conflicting-scalars",
295            format!(
296                "label {label:?} has values of more than one scalar ({}); cannot infer one scalar type",
297                sorted.join(", ")
298            ),
299        ));
300    }
301    let kind = ScalarKind::parse(names.iter().next().unwrap())
302        .expect("names only ever holds known scalar kind names, inserted above");
303    Ok(FieldType::Scalar(Scalar::new(kind, null)))
304}
305
306/// A fresh, `used`-unique `PascalCase` record name derived from `base`
307/// (typically a field label). Mirrors the Python reference's `_unique`.
308fn unique_name(base: &str, used: &mut IndexSet<String>) -> String {
309    let ident = identifier(base);
310    let name = if ident.is_empty() {
311        "Rec".to_string()
312    } else {
313        ident
314    };
315    // `name` is always non-empty here: either `ident` was non-empty, or the
316    // "Rec" fallback above kicked in -- so there's always a first char to
317    // uppercase. `.expect()` documents that instead of leaving a `None` arm
318    // with no reachable input to test (same "confirmed unreachable, not
319    // assumed" bar as document.rs's `mandatory_u32`).
320    let mut chars = name.chars();
321    let first = chars
322        .next()
323        .expect("name is never empty: identifier()'s fallback or the \"Rec\" default guarantees a first char");
324    let name = first.to_uppercase().collect::<String>() + chars.as_str();
325    let mut cand = name.clone();
326    let mut i = 2;
327    while used.contains(&cand) {
328        cand = format!("{name}{i}");
329        i += 1;
330    }
331    used.insert(cand.clone());
332    cand
333}
334
335/// Substitutes every non-alnum/underscore char with `_`, then strips
336/// leading digits/underscores -- falling back to the substituted-but-
337/// unstripped string if that would leave nothing. Mirrors the Python
338/// reference's `_identifier`.
339fn identifier(s: &str) -> String {
340    let out: String = s
341        .chars()
342        .map(|c| {
343            if c.is_alphanumeric() || c == '_' {
344                c
345            } else {
346                '_'
347            }
348        })
349        .collect();
350    let trimmed = out.trim_start_matches(|c: char| c.is_ascii_digit() || c == '_');
351    if trimmed.is_empty() {
352        out
353    } else {
354        trimmed.to_string()
355    }
356}
357
358#[cfg(test)]
359mod tests;