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;