Skip to main content

shifty_opt/
normalize.rs

1//! Semantics-preserving normalization of a [`Schema`] (Layer 4).
2//!
3//! Rebuilds the shape arena from the schema roots, hash-consing
4//! structurally-identical nodes (CSE) and applying the sound Boolean/count
5//! simplifications tracked in `docs/04-normalization.md`. Because the rebuild
6//! interns only what the roots reach, the result is also compacted (no orphan
7//! slots). Recursive SCCs (found via [`crate::strata`]) are rebuilt preserving
8//! sharing but *not* collapsed, so cycles survive.
9//!
10//! All rewrites are per-node truth-functional, hence sound under the gfp
11//! validation semantics; the W3C harness cross-checks `validate(normalize(S))
12//! ≡ validate(S)` on every core test.
13
14use crate::strata::analyze;
15use shifty_algebra::{
16    NodeExpr, NodeKindSet, Path, Rule, RuleHead, Schema, Selector, Shape, ShapeArena, ShapeId,
17    Statement, ValueType,
18};
19use std::collections::{HashMap, HashSet};
20
21/// Normalize a schema: CSE + compaction + Boolean/count simplification.
22pub fn normalize(schema: &Schema) -> Schema {
23    let mut z = Interner::new(&schema.arena);
24    // dedup identical (selector, shape) pairs after normalization
25    let mut seen: HashSet<(Selector, ShapeId)> = HashSet::new();
26    let statements = schema
27        .statements
28        .iter()
29        .map(|st| Statement {
30            selector: z.selector(&st.selector),
31            shape: z.intern(st.shape),
32        })
33        .filter(|st| seen.insert((st.selector.clone(), st.shape)))
34        .collect();
35    let rules = schema.rules.iter().map(|r| z.rule(r)).collect();
36    // remap shape names through the CSE memo (CSE may collapse two named shapes)
37    let names = schema
38        .names
39        .iter()
40        .filter_map(|(old, name)| z.memo.get(old).map(|new| (*new, name.clone())))
41        .collect();
42    let normalized = Schema {
43        arena: z.dst,
44        statements,
45        rules,
46        names,
47    };
48    normalized.arena.debug_assert_finalized();
49    normalized
50}
51
52/// Push `Inverse` inward one level, returning the canonical inverse of `path`
53/// (only `Inverse(Pred(...))` leaves remain after full recursion).
54fn push_inverse(path: Path) -> Path {
55    match path {
56        Path::Id => Path::Id,
57        Path::Inverse(inner) => normalize_path(*inner), // (π⁻)⁻ = normalize(π)
58        Path::Seq(steps) => {
59            // (π₁·…·πₙ)⁻ = πₙ⁻·…·π₁⁻
60            Path::seq(steps.into_iter().rev().map(push_inverse).collect())
61        }
62        Path::Alt(alts) => Path::alt(alts.into_iter().map(push_inverse).collect()),
63        Path::Star(inner) => Path::star(push_inverse(*inner)), // (π*)⁻ = (π⁻)*
64        pred => Path::Inverse(Box::new(pred)),                 // Pred: stays wrapped
65    }
66}
67
68/// Recursively normalize a path so `Inverse` only wraps `Pred` leaves,
69/// `Alt` members are deduped, and star laws are applied.
70fn normalize_path(path: Path) -> Path {
71    match path {
72        Path::Inverse(inner) => push_inverse(*inner),
73        Path::Seq(steps) => {
74            let steps: Vec<Path> = steps.into_iter().map(normalize_path).collect();
75            // π*·π* = π* — merge adjacent equal stars
76            let mut merged: Vec<Path> = Vec::with_capacity(steps.len());
77            for step in steps {
78                match merged.last() {
79                    Some(last) if matches!(last, Path::Star(_)) && last == &step => {}
80                    _ => merged.push(step),
81                }
82            }
83            Path::seq(merged)
84        }
85        Path::Alt(alts) => {
86            // dedup while preserving first-occurrence order
87            let mut seen = HashSet::new();
88            let deduped: Vec<Path> = alts
89                .into_iter()
90                .map(normalize_path)
91                .filter(|p| seen.insert(p.clone()))
92                .collect();
93            Path::alt(deduped)
94        }
95        Path::Star(inner) => {
96            let inner = normalize_path(*inner);
97            // (π∪id)* = π* — Id is implicit in the reflexive closure
98            let inner = match inner {
99                Path::Alt(alts) => {
100                    let without_id: Vec<Path> =
101                        alts.into_iter().filter(|p| *p != Path::Id).collect();
102                    Path::alt(without_id)
103                }
104                other => other,
105            };
106            inner.star()
107        }
108        other => other,
109    }
110}
111
112/// The tighter lower bound (larger min), treating `None` as no bound.
113fn tighter_lower(a: Option<u64>, b: Option<u64>) -> Option<u64> {
114    match (a, b) {
115        (Some(x), Some(y)) => Some(x.max(y)),
116        (s, None) | (None, s) => s,
117    }
118}
119
120/// The tighter upper bound (smaller max), treating `None` as no bound.
121fn tighter_upper(a: Option<u64>, b: Option<u64>) -> Option<u64> {
122    match (a, b) {
123        (Some(x), Some(y)) => Some(x.min(y)),
124        (s, None) | (None, s) => s,
125    }
126}
127
128struct Interner<'a> {
129    src: &'a ShapeArena,
130    dst: ShapeArena,
131    /// src id → dst id
132    memo: HashMap<ShapeId, ShapeId>,
133    /// canonical dst node → its id (hash-consing)
134    cons: HashMap<Shape, ShapeId>,
135    /// src ids inside a recursive SCC (rebuilt, not CSE'd/collapsed)
136    cyclic: HashSet<ShapeId>,
137    /// dst ids that are recursive; NNF must not push negation into these
138    cyclic_dst: HashSet<ShapeId>,
139}
140
141impl<'a> Interner<'a> {
142    fn new(src: &'a ShapeArena) -> Self {
143        let strat = analyze(src);
144        let cyclic = strat
145            .strata
146            .iter()
147            .filter(|s| s.recursive)
148            .flat_map(|s| s.shapes.iter().copied())
149            .collect();
150        Self {
151            src,
152            dst: ShapeArena::new(),
153            memo: HashMap::new(),
154            cons: HashMap::new(),
155            cyclic,
156            cyclic_dst: HashSet::new(),
157        }
158    }
159
160    fn cons(&mut self, shape: Shape) -> ShapeId {
161        if let Some(&d) = self.cons.get(&shape) {
162            return d;
163        }
164        let d = self.dst.insert(shape.clone());
165        self.cons.insert(shape, d);
166        d
167    }
168
169    fn top(&mut self) -> ShapeId {
170        self.cons(Shape::Top)
171    }
172
173    fn bottom(&mut self) -> ShapeId {
174        let t = self.top();
175        self.cons(Shape::Not(t))
176    }
177
178    fn is_top(&self, id: ShapeId) -> bool {
179        matches!(self.dst.get(id), Shape::Top)
180    }
181
182    fn is_bottom(&self, id: ShapeId) -> bool {
183        matches!(self.dst.get(id), Shape::Not(x) if matches!(self.dst.get(*x), Shape::Top))
184    }
185
186    fn intern(&mut self, id: ShapeId) -> ShapeId {
187        if let Some(&d) = self.memo.get(&id) {
188            return d;
189        }
190        if self.cyclic.contains(&id) {
191            let d = self.dst.reserve();
192            self.memo.insert(id, d);
193            self.cyclic_dst.insert(d);
194            let shape = self.rebuild_cyclic(id);
195            self.dst.set(d, shape);
196            d
197        } else {
198            let r = self.simplify(id);
199            self.memo.insert(id, r);
200            r
201        }
202    }
203
204    /// Full simplification for an acyclic node, returning a (possibly existing)
205    /// canonical id.
206    fn simplify(&mut self, id: ShapeId) -> ShapeId {
207        match self.src.get(id).clone() {
208            Shape::Annotated { severity, shape } => {
209                let shape = self.intern(shape);
210                self.cons(Shape::Annotated { severity, shape })
211            }
212            Shape::Top => self.top(),
213            Shape::Not(c) => {
214                let cn = self.intern(c);
215                self.mk_not(cn)
216            }
217            Shape::And(cs) => {
218                let ids = cs.iter().map(|c| self.intern(*c)).collect();
219                self.mk_and(ids)
220            }
221            Shape::Or(cs) => {
222                let ids = cs.iter().map(|c| self.intern(*c)).collect();
223                self.mk_or(ids)
224            }
225            Shape::Count {
226                path,
227                min,
228                max,
229                qualifier,
230            } => {
231                let q = self.intern(qualifier);
232                self.mk_count(normalize_path(path), min, max, q)
233            }
234            // value-type facet tightening + same-family unsat (§4)
235            Shape::TestType(vt) => match vt.normalize() {
236                None => self.bottom(),              // facet unsat ⇒ ⊥
237                Some(ValueType::Any) => self.top(), // any ⇒ ⊤
238                Some(v) => self.cons(Shape::TestType(v)),
239            },
240            // path-bearing leaves: normalize their paths
241            Shape::Eq(path, nn) => self.cons(Shape::Eq(normalize_path(path), nn)),
242            Shape::Disj(path, nn) => self.cons(Shape::Disj(normalize_path(path), nn)),
243            Shape::Lt(path, nn) => self.cons(Shape::Lt(normalize_path(path), nn)),
244            Shape::Le(path, nn) => self.cons(Shape::Le(normalize_path(path), nn)),
245            Shape::UniqueLang(path) => self.cons(Shape::UniqueLang(normalize_path(path))),
246            // re-intern the node expression's `Filter` shape references into dst
247            Shape::Expression(e) => {
248                let e = self.node_expr(&e);
249                self.cons(Shape::Expression(e))
250            }
251            // remaining leaves (and the transient Pending) are interned verbatim
252            leaf => self.cons(leaf),
253        }
254    }
255
256    /// Light rebuild for a node inside a recursive SCC: intern children and keep
257    /// the variant (dedup `And`/`Or` members) but never collapse.
258    fn rebuild_cyclic(&mut self, id: ShapeId) -> Shape {
259        match self.src.get(id).clone() {
260            Shape::Annotated { severity, shape } => Shape::Annotated {
261                severity,
262                shape: self.intern(shape),
263            },
264            Shape::Not(c) => Shape::Not(self.intern(c)),
265            Shape::And(cs) => Shape::And(self.intern_set(&cs)),
266            Shape::Or(cs) => Shape::Or(self.intern_set(&cs)),
267            Shape::Count {
268                path,
269                min,
270                max,
271                qualifier,
272            } => Shape::Count {
273                path: normalize_path(path),
274                min,
275                max,
276                qualifier: self.intern(qualifier),
277            },
278            Shape::Expression(e) => Shape::Expression(self.node_expr(&e)),
279            leaf => leaf,
280        }
281    }
282
283    fn intern_set(&mut self, cs: &[ShapeId]) -> Vec<ShapeId> {
284        let mut v: Vec<ShapeId> = cs.iter().map(|c| self.intern(*c)).collect();
285        v.sort();
286        v.dedup();
287        v
288    }
289
290    /// `¬c`, pushed inward to negation normal form. `¬` only ever ends up on a
291    /// leaf atom or on a recursive node (which we don't unfold).
292    fn mk_not(&mut self, c: ShapeId) -> ShapeId {
293        if let Shape::Not(x) = self.dst.get(c) {
294            return *x; // ¬¬φ = φ (always safe, just an id lookup)
295        }
296        if self.cyclic_dst.contains(&c) {
297            return self.cons(Shape::Not(c)); // don't push negation into a cycle
298        }
299        match self.dst.get(c).clone() {
300            // De Morgan
301            Shape::And(cs) => {
302                let neg = cs.iter().map(|c| self.mk_not(*c)).collect();
303                self.mk_or(neg)
304            }
305            Shape::Or(cs) => {
306                let neg = cs.iter().map(|c| self.mk_not(*c)).collect();
307                self.mk_and(neg)
308            }
309            // ¬(∃[min..max] π.q) = ∃≤(min-1) π.q ∨ ∃≥(max+1) π.q (qualifier stays positive)
310            Shape::Count {
311                path,
312                min,
313                max,
314                qualifier,
315            } => {
316                let mut alts = Vec::new();
317                if let Some(a) = min
318                    && a > 0
319                {
320                    alts.push(self.mk_count(path.clone(), None, Some(a - 1), qualifier));
321                }
322                if let Some(b) = max {
323                    alts.push(self.mk_count(path, Some(b + 1), None, qualifier));
324                }
325                self.mk_or(alts)
326            }
327            // ¬TestKind(K) = TestKind(K̄) — complement the node-kind bitset
328            Shape::TestKind(k) => {
329                let comp: NodeKindSet = k.complement();
330                if comp.is_empty() {
331                    self.bottom() // K covered all kinds ⇒ complement is ⊥
332                } else {
333                    self.cons(Shape::TestKind(comp))
334                }
335            }
336            // leaf atom (and ⊤, which becomes ⊥ = ¬⊤)
337            _ => self.cons(Shape::Not(c)),
338        }
339    }
340
341    fn mk_and(&mut self, ids: Vec<ShapeId>) -> ShapeId {
342        // flatten nested And
343        let mut flat = Vec::new();
344        for id in ids {
345            match self.dst.get(id) {
346                Shape::And(inner) => flat.extend(inner.iter().copied()),
347                _ => flat.push(id),
348            }
349        }
350        // merge counts on the same (path, qualifier) into one tightened bound
351        let flat = self.merge_counts(flat);
352        // fuse sibling value-type facets into one tightened test(τ)
353        let flat = self.merge_value_types(flat);
354        // intersect sibling node-kind sets; unsat intersection → ⊥
355        let flat = self.merge_node_kinds(flat);
356        // absorption (merging may have produced ⊤/⊥) + dedup + complement
357        let mut acc = Vec::new();
358        for id in flat {
359            if self.is_bottom(id) {
360                return id; // φ ∧ ⊥ = ⊥
361            }
362            if self.is_top(id) {
363                continue; // φ ∧ ⊤ = φ
364            }
365            acc.push(id);
366        }
367        acc.sort();
368        acc.dedup();
369        if self.has_complement(&acc) {
370            return self.bottom(); // φ ∧ ¬φ = ⊥
371        }
372        match acc.len() {
373            0 => self.top(),
374            1 => acc[0],
375            _ => self.cons(Shape::And(acc)),
376        }
377    }
378
379    /// Intersect sibling `TestKind` sets in a conjunction.  An empty intersection
380    /// means no term can satisfy the shape ⇒ ⊥.  A full intersection (all three
381    /// kinds) imposes no constraint ⇒ drops from ∧ (same as ⊤).
382    fn merge_node_kinds(&mut self, flat: Vec<ShapeId>) -> Vec<ShapeId> {
383        let mut acc: Option<NodeKindSet> = None;
384        let mut others = Vec::new();
385        for id in flat {
386            match self.dst.get(id) {
387                Shape::TestKind(k) => {
388                    acc = Some(match acc {
389                        None => *k,
390                        Some(prev) => NodeKindSet {
391                            iri: prev.iri && k.iri,
392                            blank: prev.blank && k.blank,
393                            literal: prev.literal && k.literal,
394                        },
395                    });
396                }
397                _ => others.push(id),
398            }
399        }
400        if let Some(k) = acc {
401            if k.is_empty() {
402                others.push(self.bottom()); // empty intersection ⇒ ⊥
403            } else if k.iri && k.blank && k.literal {
404                // all kinds allowed ⇒ no constraint; drops from ∧ like ⊤
405            } else {
406                let id = self.cons(Shape::TestKind(k));
407                others.push(id);
408            }
409        }
410        others
411    }
412
413    /// Fuse conjoined counts over the same `(path, qualifier)`: the lower bounds
414    /// take their max, the upper bounds their min (`∃≥a ∧ ∃≥b = ∃≥max`,
415    /// `∃≤a ∧ ∃≤b = ∃≤min`, and a separate min/max count become one node).
416    fn merge_counts(&mut self, flat: Vec<ShapeId>) -> Vec<ShapeId> {
417        let mut keys: Vec<(Path, ShapeId)> = Vec::new();
418        let mut bounds: Vec<(Option<u64>, Option<u64>)> = Vec::new();
419        let mut index: HashMap<(Path, ShapeId), usize> = HashMap::new();
420        let mut others = Vec::new();
421
422        for id in flat {
423            if let Shape::Count {
424                path,
425                min,
426                max,
427                qualifier,
428            } = self.dst.get(id).clone()
429            {
430                let key = (path, qualifier);
431                match index.get(&key) {
432                    Some(&i) => {
433                        bounds[i].0 = tighter_lower(bounds[i].0, min);
434                        bounds[i].1 = tighter_upper(bounds[i].1, max);
435                    }
436                    None => {
437                        index.insert(key.clone(), keys.len());
438                        keys.push(key);
439                        bounds.push((min, max));
440                    }
441                }
442            } else {
443                others.push(id);
444            }
445        }
446
447        let mut result = others;
448        for ((path, q), (min, max)) in keys.into_iter().zip(bounds) {
449            let merged = self.mk_count(path, min, max, q);
450            result.push(merged);
451        }
452        result
453    }
454
455    /// Fuse conjoined value-type facets (`test(τ)` siblings) into one tightened
456    /// `test(τ₁ ∧ … ∧ τₙ)`, applying range/length bound-merging and same-family
457    /// unsat ([`ValueType::normalize`]). An unsatisfiable combination becomes
458    /// ⊥ (absorbed by the surrounding ∧); a vacuous one (`any`) drops out.
459    fn merge_value_types(&mut self, flat: Vec<ShapeId>) -> Vec<ShapeId> {
460        let mut facets: Vec<ValueType> = Vec::new();
461        let mut others = Vec::new();
462        for id in flat {
463            match self.dst.get(id) {
464                Shape::TestType(vt) => facets.push(vt.clone()),
465                _ => others.push(id),
466            }
467        }
468        if facets.is_empty() {
469            return others;
470        }
471        match ValueType::and(facets).normalize() {
472            None => others.push(self.bottom()), // unsat ⇒ ⊥ (mk_and's loop absorbs)
473            Some(ValueType::Any) => {}          // vacuous ⇒ drops from ∧
474            Some(v) => {
475                let id = self.cons(Shape::TestType(v));
476                others.push(id);
477            }
478        }
479        others
480    }
481
482    fn mk_or(&mut self, ids: Vec<ShapeId>) -> ShapeId {
483        let mut flat = Vec::new();
484        for id in ids {
485            if self.is_top(id) {
486                return id; // φ ∨ ⊤ = ⊤
487            }
488            if self.is_bottom(id) {
489                continue; // drop ⊥
490            }
491            match self.dst.get(id) {
492                Shape::Or(inner) => flat.extend(inner.iter().copied()),
493                _ => flat.push(id),
494            }
495        }
496        flat.sort();
497        flat.dedup();
498        if self.has_complement(&flat) {
499            return self.top(); // φ ∨ ¬φ = ⊤
500        }
501        match flat.len() {
502            0 => self.bottom(),
503            1 => flat[0],
504            _ => self.cons(Shape::Or(flat)),
505        }
506    }
507
508    /// Does `ids` contain some `X` and its negation `¬X`?
509    fn has_complement(&self, ids: &[ShapeId]) -> bool {
510        let set: HashSet<ShapeId> = ids.iter().copied().collect();
511        ids.iter().any(|&id| match self.dst.get(id) {
512            Shape::Not(x) => set.contains(x),
513            _ => false,
514        })
515    }
516
517    fn mk_count(
518        &mut self,
519        path: shifty_algebra::Path,
520        min: Option<u64>,
521        max: Option<u64>,
522        q: ShapeId,
523    ) -> ShapeId {
524        if max.is_none() && matches!(min, None | Some(0)) {
525            return self.top(); // ∃≥0 = ⊤
526        }
527        if let (Some(a), Some(b)) = (min, max)
528            && a > b
529        {
530            return self.bottom(); // unsatisfiable bounds
531        }
532        // Empty-Alt path: Alt([]) matches no neighbors, so count is always 0
533        if matches!(&path, Path::Alt(v) if v.is_empty()) {
534            return if min.unwrap_or(0) >= 1 {
535                self.bottom() // ∃≥1 ∅.φ = ⊥
536            } else {
537                self.top() // ∃[0..m] ∅.φ = ⊤
538            };
539        }
540        // qualifier-⊥ collapse: no node ever satisfies ⊥, so count is always 0
541        if self.is_bottom(q) {
542            return if min.unwrap_or(0) >= 1 {
543                self.bottom() // ∃≥1 π.⊥ = ⊥
544            } else {
545                self.top() // ∃[0..m] π.⊥ = ⊤
546            };
547        }
548        // id-path collapse: id reaches exactly 1 node (the focus node itself)
549        if path == Path::Id {
550            let lo = min.unwrap_or(0);
551            if lo >= 2 {
552                return self.bottom(); // ∃≥2 id.φ = ⊥
553            }
554            return match (lo, max) {
555                (0, Some(0)) => self.mk_not(q), // ∃[0..0] id.φ = ¬φ
556                (1, _) => q,                    // ∃≥1 id.φ = φ
557                _ => self.top(),                // ∃[0..≥1] id.φ = ⊤
558            };
559        }
560        self.cons(Shape::Count {
561            path,
562            min,
563            max,
564            qualifier: q,
565        })
566    }
567
568    fn selector(&mut self, sel: &Selector) -> Selector {
569        match sel {
570            Selector::HasPath(p, q) => {
571                let path = normalize_path(p.clone());
572                let shape = self.intern(*q);
573                // HasPath(Pred(q), ⊤) ⇒ HasOut(q)
574                // HasPath(Pred(q)⁻, ⊤) ⇒ HasIn(q)
575                if self.is_top(shape) {
576                    match &path {
577                        Path::Pred(nn) => return Selector::HasOut(nn.clone()),
578                        Path::Inverse(inner) => {
579                            if let Path::Pred(nn) = inner.as_ref() {
580                                return Selector::HasIn(nn.clone());
581                            }
582                        }
583                        _ => {}
584                    }
585                }
586                Selector::HasPath(path, shape)
587            }
588            other => other.clone(),
589        }
590    }
591
592    fn rule(&mut self, r: &Rule) -> Rule {
593        Rule {
594            selector: self.selector(&r.selector),
595            conditions: r.conditions.iter().map(|c| self.intern(*c)).collect(),
596            head: self.head(&r.head),
597            order: r.order,
598            deactivated: r.deactivated,
599        }
600    }
601
602    fn head(&mut self, h: &RuleHead) -> RuleHead {
603        match h {
604            RuleHead::Triple {
605                subject,
606                predicate,
607                object,
608            } => RuleHead::Triple {
609                subject: self.node_expr(subject),
610                predicate: self.node_expr(predicate),
611                object: self.node_expr(object),
612            },
613            RuleHead::Sparql(s) => RuleHead::Sparql(s.clone()),
614        }
615    }
616
617    fn node_expr(&mut self, e: &NodeExpr) -> NodeExpr {
618        match e {
619            NodeExpr::Filter { input, shape } => NodeExpr::Filter {
620                input: Box::new(self.node_expr(input)),
621                shape: self.intern(*shape),
622            },
623            NodeExpr::Intersection(v) => {
624                NodeExpr::Intersection(v.iter().map(|x| self.node_expr(x)).collect())
625            }
626            NodeExpr::Union(v) => NodeExpr::Union(v.iter().map(|x| self.node_expr(x)).collect()),
627            NodeExpr::Function { iri, args } => NodeExpr::Function {
628                iri: iri.clone(),
629                args: args.iter().map(|x| self.node_expr(x)).collect(),
630            },
631            other => other.clone(),
632        }
633    }
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639    use shifty_algebra::{NodeKindSet, Path, Selector};
640
641    fn schema_with(arena: ShapeArena, root: ShapeId) -> Schema {
642        Schema {
643            arena,
644            statements: vec![Statement {
645                selector: Selector::IsConst(shifty_algebra::Term::NamedNode(
646                    shifty_algebra::NamedNode::new("http://ex/x").unwrap(),
647                )),
648                shape: root,
649            }],
650            rules: Vec::new(),
651            names: Default::default(),
652        }
653    }
654
655    #[test]
656    fn cse_dedups_identical_subshapes() {
657        let mut a = ShapeArena::new();
658        let t1 = a.insert(Shape::TestKind(NodeKindSet::IRI));
659        let t2 = a.insert(Shape::TestKind(NodeKindSet::IRI)); // duplicate
660        let root = a.insert(Shape::And(vec![t1, t2]));
661        let n = normalize(&schema_with(a, root));
662        // And([X, X]) → dedup → single → the TestKind itself
663        let rooted = n.statements[0].shape;
664        assert!(matches!(n.arena.get(rooted), Shape::TestKind(_)));
665        // exactly one TestKind survives (plus nothing else reachable)
666        assert_eq!(n.arena.len(), 1);
667    }
668
669    #[test]
670    fn bottom_absorbs_conjunction() {
671        let mut a = ShapeArena::new();
672        let k = a.insert(Shape::TestKind(NodeKindSet::IRI));
673        let t = a.insert(Shape::Top);
674        let bot = a.insert(Shape::Not(t));
675        let root = a.insert(Shape::And(vec![k, bot]));
676        let n = normalize(&schema_with(a, root));
677        let rooted = n.statements[0].shape;
678        assert!(
679            matches!(n.arena.get(rooted), Shape::Not(x) if matches!(n.arena.get(*x), Shape::Top))
680        );
681    }
682
683    #[test]
684    fn top_absorbs_disjunction() {
685        let mut a = ShapeArena::new();
686        let k = a.insert(Shape::TestKind(NodeKindSet::IRI));
687        let t = a.insert(Shape::Top);
688        let root = a.insert(Shape::Or(vec![k, t]));
689        let n = normalize(&schema_with(a, root));
690        assert!(matches!(n.arena.get(n.statements[0].shape), Shape::Top));
691    }
692
693    #[test]
694    fn complement_is_unsat() {
695        let mut a = ShapeArena::new();
696        let k = a.insert(Shape::TestKind(NodeKindSet::IRI));
697        let nk = a.insert(Shape::Not(k));
698        let root = a.insert(Shape::And(vec![k, nk]));
699        let n = normalize(&schema_with(a, root));
700        assert!(
701            matches!(n.arena.get(n.statements[0].shape), Shape::Not(x) if matches!(n.arena.get(*x), Shape::Top))
702        );
703    }
704
705    #[test]
706    fn nnf_pushes_negation_through_and() {
707        // ¬(TestKind(IRI) ∧ Count(≥1 p.⊤)) → TestKind(Blank|Lit) ∨ Count(≤0 p.⊤)
708        // Uses a TestKind + Count pair so merge_node_kinds can't short-circuit to ⊥.
709        let mut a = ShapeArena::new();
710        let p = Path::Pred(shifty_algebra::NamedNode::new("http://ex/p").unwrap());
711        let top = a.insert(Shape::Top);
712        let k = a.insert(Shape::TestKind(NodeKindSet::IRI));
713        let cnt = a.insert(Shape::Count {
714            path: p,
715            min: Some(1),
716            max: None,
717            qualifier: top,
718        });
719        let and = a.insert(Shape::And(vec![k, cnt]));
720        let root = a.insert(Shape::Not(and));
721        let n = normalize(&schema_with(a, root));
722        match n.arena.get(n.statements[0].shape) {
723            Shape::Or(cs) => {
724                assert_eq!(cs.len(), 2);
725                let kinds: Vec<_> = cs
726                    .iter()
727                    .map(|c| n.arena.get(*c))
728                    .map(|s| matches!(s, Shape::TestKind(_) | Shape::Count { .. }))
729                    .collect();
730                assert!(
731                    kinds.iter().all(|&b| b),
732                    "expected Or of TestKind+Count, got something else"
733                );
734            }
735            other => panic!("expected Or of two shapes, got {other:?}"),
736        }
737    }
738
739    #[test]
740    fn disjoint_node_kinds_in_and_is_unsat() {
741        // TestKind(IRI) ∧ TestKind(Literal) = ⊥ (no term is both)
742        let mut a = ShapeArena::new();
743        let iri = a.insert(Shape::TestKind(NodeKindSet::IRI));
744        let lit = a.insert(Shape::TestKind(NodeKindSet::LITERAL));
745        let root = a.insert(Shape::And(vec![iri, lit]));
746        let n = normalize(&schema_with(a, root));
747        assert!(
748            matches!(n.arena.get(n.statements[0].shape), Shape::Not(x) if matches!(n.arena.get(*x), Shape::Top))
749        );
750    }
751
752    #[test]
753    fn nnf_flips_count_bound() {
754        // ¬(∃≥2 p.⊤) → ∃≤1 p.⊤  (qualifier stays positive)
755        let mut a = ShapeArena::new();
756        let top = a.insert(Shape::Top);
757        let count = a.insert(Shape::Count {
758            path: Path::Pred(shifty_algebra::NamedNode::new("http://ex/p").unwrap()),
759            min: Some(2),
760            max: None,
761            qualifier: top,
762        });
763        let root = a.insert(Shape::Not(count));
764        let n = normalize(&schema_with(a, root));
765        match n.arena.get(n.statements[0].shape) {
766            Shape::Count { min, max, .. } => {
767                assert_eq!((*min, *max), (None, Some(1)));
768            }
769            other => panic!("expected ∃≤1, got {other:?}"),
770        }
771    }
772
773    #[test]
774    fn merges_min_and_max_counts() {
775        // (∃≥1 p.⊤) ∧ (∃≤1 p.⊤) → ∃[1..1] p.⊤
776        let mut a = ShapeArena::new();
777        let p = Path::Pred(shifty_algebra::NamedNode::new("http://ex/p").unwrap());
778        let t1 = a.insert(Shape::Top);
779        let t2 = a.insert(Shape::Top);
780        let lo = a.insert(Shape::Count {
781            path: p.clone(),
782            min: Some(1),
783            max: None,
784            qualifier: t1,
785        });
786        let hi = a.insert(Shape::Count {
787            path: p,
788            min: None,
789            max: Some(1),
790            qualifier: t2,
791        });
792        let root = a.insert(Shape::And(vec![lo, hi]));
793        let n = normalize(&schema_with(a, root));
794        match n.arena.get(n.statements[0].shape) {
795            Shape::Count { min, max, .. } => assert_eq!((*min, *max), (Some(1), Some(1))),
796            other => panic!("expected one fused ∃[1..1], got {other:?}"),
797        }
798    }
799
800    #[test]
801    fn merged_counts_can_be_unsat() {
802        // (∃≥2 p.⊤) ∧ (∃≤1 p.⊤) → ⊥
803        let mut a = ShapeArena::new();
804        let p = Path::Pred(shifty_algebra::NamedNode::new("http://ex/p").unwrap());
805        let t1 = a.insert(Shape::Top);
806        let t2 = a.insert(Shape::Top);
807        let lo = a.insert(Shape::Count {
808            path: p.clone(),
809            min: Some(2),
810            max: None,
811            qualifier: t1,
812        });
813        let hi = a.insert(Shape::Count {
814            path: p,
815            min: None,
816            max: Some(1),
817            qualifier: t2,
818        });
819        let root = a.insert(Shape::And(vec![lo, hi]));
820        let n = normalize(&schema_with(a, root));
821        assert!(
822            matches!(n.arena.get(n.statements[0].shape), Shape::Not(x) if matches!(n.arena.get(*x), Shape::Top))
823        );
824    }
825
826    #[test]
827    fn unsat_value_type_absorbs_conjunction() {
828        // K(IRI) ∧ test([5,3])  →  ⊥   (the empty range folds to ⊥, absorbing ∧)
829        use shifty_algebra::{Bound, Literal, NamedNode, ValueType};
830        let int = |n: i64| {
831            Literal::new_typed_literal(
832                n.to_string(),
833                NamedNode::new("http://www.w3.org/2001/XMLSchema#integer").unwrap(),
834            )
835        };
836        let mut a = ShapeArena::new();
837        let k = a.insert(Shape::TestKind(NodeKindSet::IRI));
838        let bad = a.insert(Shape::TestType(ValueType::NumericRange {
839            lo: Some(Bound {
840                value: int(5),
841                inclusive: true,
842            }),
843            hi: Some(Bound {
844                value: int(3),
845                inclusive: true,
846            }),
847        }));
848        let root = a.insert(Shape::And(vec![k, bad]));
849        let n = normalize(&schema_with(a, root));
850        assert!(matches!(n.arena.get(n.statements[0].shape),
851            Shape::Not(x) if matches!(n.arena.get(*x), Shape::Top)));
852    }
853
854    #[test]
855    fn conjoined_range_facets_merge() {
856        // test(≥1) ∧ test(≤10)  →  single test([1,10])
857        use shifty_algebra::{Bound, Literal, NamedNode, ValueType};
858        let int = |n: i64| {
859            Literal::new_typed_literal(
860                n.to_string(),
861                NamedNode::new("http://www.w3.org/2001/XMLSchema#integer").unwrap(),
862            )
863        };
864        let mut a = ShapeArena::new();
865        let lo = a.insert(Shape::TestType(ValueType::NumericRange {
866            lo: Some(Bound {
867                value: int(1),
868                inclusive: true,
869            }),
870            hi: None,
871        }));
872        let hi = a.insert(Shape::TestType(ValueType::NumericRange {
873            lo: None,
874            hi: Some(Bound {
875                value: int(10),
876                inclusive: true,
877            }),
878        }));
879        let root = a.insert(Shape::And(vec![lo, hi]));
880        let n = normalize(&schema_with(a, root));
881        match n.arena.get(n.statements[0].shape) {
882            Shape::TestType(ValueType::NumericRange { lo, hi }) => {
883                assert!(lo.is_some() && hi.is_some(), "expected fused [1,10]");
884            }
885            other => panic!("expected one fused range facet, got {other:?}"),
886        }
887    }
888
889    #[test]
890    fn negating_recursive_shape_terminates() {
891        // T := ¬S where S := ∃≥1 p.S — must not loop; ¬ stays outside the cycle
892        let mut a = ShapeArena::new();
893        let s = a.reserve();
894        a.set(
895            s,
896            Shape::Count {
897                path: Path::Pred(shifty_algebra::NamedNode::new("http://ex/p").unwrap()),
898                min: Some(1),
899                max: None,
900                qualifier: s,
901            },
902        );
903        let root = a.insert(Shape::Not(s));
904        let n = normalize(&schema_with(a, root));
905        assert!(matches!(n.arena.get(n.statements[0].shape), Shape::Not(_)));
906    }
907
908    #[test]
909    fn qualifier_bottom_min1_is_bottom() {
910        // ∃≥1 p.⊥ = ⊥
911        let mut a = ShapeArena::new();
912        let top = a.insert(Shape::Top);
913        let bot = a.insert(Shape::Not(top));
914        let count = a.insert(Shape::Count {
915            path: Path::Pred(shifty_algebra::NamedNode::new("http://ex/p").unwrap()),
916            min: Some(1),
917            max: None,
918            qualifier: bot,
919        });
920        let n = normalize(&schema_with(a, count));
921        let r = n.statements[0].shape;
922        assert!(matches!(n.arena.get(r), Shape::Not(x) if matches!(n.arena.get(*x), Shape::Top)));
923    }
924
925    #[test]
926    fn qualifier_bottom_max_bound_is_top() {
927        // ∃[0..2] p.⊥ = ⊤
928        let mut a = ShapeArena::new();
929        let top = a.insert(Shape::Top);
930        let bot = a.insert(Shape::Not(top));
931        let count = a.insert(Shape::Count {
932            path: Path::Pred(shifty_algebra::NamedNode::new("http://ex/p").unwrap()),
933            min: None,
934            max: Some(2),
935            qualifier: bot,
936        });
937        let n = normalize(&schema_with(a, count));
938        assert!(matches!(n.arena.get(n.statements[0].shape), Shape::Top));
939    }
940
941    #[test]
942    fn id_path_min1_is_qualifier() {
943        // ∃≥1 id.φ = φ
944        let mut a = ShapeArena::new();
945        let k = a.insert(Shape::TestKind(NodeKindSet::IRI));
946        let count = a.insert(Shape::Count {
947            path: Path::Id,
948            min: Some(1),
949            max: None,
950            qualifier: k,
951        });
952        let n = normalize(&schema_with(a, count));
953        assert!(matches!(
954            n.arena.get(n.statements[0].shape),
955            Shape::TestKind(NodeKindSet::IRI)
956        ));
957    }
958
959    #[test]
960    fn id_path_max0_is_negation() {
961        // ∃[0..0] id.φ = ¬φ; for TestKind(IRI) that becomes TestKind(Blank|Lit)
962        let mut a = ShapeArena::new();
963        let k = a.insert(Shape::TestKind(NodeKindSet::IRI));
964        let count = a.insert(Shape::Count {
965            path: Path::Id,
966            min: None,
967            max: Some(0),
968            qualifier: k,
969        });
970        let n = normalize(&schema_with(a, count));
971        match n.arena.get(n.statements[0].shape) {
972            Shape::TestKind(nk) => {
973                assert_eq!(*nk, NodeKindSet::BLANK_NODE_OR_LITERAL);
974            }
975            other => panic!("expected TestKind(Blank|Lit), got {other:?}"),
976        }
977    }
978
979    #[test]
980    fn id_path_min2_is_bottom() {
981        // ∃≥2 id.φ = ⊥
982        let mut a = ShapeArena::new();
983        let k = a.insert(Shape::TestKind(NodeKindSet::IRI));
984        let count = a.insert(Shape::Count {
985            path: Path::Id,
986            min: Some(2),
987            max: None,
988            qualifier: k,
989        });
990        let n = normalize(&schema_with(a, count));
991        let r = n.statements[0].shape;
992        assert!(matches!(n.arena.get(r), Shape::Not(x) if matches!(n.arena.get(*x), Shape::Top)));
993    }
994
995    #[test]
996    fn converse_pushdown_through_seq() {
997        // ∃≥1 (p·q)⁻.⊤ → path normalized to q⁻·p⁻
998        let p = shifty_algebra::NamedNode::new("http://ex/p").unwrap();
999        let q = shifty_algebra::NamedNode::new("http://ex/q").unwrap();
1000        let inv_seq = Path::Inverse(Box::new(Path::Seq(vec![
1001            Path::Pred(p.clone()),
1002            Path::Pred(q.clone()),
1003        ])));
1004        let mut a = ShapeArena::new();
1005        let top = a.insert(Shape::Top);
1006        let count = a.insert(Shape::Count {
1007            path: inv_seq,
1008            min: Some(1),
1009            max: None,
1010            qualifier: top,
1011        });
1012        let n = normalize(&schema_with(a, count));
1013        match n.arena.get(n.statements[0].shape) {
1014            Shape::Count { path, .. } => {
1015                let expected = Path::Seq(vec![
1016                    Path::Inverse(Box::new(Path::Pred(q))),
1017                    Path::Inverse(Box::new(Path::Pred(p))),
1018                ]);
1019                assert_eq!(*path, expected, "expected (p·q)⁻ normalized to q⁻·p⁻");
1020            }
1021            other => panic!("expected Count with normalized path, got {other:?}"),
1022        }
1023    }
1024
1025    #[test]
1026    fn converse_pushdown_through_alt_and_star() {
1027        // (p|q)⁻ = p⁻|q⁻;  (p*)⁻ = (p⁻)*
1028        let p = shifty_algebra::NamedNode::new("http://ex/p").unwrap();
1029        let q = shifty_algebra::NamedNode::new("http://ex/q").unwrap();
1030        let alt = Path::Alt(vec![Path::Pred(p.clone()), Path::Pred(q.clone())]);
1031        assert_eq!(
1032            normalize_path(Path::Inverse(Box::new(alt))),
1033            Path::Alt(vec![
1034                Path::Inverse(Box::new(Path::Pred(p.clone()))),
1035                Path::Inverse(Box::new(Path::Pred(q))),
1036            ])
1037        );
1038        let star_inv = Path::Inverse(Box::new(Path::Star(Box::new(Path::Pred(p.clone())))));
1039        assert_eq!(
1040            normalize_path(star_inv),
1041            Path::Star(Box::new(Path::Inverse(Box::new(Path::Pred(p)))))
1042        );
1043    }
1044
1045    #[test]
1046    fn recursive_shape_survives_normalization() {
1047        // S := ∃≥1 p . S
1048        let mut a = ShapeArena::new();
1049        let s = a.reserve();
1050        a.set(
1051            s,
1052            Shape::Count {
1053                path: Path::Pred(shifty_algebra::NamedNode::new("http://ex/p").unwrap()),
1054                min: Some(1),
1055                max: None,
1056                qualifier: s,
1057            },
1058        );
1059        let n = normalize(&schema_with(a, s));
1060        let rooted = n.statements[0].shape;
1061        match n.arena.get(rooted) {
1062            Shape::Count { qualifier, .. } => assert_eq!(*qualifier, rooted),
1063            other => panic!("expected self-referential Count, got {other:?}"),
1064        }
1065    }
1066
1067    #[test]
1068    fn negkind_iri_becomes_blank_or_literal() {
1069        // ¬TestKind(IRI) = TestKind(Blank|Literal)
1070        let mut a = ShapeArena::new();
1071        let iri = a.insert(Shape::TestKind(NodeKindSet::IRI));
1072        let root = a.insert(Shape::Not(iri));
1073        let n = normalize(&schema_with(a, root));
1074        assert!(matches!(
1075            n.arena.get(n.statements[0].shape),
1076            Shape::TestKind(NodeKindSet::BLANK_NODE_OR_LITERAL)
1077        ));
1078    }
1079
1080    #[test]
1081    fn empty_alt_path_min1_is_bottom() {
1082        // ∃≥1 Alt([]).φ = ⊥
1083        let mut a = ShapeArena::new();
1084        let top = a.insert(Shape::Top);
1085        let count = a.insert(Shape::Count {
1086            path: Path::Alt(vec![]),
1087            min: Some(1),
1088            max: None,
1089            qualifier: top,
1090        });
1091        let n = normalize(&schema_with(a, count));
1092        let r = n.statements[0].shape;
1093        assert!(matches!(n.arena.get(r), Shape::Not(x) if matches!(n.arena.get(*x), Shape::Top)));
1094    }
1095
1096    #[test]
1097    fn empty_alt_path_max_bound_is_top() {
1098        // ∃[0..3] Alt([]).φ = ⊤
1099        let mut a = ShapeArena::new();
1100        let top = a.insert(Shape::Top);
1101        let count = a.insert(Shape::Count {
1102            path: Path::Alt(vec![]),
1103            min: None,
1104            max: Some(3),
1105            qualifier: top,
1106        });
1107        let n = normalize(&schema_with(a, count));
1108        assert!(matches!(n.arena.get(n.statements[0].shape), Shape::Top));
1109    }
1110
1111    #[test]
1112    fn star_drops_id_from_alt() {
1113        // (p∪id)* = p*
1114        let p = shifty_algebra::NamedNode::new("http://ex/p").unwrap();
1115        let alt_id = Path::Alt(vec![Path::Pred(p.clone()), Path::Id]);
1116        let star = Path::Star(Box::new(alt_id));
1117        assert_eq!(normalize_path(star), Path::Star(Box::new(Path::Pred(p))));
1118    }
1119
1120    #[test]
1121    fn seq_merges_adjacent_equal_stars() {
1122        // p*·p* = p*
1123        let p = shifty_algebra::NamedNode::new("http://ex/p").unwrap();
1124        let star = Path::Star(Box::new(Path::Pred(p.clone())));
1125        let seq = Path::Seq(vec![star.clone(), star]);
1126        assert_eq!(normalize_path(seq), Path::Star(Box::new(Path::Pred(p))));
1127    }
1128
1129    #[test]
1130    fn alt_deduplicates_paths() {
1131        // Alt([p, p, q]) = Alt([p, q])
1132        let p = shifty_algebra::NamedNode::new("http://ex/p").unwrap();
1133        let q = shifty_algebra::NamedNode::new("http://ex/q").unwrap();
1134        let dup = Path::Alt(vec![
1135            Path::Pred(p.clone()),
1136            Path::Pred(p.clone()),
1137            Path::Pred(q.clone()),
1138        ]);
1139        let result = normalize_path(dup);
1140        assert_eq!(result, Path::Alt(vec![Path::Pred(p), Path::Pred(q)]));
1141    }
1142
1143    #[test]
1144    fn selector_haspath_pred_top_becomes_hasout() {
1145        // HasPath(Pred(q), ⊤) ⇒ HasOut(q)
1146        let q = shifty_algebra::NamedNode::new("http://ex/q").unwrap();
1147        let mut a = ShapeArena::new();
1148        let top = a.insert(Shape::Top);
1149        let schema = Schema {
1150            arena: a,
1151            statements: vec![Statement {
1152                selector: Selector::HasPath(Path::Pred(q.clone()), top),
1153                shape: top,
1154            }],
1155            rules: vec![],
1156            names: Default::default(),
1157        };
1158        let n = normalize(&schema);
1159        assert_eq!(n.statements[0].selector, Selector::HasOut(q));
1160    }
1161
1162    #[test]
1163    fn selector_haspath_inv_pred_top_becomes_hasin() {
1164        // HasPath(Pred(q)⁻, ⊤) ⇒ HasIn(q)
1165        let q = shifty_algebra::NamedNode::new("http://ex/q").unwrap();
1166        let mut a = ShapeArena::new();
1167        let top = a.insert(Shape::Top);
1168        let schema = Schema {
1169            arena: a,
1170            statements: vec![Statement {
1171                selector: Selector::HasPath(Path::Inverse(Box::new(Path::Pred(q.clone()))), top),
1172                shape: top,
1173            }],
1174            rules: vec![],
1175            names: Default::default(),
1176        };
1177        let n = normalize(&schema);
1178        assert_eq!(n.statements[0].selector, Selector::HasIn(q));
1179    }
1180
1181    #[test]
1182    fn statement_dedup_removes_identical() {
1183        let mut a = ShapeArena::new();
1184        let k = a.insert(Shape::TestKind(NodeKindSet::IRI));
1185        let node =
1186            shifty_algebra::Term::NamedNode(shifty_algebra::NamedNode::new("http://ex/x").unwrap());
1187        let sel = Selector::IsConst(node);
1188        let schema = Schema {
1189            arena: a,
1190            statements: vec![
1191                Statement {
1192                    selector: sel.clone(),
1193                    shape: k,
1194                },
1195                Statement {
1196                    selector: sel.clone(),
1197                    shape: k,
1198                },
1199            ],
1200            rules: vec![],
1201            names: Default::default(),
1202        };
1203        let n = normalize(&schema);
1204        assert_eq!(n.statements.len(), 1);
1205    }
1206}