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