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