diff --git a/CHANGELOG.md b/CHANGELOG.md index 62536492e..2babb7292 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - Add DispatchExecutor, a query plan executor that is Dispatch-aware and sends subproblems on Alias boundaries (https://github.com/authzed/spicedb/pull/3074) - Implement Dispatch caching for query plan execution (https://github.com/authzed/spicedb/pull/3079) +- Add new optimizer to query planner based on set theory laws for simplifications (https://github.com/authzed/spicedb/pull/3051) ### Changed - Build: strip quarantine attribute for MacOS (https://github.com/authzed/spicedb/pull/3082) diff --git a/pkg/query/mutations.go b/pkg/query/mutations.go index 2cc7341af..24124c543 100644 --- a/pkg/query/mutations.go +++ b/pkg/query/mutations.go @@ -22,7 +22,7 @@ func MutateOutline(outline Outline, fns []OutlineMutation) Outline { // upward through the tree according to each node type's semantics: // - Union: null if ALL children are null // - Intersection: null if ANY child is null -// - Arrow/IntersectionArrow: null if the right child is null +// - Arrow/IntersectionArrow: null if either child is null // - Exclusion: null if the left child is null // - Caveat/Alias/Recursive: null if the only child is null // @@ -47,7 +47,7 @@ func NullPropagation(outline Outline) Outline { } case ArrowIteratorType, IntersectionArrowIteratorType: - if len(outline.SubOutlines) == 2 && outline.SubOutlines[1].Type == NullIteratorType { + if len(outline.SubOutlines) == 2 && (outline.SubOutlines[0].Type == NullIteratorType || outline.SubOutlines[1].Type == NullIteratorType) { return Outline{Type: NullIteratorType, ID: outline.ID} } diff --git a/pkg/query/mutations_test.go b/pkg/query/mutations_test.go index f60be0ec6..3c7e0125a 100644 --- a/pkg/query/mutations_test.go +++ b/pkg/query/mutations_test.go @@ -6,6 +6,268 @@ import ( "github.com/stretchr/testify/require" ) +func TestNullPropagation(t *testing.T) { + live := Outline{Type: FixedIteratorType} + null := Outline{Type: NullIteratorType} + + // --- UnionIteratorType --- + + t.Run("union: all null → null", func(t *testing.T) { + result := NullPropagation(Outline{Type: UnionIteratorType, SubOutlines: []Outline{null, null}}) + require.Equal(t, NullIteratorType, result.Type) + }) + + t.Run("union: all null (3 children) → null", func(t *testing.T) { + result := NullPropagation(Outline{Type: UnionIteratorType, SubOutlines: []Outline{null, null, null}}) + require.Equal(t, NullIteratorType, result.Type) + }) + + t.Run("union: one live child → unchanged", func(t *testing.T) { + result := NullPropagation(Outline{Type: UnionIteratorType, SubOutlines: []Outline{live, null}}) + require.Equal(t, UnionIteratorType, result.Type) + }) + + t.Run("union: mixed (3 children, 1 live) → unchanged", func(t *testing.T) { + result := NullPropagation(Outline{Type: UnionIteratorType, SubOutlines: []Outline{null, live, null}}) + require.Equal(t, UnionIteratorType, result.Type) + }) + + t.Run("union: all live → unchanged", func(t *testing.T) { + result := NullPropagation(Outline{Type: UnionIteratorType, SubOutlines: []Outline{live, live}}) + require.Equal(t, UnionIteratorType, result.Type) + }) + + t.Run("union: preserves ID when null", func(t *testing.T) { + result := NullPropagation(Outline{Type: UnionIteratorType, ID: 7, SubOutlines: []Outline{null, null}}) + require.Equal(t, NullIteratorType, result.Type) + require.Equal(t, OutlineNodeID(7), result.ID) + }) + + // --- IntersectionIteratorType --- + + t.Run("intersection: any null child → null", func(t *testing.T) { + result := NullPropagation(Outline{Type: IntersectionIteratorType, SubOutlines: []Outline{live, null}}) + require.Equal(t, NullIteratorType, result.Type) + }) + + t.Run("intersection: null first child → null", func(t *testing.T) { + result := NullPropagation(Outline{Type: IntersectionIteratorType, SubOutlines: []Outline{null, live}}) + require.Equal(t, NullIteratorType, result.Type) + }) + + t.Run("intersection: all null → null", func(t *testing.T) { + result := NullPropagation(Outline{Type: IntersectionIteratorType, SubOutlines: []Outline{null, null}}) + require.Equal(t, NullIteratorType, result.Type) + }) + + t.Run("intersection: one null in 3 children → null", func(t *testing.T) { + result := NullPropagation(Outline{Type: IntersectionIteratorType, SubOutlines: []Outline{live, null, live}}) + require.Equal(t, NullIteratorType, result.Type) + }) + + t.Run("intersection: all live → unchanged", func(t *testing.T) { + result := NullPropagation(Outline{Type: IntersectionIteratorType, SubOutlines: []Outline{live, live}}) + require.Equal(t, IntersectionIteratorType, result.Type) + }) + + t.Run("intersection: preserves ID when null", func(t *testing.T) { + result := NullPropagation(Outline{Type: IntersectionIteratorType, ID: 8, SubOutlines: []Outline{live, null}}) + require.Equal(t, NullIteratorType, result.Type) + require.Equal(t, OutlineNodeID(8), result.ID) + }) + + // --- ArrowIteratorType --- + + t.Run("arrow: null left child → null", func(t *testing.T) { + // Null → B has no sources to traverse, so result is empty. + result := NullPropagation(Outline{Type: ArrowIteratorType, SubOutlines: []Outline{null, live}}) + require.Equal(t, NullIteratorType, result.Type) + }) + + t.Run("arrow: null right child → null", func(t *testing.T) { + // A → Null has no target to reach. + result := NullPropagation(Outline{Type: ArrowIteratorType, SubOutlines: []Outline{live, null}}) + require.Equal(t, NullIteratorType, result.Type) + }) + + t.Run("arrow: both children null → null", func(t *testing.T) { + result := NullPropagation(Outline{Type: ArrowIteratorType, SubOutlines: []Outline{null, null}}) + require.Equal(t, NullIteratorType, result.Type) + }) + + t.Run("arrow: neither child null → unchanged", func(t *testing.T) { + result := NullPropagation(Outline{Type: ArrowIteratorType, SubOutlines: []Outline{live, live}}) + require.Equal(t, ArrowIteratorType, result.Type) + }) + + t.Run("arrow: wrong child count (1) → unchanged (defensive guard)", func(t *testing.T) { + result := NullPropagation(Outline{Type: ArrowIteratorType, SubOutlines: []Outline{null}}) + require.Equal(t, ArrowIteratorType, result.Type) + }) + + t.Run("arrow: preserves ID when null", func(t *testing.T) { + result := NullPropagation(Outline{Type: ArrowIteratorType, ID: 42, SubOutlines: []Outline{null, live}}) + require.Equal(t, NullIteratorType, result.Type) + require.Equal(t, OutlineNodeID(42), result.ID) + }) + + // --- IntersectionArrowIteratorType --- + + t.Run("intersection arrow: null left child → null", func(t *testing.T) { + result := NullPropagation(Outline{Type: IntersectionArrowIteratorType, SubOutlines: []Outline{null, live}}) + require.Equal(t, NullIteratorType, result.Type) + }) + + t.Run("intersection arrow: null right child → null", func(t *testing.T) { + result := NullPropagation(Outline{Type: IntersectionArrowIteratorType, SubOutlines: []Outline{live, null}}) + require.Equal(t, NullIteratorType, result.Type) + }) + + t.Run("intersection arrow: both children null → null", func(t *testing.T) { + result := NullPropagation(Outline{Type: IntersectionArrowIteratorType, SubOutlines: []Outline{null, null}}) + require.Equal(t, NullIteratorType, result.Type) + }) + + t.Run("intersection arrow: neither child null → unchanged", func(t *testing.T) { + result := NullPropagation(Outline{Type: IntersectionArrowIteratorType, SubOutlines: []Outline{live, live}}) + require.Equal(t, IntersectionArrowIteratorType, result.Type) + }) + + t.Run("intersection arrow: wrong child count (1) → unchanged (defensive guard)", func(t *testing.T) { + result := NullPropagation(Outline{Type: IntersectionArrowIteratorType, SubOutlines: []Outline{null}}) + require.Equal(t, IntersectionArrowIteratorType, result.Type) + }) + + t.Run("intersection arrow: wrong child count (3) with null child → unchanged (defensive guard)", func(t *testing.T) { + result := NullPropagation(Outline{Type: IntersectionArrowIteratorType, SubOutlines: []Outline{null, live, live}}) + require.Equal(t, IntersectionArrowIteratorType, result.Type) + }) + + t.Run("intersection arrow: preserves ID when null", func(t *testing.T) { + result := NullPropagation(Outline{Type: IntersectionArrowIteratorType, ID: 9, SubOutlines: []Outline{null, live}}) + require.Equal(t, NullIteratorType, result.Type) + require.Equal(t, OutlineNodeID(9), result.ID) + }) + + // --- ExclusionIteratorType --- + + t.Run("exclusion: null left child (main set) → null", func(t *testing.T) { + result := NullPropagation(Outline{Type: ExclusionIteratorType, SubOutlines: []Outline{null, live}}) + require.Equal(t, NullIteratorType, result.Type) + }) + + t.Run("exclusion: null right child (excluded set) → unchanged (A − ∅ = A)", func(t *testing.T) { + // Subtracting the empty set is a no-op; the node stays for a later pass. + result := NullPropagation(Outline{Type: ExclusionIteratorType, SubOutlines: []Outline{live, null}}) + require.Equal(t, ExclusionIteratorType, result.Type) + }) + + t.Run("exclusion: both null → null (left child triggers)", func(t *testing.T) { + result := NullPropagation(Outline{Type: ExclusionIteratorType, SubOutlines: []Outline{null, null}}) + require.Equal(t, NullIteratorType, result.Type) + }) + + t.Run("exclusion: neither null → unchanged", func(t *testing.T) { + result := NullPropagation(Outline{Type: ExclusionIteratorType, SubOutlines: []Outline{live, live}}) + require.Equal(t, ExclusionIteratorType, result.Type) + }) + + t.Run("exclusion: wrong child count (1) → unchanged (defensive guard)", func(t *testing.T) { + result := NullPropagation(Outline{Type: ExclusionIteratorType, SubOutlines: []Outline{null}}) + require.Equal(t, ExclusionIteratorType, result.Type) + }) + + t.Run("exclusion: preserves ID when null", func(t *testing.T) { + result := NullPropagation(Outline{Type: ExclusionIteratorType, ID: 5, SubOutlines: []Outline{null, live}}) + require.Equal(t, NullIteratorType, result.Type) + require.Equal(t, OutlineNodeID(5), result.ID) + }) + + // --- CaveatIteratorType --- + + t.Run("caveat: null child → null", func(t *testing.T) { + result := NullPropagation(Outline{Type: CaveatIteratorType, SubOutlines: []Outline{null}}) + require.Equal(t, NullIteratorType, result.Type) + }) + + t.Run("caveat: live child → unchanged", func(t *testing.T) { + result := NullPropagation(Outline{Type: CaveatIteratorType, SubOutlines: []Outline{live}}) + require.Equal(t, CaveatIteratorType, result.Type) + }) + + t.Run("caveat: wrong child count (2) → unchanged (defensive guard)", func(t *testing.T) { + result := NullPropagation(Outline{Type: CaveatIteratorType, SubOutlines: []Outline{null, null}}) + require.Equal(t, CaveatIteratorType, result.Type) + }) + + t.Run("caveat: preserves ID when null", func(t *testing.T) { + result := NullPropagation(Outline{Type: CaveatIteratorType, ID: 3, SubOutlines: []Outline{null}}) + require.Equal(t, NullIteratorType, result.Type) + require.Equal(t, OutlineNodeID(3), result.ID) + }) + + // --- AliasIteratorType --- + + t.Run("alias: null child → null", func(t *testing.T) { + result := NullPropagation(Outline{Type: AliasIteratorType, SubOutlines: []Outline{null}}) + require.Equal(t, NullIteratorType, result.Type) + }) + + t.Run("alias: live child → unchanged", func(t *testing.T) { + result := NullPropagation(Outline{Type: AliasIteratorType, SubOutlines: []Outline{live}}) + require.Equal(t, AliasIteratorType, result.Type) + }) + + t.Run("alias: wrong child count (2) → unchanged (defensive guard)", func(t *testing.T) { + result := NullPropagation(Outline{Type: AliasIteratorType, SubOutlines: []Outline{null, null}}) + require.Equal(t, AliasIteratorType, result.Type) + }) + + // --- RecursiveIteratorType --- + + t.Run("recursive: null child → null", func(t *testing.T) { + result := NullPropagation(Outline{Type: RecursiveIteratorType, SubOutlines: []Outline{null}}) + require.Equal(t, NullIteratorType, result.Type) + }) + + t.Run("recursive: live child → unchanged", func(t *testing.T) { + result := NullPropagation(Outline{Type: RecursiveIteratorType, SubOutlines: []Outline{live}}) + require.Equal(t, RecursiveIteratorType, result.Type) + }) + + t.Run("recursive: wrong child count (2) → unchanged (defensive guard)", func(t *testing.T) { + result := NullPropagation(Outline{Type: RecursiveIteratorType, SubOutlines: []Outline{null, null}}) + require.Equal(t, RecursiveIteratorType, result.Type) + }) + + // --- Leaf / unhandled types → always unchanged --- + + t.Run("null node itself → unchanged", func(t *testing.T) { + result := NullPropagation(Outline{Type: NullIteratorType}) + require.Equal(t, NullIteratorType, result.Type) + }) + + t.Run("datastore leaf → unchanged", func(t *testing.T) { + result := NullPropagation(Outline{Type: DatastoreIteratorType}) + require.Equal(t, DatastoreIteratorType, result.Type) + }) + + t.Run("self leaf → unchanged", func(t *testing.T) { + result := NullPropagation(Outline{Type: SelfIteratorType}) + require.Equal(t, SelfIteratorType, result.Type) + }) + + t.Run("fixed leaf → unchanged", func(t *testing.T) { + result := NullPropagation(Outline{Type: FixedIteratorType}) + require.Equal(t, FixedIteratorType, result.Type) + }) + + t.Run("recursive sentinel leaf → unchanged", func(t *testing.T) { + result := NullPropagation(Outline{Type: RecursiveSentinelIteratorType}) + require.Equal(t, RecursiveSentinelIteratorType, result.Type) + }) +} + func TestReorderMutation(t *testing.T) { t.Run("reorders children correctly", func(t *testing.T) { child0 := Outline{Type: FixedIteratorType, Args: &IteratorArgs{FixedPaths: []Path{*MustPathFromString("document:doc0#viewer@user:alice")}}} diff --git a/pkg/query/queryopt/caveat_pushdown.go b/pkg/query/queryopt/caveat_pushdown.go index f9e938cdf..a25efa0b5 100644 --- a/pkg/query/queryopt/caveat_pushdown.go +++ b/pkg/query/queryopt/caveat_pushdown.go @@ -9,6 +9,7 @@ func init() { Pushes caveat evalution to the lowest point in the tree. Cannot push through intersection arrows `, + Priority: 20, NewTransform: func(_ RequestParams) OutlineTransform { return func(outline query.Outline) query.Outline { return query.MutateOutline(outline, []query.OutlineMutation{caveatPushdown}) diff --git a/pkg/query/queryopt/reachability_pruning.go b/pkg/query/queryopt/reachability_pruning.go index 5f16c0a75..e88f46465 100644 --- a/pkg/query/queryopt/reachability_pruning.go +++ b/pkg/query/queryopt/reachability_pruning.go @@ -11,6 +11,7 @@ func init() { Replaces subtrees with NullIteratorType nodes when they can never produce the target subject type of the request. `, + Priority: 0, NewTransform: func(params RequestParams) OutlineTransform { return reachabilityPruning(params) }, diff --git a/pkg/query/queryopt/registry.go b/pkg/query/queryopt/registry.go index d61253217..2f6a48247 100644 --- a/pkg/query/queryopt/registry.go +++ b/pkg/query/queryopt/registry.go @@ -58,6 +58,7 @@ type RequestParams struct { func OptimizersForRequest(params RequestParams) []Optimizer { base := []Optimizer{ optimizationRegistry["simple-caveat-pushdown"], + optimizationRegistry["set-simplification"], optimizationRegistry["reachability-pruning"], } diff --git a/pkg/query/queryopt/set_simplification.go b/pkg/query/queryopt/set_simplification.go new file mode 100644 index 000000000..feeed093c --- /dev/null +++ b/pkg/query/queryopt/set_simplification.go @@ -0,0 +1,394 @@ +package queryopt + +import ( + "slices" + + "github.com/authzed/spicedb/pkg/query" +) + +func init() { + MustRegisterOptimization(Optimizer{ + Name: "set-simplification", + Description: ` + Removes subsumed branches from union and intersection expressions, and + annihilates exclusions whose minuend is a subset of the subtrahend, using + ten set-theoretic laws applied in the order listed: + + Normalization: + - Associativity: (A ∪ B) ∪ C = A ∪ B ∪ C (and same for ∩) + + Union laws (drop Y when Y ⊆ X for some sibling X): + - Idempotency: A ∪ A = A + - Absorption: A ∪ (A ∩ B) = A + - Complement-absorption: A ∪ (A − B) = A + + Intersection laws (drop Y when X ⊆ Y for some sibling X): + - Idempotency: A ∩ A = A + - Absorption: A ∩ (A ∪ B) = A + - Complement: Y ∩ (A − C) = ∅ when Y ⊆ C + + Exclusion laws: + - Annihilation: X − A = ∅ when X ⊆ A + - Left-pruning: (A ∪ B) − Y = B − Y when A ⊆ Y + - Null identity: A − ∅ = A + + Caveats and arrows are treated as opaque structural units throughout: + isSubsumedBy never looks inside them, so two caveat nodes or arrow nodes + are equal only when structurally identical. + + Runs after simple-caveat-pushdown (priority 20) so it sees the final caveat + shape, and before reachability-pruning (priority 0) so pruning works on a + smaller tree. + `, + Priority: 10, + NewTransform: func(_ RequestParams) OutlineTransform { + return func(outline query.Outline) query.Outline { + return query.MutateOutline(outline, []query.OutlineMutation{ + flattenAssociativity, + unionIdempotency, + unionAbsorption, + unionComplementAbsorption, + intersectionIdempotency, + intersectionAbsorption, + intersectionComplementAnnihilation, + exclusionAnnihilation, + exclusionLeftPruning, + exclusionNullIdentity, + query.NullPropagation, + }) + } + }, + }) +} + +// flattenAssociativity is an OutlineMutation that inlines nested same-type union +// or intersection children into their parent, normalizing to a flat n-ary form. +// Law: (A ∪ B) ∪ C = A ∪ B ∪ C (and same for ∩) +// +// This runs first so that all siblings are visible at the same level before any +// absorption rule runs. Without it, A and A∩B in Union[A, Union[A∩B, C]] are not +// peers and unionAbsorption cannot fire. +// +// A single surviving child is returned unwrapped to avoid a redundant wrapper. +func flattenAssociativity(outline query.Outline) query.Outline { + if outline.Type != query.UnionIteratorType && outline.Type != query.IntersectionIteratorType { + return outline + } + + var newChildren []query.Outline + changed := false + for _, child := range outline.SubOutlines { + if child.Type == outline.Type { + newChildren = append(newChildren, child.SubOutlines...) + changed = true + } else { + newChildren = append(newChildren, child) + } + } + + switch { + case !changed: + // Nothing changed, return the original. + return outline + + case len(newChildren) == 1: + // Only one result: return it as a singleton. + return newChildren[0] + + default: + return query.Outline{ + Type: outline.Type, + Args: outline.Args, + SubOutlines: newChildren, + } + } +} + +// unionIdempotency is an OutlineMutation that collapses structurally identical +// children in a union. +// Law: A ∪ A = A +func unionIdempotency(outline query.Outline) query.Outline { + return eliminateRedundantChildren( + outline, + query.UnionIteratorType, + func(y, x query.Outline) bool { return x.Equals(y) }, + ) +} + +// unionAbsorption is an OutlineMutation that drops intersection children from a +// union when they are subsumed by a sibling. +// Law: A ∪ (A ∩ B) = A; generalizes to (A ∩ B) ∪ (A ∩ B ∩ C) = A ∩ B. +// +// A child Y is dropped when Y is an intersection and every factor of some sibling X +// appears among Y's direct children. Anything satisfying Y must also satisfy X, so +// the union already covers Y via X. +func unionAbsorption(outline query.Outline) query.Outline { + return eliminateRedundantChildren(outline, query.UnionIteratorType, func(y, x query.Outline) bool { + if y.Type != query.IntersectionIteratorType { + return false + } + for _, factor := range intersectionFactors(x) { + if !slices.ContainsFunc(y.SubOutlines, factor.Equals) { + return false + } + } + return true + }) +} + +// unionComplementAbsorption is an OutlineMutation that drops exclusion children +// from a union when they are subsumed by a sibling. +// Law: A ∪ (A − B) = A +// +// A child Y is dropped when Y is an exclusion (M − S) and the minuend M is +// subsumed by some sibling X. Since (M − S) ⊆ M ⊆ X, the union already covers Y. +func unionComplementAbsorption(outline query.Outline) query.Outline { + return eliminateRedundantChildren(outline, query.UnionIteratorType, func(y, x query.Outline) bool { + return y.Type == query.ExclusionIteratorType && + len(y.SubOutlines) == 2 && + isSubsumedBy(y.SubOutlines[0], x) + }) +} + +// intersectionIdempotency is an OutlineMutation that collapses structurally +// identical children in an intersection. +// Law: A ∩ A = A +func intersectionIdempotency(outline query.Outline) query.Outline { + return eliminateRedundantChildren( + outline, + query.IntersectionIteratorType, + func(y, x query.Outline) bool { return x.Equals(y) }, + ) +} + +// intersectionAbsorption is an OutlineMutation that drops weaker children from an +// intersection when a stricter sibling is present. +// Law: A ∩ (A ∪ B) = A +// +// A child Y is dropped when some sibling X satisfies X ⊆ Y. The union A ∪ B is a +// weaker constraint than A alone, so intersecting with both is redundant. +// +// This also subsumes the case where two intersection children differ in specificity: +// (A ∩ B) ∩ (A ∩ B ∩ C) = A ∩ B ∩ C, because A ∩ B ∩ C ⊆ A ∩ B. +func intersectionAbsorption(outline query.Outline) query.Outline { + return eliminateRedundantChildren( + outline, + query.IntersectionIteratorType, + func(y, x query.Outline) bool { return isSubsumedBy(x, y) }, + ) +} + +// intersectionComplementAnnihilation is an OutlineMutation that replaces an +// intersection with ∅ when it contains an exclusion child (A − C) and a sibling +// Y where Y ⊆ C. +// Law: Y ∩ (A − C) = ∅ when Y ⊆ C +// +// Elements of A − C are outside C by definition; elements of Y are inside C +// (since Y ⊆ C); the two sets are disjoint so their intersection is empty. +func intersectionComplementAnnihilation(outline query.Outline) query.Outline { + if outline.Type != query.IntersectionIteratorType { + return outline + } + + for i, child := range outline.SubOutlines { + if child.Type != query.ExclusionIteratorType || len(child.SubOutlines) != 2 { + continue + } + subtrahend := child.SubOutlines[1] + for j, sibling := range outline.SubOutlines { + if i == j { + continue + } + if isSubsumedBy(sibling, subtrahend) { + return query.Outline{Type: query.NullIteratorType} + } + } + } + + return outline +} + +// exclusionAnnihilation is an OutlineMutation that replaces X − A with ∅ when +// X ⊆ A — if everything satisfying X also satisfies A, subtracting A removes all of X. +// Law: X − A = ∅ when X ⊆ A +// +// Examples: A − A = ∅, (A ∩ B) − A = ∅, A − (A ∪ B) = ∅. +// +// NullPropagation cascades the resulting null through any parent nodes. +func exclusionAnnihilation(outline query.Outline) query.Outline { + if outline.Type != query.ExclusionIteratorType || len(outline.SubOutlines) != 2 { + return outline + } + if isSubsumedBy(outline.SubOutlines[0], outline.SubOutlines[1]) { + return query.Outline{Type: query.NullIteratorType} + } + return outline +} + +// exclusionLeftPruning is an OutlineMutation that removes union children from the +// left side of an exclusion when they are subsumed by the subtrahend. Elements of +// a subsumed child would be fully removed by the subtraction anyway. +// Law: (A ∪ B) − Y = B − Y when A ⊆ Y +// +// If all union children are pruned, the minuend becomes NullIteratorType and +// NullPropagation converts ∅ − Y = ∅ in the same pass. +func exclusionLeftPruning(outline query.Outline) query.Outline { + if outline.Type != query.ExclusionIteratorType || len(outline.SubOutlines) != 2 { + return outline + } + + left, right := outline.SubOutlines[0], outline.SubOutlines[1] + if left.Type != query.UnionIteratorType { + return outline + } + + var survivors []query.Outline + for _, child := range left.SubOutlines { + if !isSubsumedBy(child, right) { + survivors = append(survivors, child) + } + } + + if len(survivors) == len(left.SubOutlines) { + return outline + } + + var newLeft query.Outline + switch len(survivors) { + case 0: + newLeft = query.Outline{Type: query.NullIteratorType} + case 1: + newLeft = survivors[0] + default: + newLeft = query.Outline{Type: query.UnionIteratorType, SubOutlines: survivors} + } + + return query.Outline{ + Type: query.ExclusionIteratorType, + Args: outline.Args, + SubOutlines: []query.Outline{newLeft, right}, + } +} + +// exclusionNullIdentity is an OutlineMutation that drops the exclusion wrapper +// when the subtrahend is null — subtracting nothing leaves the minuend unchanged. +// Law: A − ∅ = A +// +// NullPropagation handles the symmetric left-null case (∅ − A = ∅). This rule +// lives here rather than in NullPropagation so that reachability pruning, which +// also calls NullPropagation, is not affected. +func exclusionNullIdentity(outline query.Outline) query.Outline { + if outline.Type != query.ExclusionIteratorType || len(outline.SubOutlines) != 2 { + return outline + } + if outline.SubOutlines[1].Type == query.NullIteratorType { + return outline.SubOutlines[0] + } + return outline +} + +// eliminateRedundantChildren removes children from a composite node (union or +// intersection) for which shouldDrop(Y, X) returns true for some surviving sibling X. +// +// The keep-bitmap approach ensures a child already marked for removal cannot also +// become the justification for removing a sibling: in A ∪ A, the first copy marks +// the second for removal without letting the second also mark the first. +// +// A single surviving child is returned unwrapped to avoid a redundant wrapper. +func eliminateRedundantChildren(outline query.Outline, nodeType query.IteratorType, shouldDrop func(y, x query.Outline) bool) query.Outline { + if outline.Type != nodeType || len(outline.SubOutlines) <= 1 { + return outline + } + + children := outline.SubOutlines + keep := slices.Repeat([]bool{true}, len(children)) + anyDropped := false + + for i, y := range children { + for j, x := range children { + if i == j || !keep[j] { + continue + } + if shouldDrop(y, x) { + keep[i] = false + anyDropped = true + break + } + } + } + + if !anyDropped { + return outline + } + + newSubs := make([]query.Outline, 0, len(children)) + for i, child := range children { + if keep[i] { + newSubs = append(newSubs, child) + } + } + + if len(newSubs) == 1 { + // Only one result: return it as a singleton. + return newSubs[0] + } + + return query.Outline{ + Type: nodeType, + Args: outline.Args, + SubOutlines: newSubs, + } +} + +// isSubsumedBy reports whether Y ⊆ X — every element of Y is also an element of X. +// +// Caveats and arrows are treated as opaque: isSubsumedBy never looks inside them, +// so two caveat or arrow nodes are equal only when structurally identical. +// +// Four structural patterns are recognized: +// +// - Equality: X and Y are structurally identical. +// - Union membership: X is a union and Y is one of its direct children. +// Anything satisfying Y trivially satisfies the union, so Y ⊆ X. +// - Exclusion: Y is an exclusion (M − S) whose minuend M satisfies M ⊆ X. +// Since (M − S) ⊆ M for all S, Y ⊆ M ⊆ X. +// - Intersection: Y is an intersection and every factor of X (see +// intersectionFactors) appears among Y's direct children. If Y = X₁ ∩ … ∩ Xₙ ∩ … +// and {X₁, …, Xₙ} are all factors of X, then satisfying Y requires satisfying +// each Xᵢ, so Y ⊆ X. +func isSubsumedBy(y, x query.Outline) bool { + switch { + case x.Equals(y): + return true + + case x.Type == query.UnionIteratorType: + return slices.ContainsFunc(x.SubOutlines, y.Equals) + + case y.Type == query.ExclusionIteratorType && len(y.SubOutlines) == 2: + return isSubsumedBy(y.SubOutlines[0], x) + + case y.Type == query.IntersectionIteratorType: + for _, factor := range intersectionFactors(x) { + if !slices.ContainsFunc(y.SubOutlines, factor.Equals) { + return false + } + } + return true + + default: + return false + } +} + +// intersectionFactors returns the atomic operands of X for subsumption checks. +// For an intersection, the factors are its direct children; otherwise X is its +// own single factor. +// +// factors of Intersection[A, B] = {A, B} +// factors of A = {A} +func intersectionFactors(x query.Outline) []query.Outline { + if x.Type == query.IntersectionIteratorType { + return x.SubOutlines + } + return []query.Outline{x} +} diff --git a/pkg/query/queryopt/set_simplification_test.go b/pkg/query/queryopt/set_simplification_test.go new file mode 100644 index 000000000..63640c42c --- /dev/null +++ b/pkg/query/queryopt/set_simplification_test.go @@ -0,0 +1,576 @@ +package queryopt + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/authzed/spicedb/pkg/query" +) + +// applyAbsorption runs all absorption-family mutations bottom-up over outline. +func applyAbsorption(outline query.Outline) query.Outline { + return query.MutateOutline(outline, []query.OutlineMutation{ + flattenAssociativity, + unionIdempotency, + unionAbsorption, + unionComplementAbsorption, + intersectionIdempotency, + intersectionAbsorption, + intersectionComplementAnnihilation, + exclusionAnnihilation, + exclusionLeftPruning, + exclusionNullIdentity, + query.NullPropagation, + }) +} + +func TestAbsorptionIdempotency(t *testing.T) { + // Leaf nodes used as distinct structural units throughout these tests. + a := dsOutlineForType("document", "viewer", "user", "...") + b := dsOutlineForType("document", "editor", "user", "...") + c := dsOutlineForType("document", "owner", "user", "...") + + t.Run("idempotency: union of two identical children collapses to one", func(t *testing.T) { + // Union[A, A] → A + result := applyAbsorption(unionOutline(a, a)) + require.Equal(t, query.DatastoreIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, a)) + }) + + t.Run("idempotency: removes one duplicate in three-child union", func(t *testing.T) { + // Union[A, A, B] → Union[A, B] + result := applyAbsorption(unionOutline(a, a, b)) + require.Equal(t, query.UnionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 2) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[0], a)) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[1], b)) + }) + + t.Run("absorption: A ∪ (A ∩ B) = A", func(t *testing.T) { + // Union[A, Intersection[A, B]] → A + result := applyAbsorption(unionOutline(a, intersectionOutline(a, b))) + require.Equal(t, query.DatastoreIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, a)) + }) + + t.Run("absorption: (A ∩ B) ∪ A = A — intersection on left", func(t *testing.T) { + // Union[Intersection[A, B], A] → A (same law, intersection child first) + result := applyAbsorption(unionOutline(intersectionOutline(a, b), a)) + require.Equal(t, query.DatastoreIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, a)) + }) + + t.Run("absorption: A ∪ (A ∩ B ∩ C) = A", func(t *testing.T) { + // Union[A, Intersection[A, B, C]] → A + result := applyAbsorption(unionOutline(a, intersectionOutline(a, b, c))) + require.Equal(t, query.DatastoreIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, a)) + }) + + t.Run("generalized absorption: (A ∩ B) ∪ (A ∩ B ∩ C) = A ∩ B", func(t *testing.T) { + // Union[Intersection[A,B], Intersection[A,B,C]] → Intersection[A,B] + ab := intersectionOutline(a, b) + abc := intersectionOutline(a, b, c) + result := applyAbsorption(unionOutline(ab, abc)) + require.Equal(t, query.IntersectionIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, ab)) + }) + + t.Run("multi-child: A ∪ B ∪ (A ∩ B) = A ∪ B", func(t *testing.T) { + // Union[A, B, Intersection[A, B]] → Union[A, B] + result := applyAbsorption(unionOutline(a, b, intersectionOutline(a, b))) + require.Equal(t, query.UnionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 2) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[0], a)) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[1], b)) + }) + + t.Run("no-op: distinct children, nothing to remove", func(t *testing.T) { + // Union[A, B, C] → unchanged + result := applyAbsorption(unionOutline(a, b, c)) + require.Equal(t, query.UnionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 3) + }) + + t.Run("no-op: non-union node is returned unchanged", func(t *testing.T) { + // Intersection[A, B] → unchanged + result := applyAbsorption(intersectionOutline(a, b)) + require.Equal(t, query.IntersectionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 2) + }) + + t.Run("no-op: Union[C, Intersection[A, B]] — C shares no factors with intersection", func(t *testing.T) { + // unionAbsorption checks whether each factor of C appears among + // Intersection[A,B]'s children; C is absent from [A,B] so the + // intersection is not dropped and the union is left unchanged. + result := applyAbsorption(unionOutline(c, intersectionOutline(a, b))) + require.Equal(t, query.UnionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 2) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[0], c)) + require.Equal(t, query.IntersectionIteratorType, result.SubOutlines[1].Type) + }) + + t.Run("nested: flattening + dedup yields flat union via MutateOutline", func(t *testing.T) { + // Union[C, Union[A, A, B]]: inner dedup fires bottom-up (A,A→A), then + // flattenAssociativity inlines the result → Union[C, A, B]. + inner := unionOutline(a, a, b) + outer := unionOutline(c, inner) + result := applyAbsorption(outer) + require.Equal(t, query.UnionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 3) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[0], c)) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[1], a)) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[2], b)) + }) +} + +func TestAbsorptionCaveatHandling(t *testing.T) { + a := dsOutlineForType("document", "viewer", "user", "...") + b := dsOutlineForType("document", "editor", "user", "...") + cavA := caveatOutline("is_admin", a) + + t.Run("A ∪ Caveat(A) is not simplified — structurally distinct", func(t *testing.T) { + // A and Caveat(A) are different structural nodes; no subsumption applies. + result := applyAbsorption(unionOutline(a, cavA)) + require.Equal(t, query.UnionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 2) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[0], a)) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[1], cavA)) + }) + + t.Run("A ∪ (A ∩ Caveat(B)) = A — A appears as direct factor in intersection", func(t *testing.T) { + // Everything satisfying (A ∩ Caveat(B)) also satisfies A. + result := applyAbsorption(unionOutline(a, intersectionOutline(a, caveatOutline("some_caveat", b)))) + require.Equal(t, query.DatastoreIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, a)) + }) + + t.Run("Caveat(A) ∪ (Caveat(A) ∩ B) = Caveat(A)", func(t *testing.T) { + // Caveat(A) appears as a direct factor in Intersection[Caveat(A), B]. + result := applyAbsorption(unionOutline(cavA, intersectionOutline(cavA, b))) + require.Equal(t, query.CaveatIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, cavA)) + }) +} + +func TestAbsorptionArrowHandling(t *testing.T) { + a := dsOutlineForType("document", "viewer", "user", "...") + b := dsOutlineForType("document", "editor", "user", "...") + c := dsOutlineForType("document", "owner", "user", "...") + arrowAB := arrowOutline(a, b) + arrowAC := arrowOutline(a, c) + + t.Run("(A->B) ∪ (A->B) = A->B — idempotency on arrows", func(t *testing.T) { + result := applyAbsorption(unionOutline(arrowAB, arrowAB)) + require.Equal(t, query.ArrowIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, arrowAB)) + }) + + t.Run("(A->B) ∪ (A->B ∩ C) = A->B — arrow as opaque factor in intersection", func(t *testing.T) { + result := applyAbsorption(unionOutline(arrowAB, intersectionOutline(arrowAB, c))) + require.Equal(t, query.ArrowIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, arrowAB)) + }) + + t.Run("(A->B) ∪ (A->C) is not simplified — distinct arrows", func(t *testing.T) { + result := applyAbsorption(unionOutline(arrowAB, arrowAC)) + require.Equal(t, query.UnionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 2) + }) + + t.Run("(A=>B) ∪ (A=>B ∩ C) = A=>B — intersection arrow as opaque factor", func(t *testing.T) { + iArrowAB := intersectionArrowOutline(a, b) + result := applyAbsorption(unionOutline(iArrowAB, intersectionOutline(iArrowAB, c))) + require.Equal(t, query.IntersectionArrowIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, iArrowAB)) + }) +} + +func TestAbsorptionRegistered(t *testing.T) { + a := dsOutlineForType("document", "viewer", "user", "...") + b := dsOutlineForType("document", "editor", "user", "...") + + t.Run("optimizer is registered and applies via ApplyOptimizations", func(t *testing.T) { + // Union[A, Intersection[A, B]] should reduce to A via the named optimizer. + input := unionOutline(a, intersectionOutline(a, b)) + co := canonicalize(input) + + opt, err := GetOptimization("set-simplification") + require.NoError(t, err) + result, err := ApplyOptimizations(co, []Optimizer{opt}, RequestParams{}) + require.NoError(t, err) + require.Equal(t, query.DatastoreIteratorType, result.Root.Type) + require.Equal(t, 0, query.OutlineCompare(result.Root, a)) + }) +} + +func TestComplementAbsorption(t *testing.T) { + a := dsOutlineForType("document", "viewer", "user", "...") + b := dsOutlineForType("document", "editor", "user", "...") + c := dsOutlineForType("document", "owner", "user", "...") + + t.Run("A ∪ (A − B) = A", func(t *testing.T) { + // (A−B) ⊆ A, so A already subsumes the exclusion. + result := applyAbsorption(unionOutline(a, exclusionOutline(a, b))) + require.Equal(t, query.DatastoreIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, a)) + }) + + t.Run("(A − B) ∪ A = A — exclusion on left", func(t *testing.T) { + // Same law with operands reversed; order must not matter. + result := applyAbsorption(unionOutline(exclusionOutline(a, b), a)) + require.Equal(t, query.DatastoreIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, a)) + }) + + t.Run("(A ∩ B) ∪ ((A ∩ B) − C) = A ∩ B", func(t *testing.T) { + // Minuend is an intersection; works identically — (A∩B)−C ⊆ A∩B. + ab := intersectionOutline(a, b) + result := applyAbsorption(unionOutline(ab, exclusionOutline(ab, c))) + require.Equal(t, query.IntersectionIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, ab)) + }) + + t.Run("A ∪ B ∪ (A − C) = A ∪ B", func(t *testing.T) { + // A subsumes (A−C); B is unaffected; result is a 2-child union. + result := applyAbsorption(unionOutline(a, b, exclusionOutline(a, c))) + require.Equal(t, query.UnionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 2) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[0], a)) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[1], b)) + }) + + t.Run("A ∪ (B − C) is not simplified — B is not subsumed by A", func(t *testing.T) { + // B's minuend is B, not A; no subsumption. + result := applyAbsorption(unionOutline(a, exclusionOutline(b, c))) + require.Equal(t, query.UnionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 2) + }) + + t.Run("(A − B) ∪ (A − C) is not simplified — neither exclusion subsumes the other", func(t *testing.T) { + // Both (A−B) and (A−C) have minuend A; A−C ⊆ A−B is not generally true, + // but A subsumes both minuends, so if A also appears as a union sibling the + // rule fires. Without a bare A sibling, neither exclusion subsumes the other. + // This test confirms no simplification occurs when no bare A is present. + result := applyAbsorption(unionOutline(exclusionOutline(a, b), exclusionOutline(a, c))) + require.Equal(t, query.UnionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 2) + }) +} + +func TestExclusionAnnihilation(t *testing.T) { + a := dsOutlineForType("document", "viewer", "user", "...") + b := dsOutlineForType("document", "editor", "user", "...") + c := dsOutlineForType("document", "owner", "user", "...") + + t.Run("A − A = ∅ — self case (structural equality)", func(t *testing.T) { + result := applyAbsorption(exclusionOutline(a, a)) + require.Equal(t, query.NullIteratorType, result.Type) + }) + + t.Run("(A ∩ B) − (A ∩ B) = ∅ — compound minuend and subtrahend", func(t *testing.T) { + ab := intersectionOutline(a, b) + result := applyAbsorption(exclusionOutline(ab, ab)) + require.Equal(t, query.NullIteratorType, result.Type) + }) + + t.Run("(A ∩ B) − A = ∅ — minuend is a strict subset of subtrahend", func(t *testing.T) { + // A ∩ B ⊆ A, so subtracting A removes everything. + result := applyAbsorption(exclusionOutline(intersectionOutline(a, b), a)) + require.Equal(t, query.NullIteratorType, result.Type) + }) + + t.Run("(A − B) − A = ∅ — complement-absorption: A−B ⊆ A", func(t *testing.T) { + // A−B ⊆ A for all B, so subtracting A removes everything. + result := applyAbsorption(exclusionOutline(exclusionOutline(a, b), a)) + require.Equal(t, query.NullIteratorType, result.Type) + }) + + t.Run("A − (A ∪ B) = ∅ — minuend is a child of subtrahend union", func(t *testing.T) { + // A ⊆ (A ∪ B), so subtracting A ∪ B removes everything. + result := applyAbsorption(exclusionOutline(a, unionOutline(a, b))) + require.Equal(t, query.NullIteratorType, result.Type) + }) + + t.Run("A − B is not simplified — structurally distinct operands", func(t *testing.T) { + result := applyAbsorption(exclusionOutline(a, b)) + require.Equal(t, query.ExclusionIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[0], a)) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[1], b)) + }) + + t.Run("no-op: (A ∩ B) − C — intersection minuend is not a subset of C", func(t *testing.T) { + // isSubsumedBy(A∩B, C): y is an intersection, intersectionFactors(C)=[C], + // C is not among [A,B] → returns false; the exclusion is left unchanged. + result := applyAbsorption(exclusionOutline(intersectionOutline(a, b), c)) + require.Equal(t, query.ExclusionIteratorType, result.Type) + require.Equal(t, query.IntersectionIteratorType, result.SubOutlines[0].Type) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[1], c)) + }) + + t.Run("null propagates to parent intersection: B ∩ (A − A) = ∅", func(t *testing.T) { + // After A−A becomes Null, NullPropagation converts B ∩ Null to Null. + result := applyAbsorption(intersectionOutline(b, exclusionOutline(a, a))) + require.Equal(t, query.NullIteratorType, result.Type) + }) + + t.Run("Union[A, (A − A)] leaves Union[A, Null] — null-in-union cleanup is out of scope", func(t *testing.T) { + // exclusionAnnihilation turns (A−A) into Null bottom-up before the parent + // Union is visited. NullPropagation only collapses a Union to Null when ALL + // children are null, so Union[A, Null] remains. The runtime union iterator + // evaluates both branches and unions the results; the null branch contributes + // nothing, so A ∪ ∅ = A is semantically correct at runtime. + result := applyAbsorption(unionOutline(b, exclusionOutline(a, a))) + require.Equal(t, query.UnionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 2) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[0], b)) + require.Equal(t, query.NullIteratorType, result.SubOutlines[1].Type) + }) +} + +func TestIntersectionIdempotencyAbsorption(t *testing.T) { + a := dsOutlineForType("document", "viewer", "user", "...") + b := dsOutlineForType("document", "editor", "user", "...") + c := dsOutlineForType("document", "owner", "user", "...") + + t.Run("idempotency: A ∩ A = A", func(t *testing.T) { + result := applyAbsorption(intersectionOutline(a, a)) + require.Equal(t, query.DatastoreIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, a)) + }) + + t.Run("idempotency: removes one duplicate in three-child intersection", func(t *testing.T) { + // A ∩ A ∩ B → A ∩ B + result := applyAbsorption(intersectionOutline(a, a, b)) + require.Equal(t, query.IntersectionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 2) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[0], a)) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[1], b)) + }) + + t.Run("absorption: A ∩ (A ∪ B) = A", func(t *testing.T) { + // (A ∪ B) is a weaker constraint than A alone; drop the union. + result := applyAbsorption(intersectionOutline(a, unionOutline(a, b))) + require.Equal(t, query.DatastoreIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, a)) + }) + + t.Run("absorption: (A ∪ B) ∩ A = A — union on left", func(t *testing.T) { + // Same law with operands swapped; order must not matter. + result := applyAbsorption(intersectionOutline(unionOutline(a, b), a)) + require.Equal(t, query.DatastoreIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, a)) + }) + + t.Run("multi-child: A ∩ B ∩ (A ∪ B) = A ∩ B", func(t *testing.T) { + // Both A and B subsume the union; drop the union, keep A and B. + result := applyAbsorption(intersectionOutline(a, b, unionOutline(a, b))) + require.Equal(t, query.IntersectionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 2) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[0], a)) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[1], b)) + }) + + t.Run("no-op: distinct children, nothing to remove", func(t *testing.T) { + result := applyAbsorption(intersectionOutline(a, b, c)) + require.Equal(t, query.IntersectionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 3) + }) + + t.Run("no-op: A ∩ (B ∪ C) — A does not appear in the union", func(t *testing.T) { + // A is not a child of (B ∪ C), so no simplification. + result := applyAbsorption(intersectionOutline(a, unionOutline(b, c))) + require.Equal(t, query.IntersectionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 2) + }) +} + +func TestFlattenAssociativity(t *testing.T) { + a := dsOutlineForType("document", "viewer", "user", "...") + b := dsOutlineForType("document", "editor", "user", "...") + c := dsOutlineForType("document", "owner", "user", "...") + + t.Run("Union[A, Union[B, C]] flattens to Union[A, B, C]", func(t *testing.T) { + result := applyAbsorption(unionOutline(a, unionOutline(b, c))) + require.Equal(t, query.UnionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 3) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[0], a)) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[1], b)) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[2], c)) + }) + + t.Run("Union[Union[A, B], C] flattens to Union[A, B, C]", func(t *testing.T) { + result := applyAbsorption(unionOutline(unionOutline(a, b), c)) + require.Equal(t, query.UnionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 3) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[0], a)) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[1], b)) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[2], c)) + }) + + t.Run("Intersection[A, Intersection[B, C]] flattens to Intersection[A, B, C]", func(t *testing.T) { + result := applyAbsorption(intersectionOutline(a, intersectionOutline(b, c))) + require.Equal(t, query.IntersectionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 3) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[0], a)) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[1], b)) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[2], c)) + }) + + t.Run("Union[A, Union[A∩B, C]] flattens then absorbs to Union[A, C]", func(t *testing.T) { + // Without flattening, A and A∩B are not peers so absorption cannot fire. + // After flattening to Union[A, A∩B, C], absorptionIdempotency drops A∩B. + result := applyAbsorption(unionOutline(a, unionOutline(intersectionOutline(a, b), c))) + require.Equal(t, query.UnionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 2) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[0], a)) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[1], c)) + }) + + t.Run("no-op: already-flat union is unchanged", func(t *testing.T) { + result := applyAbsorption(unionOutline(a, b, c)) + require.Equal(t, query.UnionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 3) + }) + + t.Run("no-op: union nested inside intersection is not flattened into it", func(t *testing.T) { + // Union children are not inlined into a parent Intersection. + result := applyAbsorption(intersectionOutline(a, unionOutline(b, c))) + require.Equal(t, query.IntersectionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 2) + }) +} + +func TestExclusionNullIdentity(t *testing.T) { + a := dsOutlineForType("document", "viewer", "user", "...") + b := dsOutlineForType("document", "editor", "user", "...") + null := query.Outline{Type: query.NullIteratorType} + + t.Run("A − ∅ = A — right-null exclusion reduces to left child", func(t *testing.T) { + result := applyAbsorption(exclusionOutline(a, null)) + require.Equal(t, query.DatastoreIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, a)) + }) + + t.Run("(A ∩ B) − ∅ = A ∩ B — compound left child preserved", func(t *testing.T) { + ab := intersectionOutline(a, b) + result := applyAbsorption(exclusionOutline(ab, null)) + require.Equal(t, query.IntersectionIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, ab)) + }) + + t.Run("∅ − A = ∅ — left-null case is unchanged (already covered by NullPropagation)", func(t *testing.T) { + result := applyAbsorption(exclusionOutline(null, a)) + require.Equal(t, query.NullIteratorType, result.Type) + }) +} + +func TestExclusionLeftPruning(t *testing.T) { + a := dsOutlineForType("document", "viewer", "user", "...") + b := dsOutlineForType("document", "editor", "user", "...") + c := dsOutlineForType("document", "owner", "user", "...") + + t.Run("(A ∪ B) − A = B − A — subsumed child pruned from union", func(t *testing.T) { + result := applyAbsorption(exclusionOutline(unionOutline(a, b), a)) + require.Equal(t, query.ExclusionIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[0], b)) + require.Equal(t, 0, query.OutlineCompare(result.SubOutlines[1], a)) + }) + + t.Run("(A ∪ B ∪ C) − A = (B ∪ C) − A — one of three children pruned", func(t *testing.T) { + result := applyAbsorption(exclusionOutline(unionOutline(a, b, c), a)) + require.Equal(t, query.ExclusionIteratorType, result.Type) + left := result.SubOutlines[0] + require.Equal(t, query.UnionIteratorType, left.Type) + require.Len(t, left.SubOutlines, 2) + require.Equal(t, 0, query.OutlineCompare(left.SubOutlines[0], b)) + require.Equal(t, 0, query.OutlineCompare(left.SubOutlines[1], c)) + }) + + t.Run("(A ∪ B) − (A ∪ B) = ∅ — all children pruned annihilates", func(t *testing.T) { + y := unionOutline(a, b) + result := applyAbsorption(exclusionOutline(unionOutline(a, b), y)) + require.Equal(t, query.NullIteratorType, result.Type) + }) + + t.Run("(A ∪ B) − (A ∪ B ∪ C) = ∅ — all union children subsumed by superset union", func(t *testing.T) { + // exclusionAnnihilation does not fire: isSubsumedBy(Union[A,B], Union[A,B,C]) + // checks direct membership — Union[A,B] is not a child of [A,B,C]. But A and B + // individually are children of Union[A,B,C], so both are pruned by + // exclusionLeftPruning (case 0), leaving Null which propagates. + result := applyAbsorption(exclusionOutline(unionOutline(a, b), unionOutline(a, b, c))) + require.Equal(t, query.NullIteratorType, result.Type) + }) + + t.Run("no-op: (A ∪ B) − C — neither A nor B is subsumed by C", func(t *testing.T) { + result := applyAbsorption(exclusionOutline(unionOutline(a, b), c)) + require.Equal(t, query.ExclusionIteratorType, result.Type) + require.Equal(t, query.UnionIteratorType, result.SubOutlines[0].Type) + require.Len(t, result.SubOutlines[0].SubOutlines, 2) + }) + + t.Run("no-op: non-union left child is untouched", func(t *testing.T) { + result := applyAbsorption(exclusionOutline(a, a)) + // exclusionAnnihilation fires first (A − A = ∅), not exclusionLeftPruning + require.Equal(t, query.NullIteratorType, result.Type) + }) +} + +func TestFlattenAssociativitySingleSurvivor(t *testing.T) { + a := dsOutlineForType("document", "viewer", "user", "...") + + t.Run("Union[Union[A]] unwraps to A — single child survives inlining", func(t *testing.T) { + // flattenAssociativity inlines the inner Union[A] into the outer Union, + // leaving exactly one child; the len==1 branch unwraps it directly. + result := applyAbsorption(unionOutline(unionOutline(a))) + require.Equal(t, query.DatastoreIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, a)) + }) + + t.Run("Intersection[Intersection[A]] unwraps to A — single child survives inlining", func(t *testing.T) { + result := applyAbsorption(intersectionOutline(intersectionOutline(a))) + require.Equal(t, query.DatastoreIteratorType, result.Type) + require.Equal(t, 0, query.OutlineCompare(result, a)) + }) +} + +func TestIntersectionComplementAnnihilation(t *testing.T) { + a := dsOutlineForType("document", "viewer", "user", "...") + b := dsOutlineForType("document", "editor", "user", "...") + c := dsOutlineForType("document", "owner", "user", "...") + + t.Run("A ∩ (B − A) = ∅ — sibling equals subtrahend", func(t *testing.T) { + result := applyAbsorption(intersectionOutline(a, exclusionOutline(b, a))) + require.Equal(t, query.NullIteratorType, result.Type) + }) + + t.Run("(B − A) ∩ A = ∅ — exclusion child first", func(t *testing.T) { + result := applyAbsorption(intersectionOutline(exclusionOutline(b, a), a)) + require.Equal(t, query.NullIteratorType, result.Type) + }) + + t.Run("A ∩ B ∩ (C − A) = ∅ — sibling A ⊆ A (subtrahend of exclusion)", func(t *testing.T) { + result := applyAbsorption(intersectionOutline(a, b, exclusionOutline(c, a))) + require.Equal(t, query.NullIteratorType, result.Type) + }) + + t.Run("(A ∩ B) ∩ (C − (A ∪ D)) = ∅ — sibling A ⊆ A ∪ D", func(t *testing.T) { + d := dsOutlineForType("document", "banned", "user", "...") + subtrahend := unionOutline(a, d) + result := applyAbsorption(intersectionOutline(intersectionOutline(a, b), exclusionOutline(c, subtrahend))) + require.Equal(t, query.NullIteratorType, result.Type) + }) + + t.Run("no-op: A ∩ (B − C) — A is not subsumed by C", func(t *testing.T) { + result := applyAbsorption(intersectionOutline(a, exclusionOutline(b, c))) + require.Equal(t, query.IntersectionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 2) + }) + + t.Run("no-op: intersection with no exclusion child", func(t *testing.T) { + result := applyAbsorption(intersectionOutline(a, b)) + require.Equal(t, query.IntersectionIteratorType, result.Type) + require.Len(t, result.SubOutlines, 2) + }) +}