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