Skip to main content

omnist/ops/
subschema.rs

1//! Subschema compatibility and equivalence. Ported from
2//! `~/dev/omnist/omnist/ops/subschema.py`.
3//!
4//! Implements the paper's Algorithm 4 (SubschemaSA) restricted to omnist's
5//! counting cardinality languages; [`equivalent`] is bidirectional
6//! inclusion.
7//!
8//! Algorithm 4 assumes its precondition MakeUsefulSA (useless-state removal,
9//! `super::prune`) has already run: the coinductive cycle rule below only
10//! coincides with true (finite-document) language inclusion once every
11//! A-side record is known satisfiable. Rather than requiring callers to
12//! pre-prune, [`compatible_with`] computes `a`'s satisfiable set once up
13//! front and consults it directly -- an unsatisfiable A-side record is
14//! vacuously a subschema of anything (it emits no documents at all), and an
15//! optional A-field whose type is unsatisfiable is skipped (it can never
16//! actually be emitted, so it imposes no obligation on B).
17
18use indexmap::{IndexMap, IndexSet};
19
20use crate::schema::{FieldType, Record, Scalar, ScalarKind, Schema};
21
22use super::prune::satisfiable_set;
23
24/// True if every document `a` accepts is also accepted by `b` (`a` is a
25/// subschema / `b` is backward-compatible).
26pub fn compatible_with(a: &Schema, b: &Schema) -> bool {
27    let sat_a = satisfiable_set(a);
28    let mut memo: IndexMap<(String, String), bool> = IndexMap::new();
29    sub(
30        a,
31        &FieldType::Ref(a.root().clone()),
32        b,
33        &FieldType::Ref(b.root().clone()),
34        &sat_a,
35        &mut memo,
36    )
37}
38
39/// True if both schemas accept exactly the same documents.
40pub fn equivalent(a: &Schema, b: &Schema) -> bool {
41    compatible_with(a, b) && compatible_with(b, a)
42}
43
44/// The memo key is `(a-ref-name, b-ref-name)`, guarding only the ref/ref
45/// case -- the only one that can cycle. Scalar comparisons never recurse,
46/// so they need no memoization to terminate.
47fn sub(
48    sa: &Schema,
49    ta: &FieldType,
50    sb: &Schema,
51    tb: &FieldType,
52    sat_a: &IndexSet<String>,
53    memo: &mut IndexMap<(String, String), bool>,
54) -> bool {
55    match (ta, tb) {
56        (FieldType::Ref(ra), _) if !sat_a.contains(&ra.name) => true,
57        // `any` on the B (super-schema) side absorbs every A-side value --
58        // always sound, regardless of what `ta` is. Checked before the
59        // `ta == Any` arm below, mirroring Python's `if isinstance(db,
60        // AnyType): True` running before its `elif isinstance(da, AnyType)`.
61        (_, FieldType::Any) => true,
62        // Only `any` holds `any`; `ta` is `any` and `tb` isn't -> never a
63        // subschema relation.
64        (FieldType::Any, _) => false,
65        (FieldType::Scalar(a), FieldType::Scalar(b)) => scalar_sub(*a, *b),
66        (FieldType::Ref(ra), FieldType::Ref(rb)) => {
67            let key = (ra.name.clone(), rb.name.clone());
68            if let Some(&v) = memo.get(&key) {
69                return v;
70            }
71            // Coinductive assumption while descending, mirroring the Python
72            // reference: a cycle that never disagrees is compatible.
73            memo.insert(key.clone(), true);
74            let reca = sa
75                .env()
76                .get(&ra.name)
77                .expect("Schema's own invariant: every Ref resolves within its env");
78            let recb = sb
79                .env()
80                .get(&rb.name)
81                .expect("Schema's own invariant: every Ref resolves within its env");
82            let result = record_sub(sa, reca, sb, recb, sat_a, memo);
83            memo.insert(key, result);
84            result
85        }
86        // A value type vs. an object type (or vice versa) is never
87        // compatible.
88        _ => false,
89    }
90}
91
92fn record_sub(
93    sa: &Schema,
94    a: &Record,
95    sb: &Schema,
96    b: &Record,
97    sat_a: &IndexSet<String>,
98    memo: &mut IndexMap<(String, String), bool>,
99) -> bool {
100    // Every label A may emit must be allowed by B, with a cardinality range
101    // B's covers and a type B accepts.
102    for fa in a.fields() {
103        if fa.max == Some(0) {
104            continue; // A never emits this label
105        }
106        if fa.min == 0
107            && let FieldType::Ref(r) = &fa.ty
108            && !sat_a.contains(&r.name)
109        {
110            continue; // A never actually emits this label either
111        }
112        let Some(fb) = b.field(&fa.label) else {
113            return false; // B is closed and has no such field
114        };
115        if !(fb.min <= fa.min && le(fa.max, fb.max)) {
116            return false; // [fa.min,fa.max] not a subset of B's range
117        }
118        if !sub(sa, &fa.ty, sb, &fb.ty, sat_a, memo) {
119            return false;
120        }
121    }
122    // Every label B *requires* must be guaranteed by A.
123    for fb in b.fields() {
124        if fb.min >= 1 {
125            match a.field(&fb.label) {
126                None => return false,
127                Some(fa) if fa.min < fb.min => return false,
128                _ => {}
129            }
130        }
131    }
132    true
133}
134
135/// `x <= y`, treating `None` as `+infinity` (unbounded max).
136fn le(x: Option<usize>, y: Option<usize>) -> bool {
137    match y {
138        None => true,
139        Some(y) => match x {
140            None => false,
141            Some(x) => x <= y,
142        },
143    }
144}
145
146fn scalar_sub(a: Scalar, b: Scalar) -> bool {
147    if a.is_nullable() && !b.is_nullable() {
148        return false;
149    }
150    if a.kind() == b.kind() {
151        return true;
152    }
153    a.kind() == ScalarKind::Integer && b.kind() == ScalarKind::Number
154}