Skip to content

Commit 6f17f7e

Browse files
ehildenbclaude
andauthored
Return MatchIndeterminate (not MatchFailed) for mixed-determinacy matches in Eval and Implies modes (#4152)
The booster matcher's job is to return the truth — terms either *definitely* cannot match (`MatchFailed`) or *cannot be decided* without further simplification (`MatchIndeterminate`) — and leave it to the caller to decide what to do with an indeterminate verdict. In `Eval` and `Implies` modes the matcher previously short-circuited to a decisive `MatchFailed` in several cases where the two terms could in fact simplify or instantiate to something equivalent, so the deferral never reached the caller. The unifying principle applied here: a decisive `MatchFailed` is sound only when the mismatch is between *rigid* shapes or sorts that cannot change under evaluation or instantiation; every other mismatch must defer. This matters most for function-equation evaluation: `handleFunctionEquation` routes `FailedMatch _ -> continue` but `IndeterminateMatch{} -> abort`. A spurious decisive `MatchFailed` caused the evaluator to silently skip a higher-priority equation and commit to a lower-priority catch-all, violating the priority contract function equations rely on. For `Implies`, the indeterminate verdict lets the existing simplify-LHS / simplify-RHS retry ladder in `Pattern.Implies` attempt the discharge and report a residual non-match as `indeterminate` rather than a decisive `valid:false`. Simplification behaviour is unchanged — `handleSimplificationEquation` routes both verdicts to `continue`. **Behavioural changes (each previously a decisive `MatchFailed`, now `addIndeterminate`):** - `bindVariable`: a pattern variable rebinding to two terms that are not both constructor-like (e.g. a domain value and a function application), in all modes — matching what `Rewrite` already did. - `match1`: a `FunctionApplication` pattern against a concrete structured subject (`DomainValue`/`Injection`/`KMap`/`KList`/`KSet`) in `Eval` — falls through to the shared catch-all. - `match1`: an `Injection` pattern against a builtin collection (`KMap`/`KList`/`KSet`) in `Eval`, making both directions of this pair indeterminate (the reverse direction already was). - `matchVar`: a pattern variable against a subject whose static sort is not a subsort of the variable's, when the subject is a `FunctionApplication` or `Var` and the two sorts share a subsort (the subject can still narrow). Sort-disjoint pairs and rigid subjects keep the decisive `DifferentSorts` failure. - `matchInj`: an injection-vs-injection pair with differing sources where a child on the wider-sorted side can narrow (a subject variable child, or a pattern function child). Rigid children at incompatible sorts keep the decisive failure. **Refactor (no behaviour change):** - `match1` is inverted to defer-by-default: the table now ends in one generic `addIndeterminate` fall-through — the always-sound outcome — and every row above it justifies something stronger (\and decomposition, variable binding, same-category descent, or decisive failure). The decisive cross-category rule is a single guarded row over a new `isRigidCategory` predicate, making the rigidity principle executable rather than commentary. The table shrinks from ~108 rows to 18. `bindVariable` and `matchInj` no longer inspect their now-dead `MatchType` argument. **Tests:** - Matcher-level cases pinning the post-fix contract in `MatchEval`, `MatchImplies`, and `MatchRewrite`, plus a paired function/simplification soundness-regression test in `ApplyEquations` (`test_soundnessGap`) showing a high-priority indeterminate match correctly aborts function-equation evaluation rather than falling through to a lower-priority rule. - A new exhaustive dispatch-class grid test (`MatchDispatch`) pinning the result class (`S`/`F`/`I`) of all 3 × 9 × 9 = 243 (mode, pattern-constructor, subject-constructor) combinations, so any future `match1` change surfaces as a reviewable grid-cell diff. **Validation:** - Full booster unit-test suite passes (979 tests). The behavioural tests were authored to fail against the pre-fix matcher and turn green only with the `Match.hs` changes applied; the `MatchDispatch` grid was first pinned on `master` and shows exactly the eight `Eval` cells (`Injection`-vs-collection and `FunctionApplication`-vs-rigid) that this branch flips from `F` to `I`. - Tested downstream on KMIR, KEVM, and Kontrol and saw that it does not cause any proofs to fail or performance regressions. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent eb2da06 commit 6f17f7e

6 files changed

Lines changed: 425 additions & 151 deletions

File tree

booster/library/Booster/Pattern/Match.hs

Lines changed: 143 additions & 137 deletions
Large diffs are not rendered by default.

booster/unit-tests/Test/Booster/Pattern/ApplyEquations.hs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ module Test.Booster.Pattern.ApplyEquations (
1414
test_simplifyConstraint,
1515
test_localFixpoint,
1616
test_errors,
17+
test_soundnessGap,
1718
) where
1819

1920
import Control.Exception (finally)
@@ -320,6 +321,43 @@ test_errors =
320321
isLoop _ (Left err) = assertFailure $ "Unexpected error " <> show err
321322
isLoop _ (Right r) = assertFailure $ "Unexpected result " <> show r
322323

324+
{- | Soundness regression test for 'Eval' mode of 'matchTerms': when a
325+
pattern variable rebinds to two terms that are not both constructor-like
326+
(e.g. a domain value and a function application), the matcher must
327+
return @MatchIndeterminate@, which routes through
328+
@IndeterminateMatch{} -> abort@ in 'handleFunctionEquation' and leaves
329+
the term unchanged so the caller can decide what to do.
330+
331+
Before this was fixed, the matcher returned a decisive
332+
@MatchFailed VariableConflict@, which 'handleFunctionEquation' routes to
333+
@continue@ — silently skipping a higher-priority equation and committing
334+
to a lower-priority catch-all. Because function-equation priorities are
335+
semantically binding, that violated the priority contract.
336+
337+
The simplification companion is the dual: simplification priorities are
338+
advisory, so both @FailedMatch@ and @IndeterminateMatch@ route to
339+
@continue@ and the behaviour is unchanged. The companion is included to
340+
pin that simplifications are unaffected by the fix.
341+
-}
342+
test_soundnessGap :: TestTree
343+
test_soundnessGap =
344+
testGroup
345+
"Eval matcher soundness: mixed-determinacy rebind"
346+
[ testCase
347+
"Function equations: high-priority indeterminate match aborts, lower-priority rule NOT tried"
348+
$ do
349+
let subj = [trm| f1{}(con3{}(\dv{SomeSort{}}("a"), f2{}(\dv{SomeSort{}}("x")))) |]
350+
ns <- noSolver
351+
runNoLoggingT (fst <$> evaluateTerm TopDown soundnessGapFunDef Nothing ns mempty mempty subj)
352+
@?>>= Right subj
353+
, testCase "Simplifications: high-priority indeterminate match continues, lower-priority rule fires" $ do
354+
let subj = [trm| f1{}(con3{}(\dv{SomeSort{}}("a"), f2{}(\dv{SomeSort{}}("x")))) |]
355+
result = [trm| con2{}(con3{}(\dv{SomeSort{}}("a"), f2{}(\dv{SomeSort{}}("x")))) |]
356+
ns <- noSolver
357+
runNoLoggingT (fst <$> evaluateTerm TopDown soundnessGapSimplDef Nothing ns mempty mempty subj)
358+
@?>>= Right result
359+
]
360+
323361
----------------------------------------
324362

325363
index :: (ByteString -> CellIndex) -> SymbolName -> TermIndex
@@ -399,6 +437,47 @@ loopDef =
399437
]
400438
}
401439

440+
{- | Rules used by 'test_soundnessGap'. Both definitions hold the same
441+
two equations on @f1@:
442+
443+
* Priority 40: @f1(con3(X, X)) = con1(X)@ — narrow pattern that requires
444+
the two arguments of @con3@ to be the same.
445+
* Priority 50: @f1(X) = con2(X)@ — catch-all.
446+
447+
The test subject is @f1(con3(\\dv "a", f2(\\dv "x")))@: when matching
448+
the priority-40 rule's LHS, the variable @X@ is bound first to
449+
@\\dv "a"@ (constructor-like) and then to @f2(\\dv "x")@ (a
450+
'FunctionApplication', not constructor-like). 'Match.bindVariable'
451+
returns @MatchIndeterminate@ for this mixed-determinacy rebind (it
452+
returned a decisive @MatchFailed VariableConflict@ in 'Eval' mode
453+
before this was fixed).
454+
-}
455+
soundnessGapRules :: [RewriteRule t]
456+
soundnessGapRules =
457+
[ equation
458+
(Just "soundness-gap-pri40")
459+
[trm| f1{}(con3{}(X:SomeSort{}, X:SomeSort{})) |]
460+
[trm| con1{}(X:SomeSort{}) |]
461+
40
462+
, equation
463+
(Just "soundness-gap-pri50")
464+
[trm| f1{}(X:SomeSort{}) |]
465+
[trm| con2{}(X:SomeSort{}) |]
466+
50
467+
]
468+
469+
soundnessGapFunDef, soundnessGapSimplDef :: KoreDefinition
470+
soundnessGapFunDef =
471+
testDefinition
472+
{ functionEquations =
473+
mkTheory [(index IdxFun "f1", soundnessGapRules)]
474+
}
475+
soundnessGapSimplDef =
476+
testDefinition
477+
{ simplifications =
478+
mkTheory [(index IdxFun "f1", soundnessGapRules)]
479+
}
480+
402481
f1Equations, f2Equations :: [RewriteRule t]
403482
f1Equations =
404483
[ equation -- f1(con1(X)) == con2(f1(X))

booster/unit-tests/Test/Booster/Pattern/MatchDispatch.hs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,12 @@ grid Eval =
108108
-- And DV Inj Map List Set Cons Fun Var
109109
[ [I, S, S, F, F, F, S, S, I] -- AndTerm
110110
, [I, S, F, F, F, F, F, I, I] -- DomainValue
111-
, [I, F, S, F, F, F, F, I, I] -- Injection
111+
, [I, F, S, I, I, I, F, I, I] -- Injection
112112
, [I, F, I, S, F, F, F, I, I] -- KMap
113113
, [I, F, I, F, S, F, F, I, I] -- KList
114114
, [I, F, I, F, F, S, F, I, I] -- KSet
115115
, [I, F, F, F, F, F, S, I, I] -- ConsApplication
116-
, [I, F, F, F, F, F, I, S, I] -- FunctionApplication
116+
, [I, I, I, I, I, I, I, S, I] -- FunctionApplication
117117
, [I, S, S, F, F, F, S, S, S] -- Var
118118
]
119119
grid Implies =

booster/unit-tests/Test/Booster/Pattern/MatchEval.hs

Lines changed: 137 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ test_match_eval =
3030
, composite
3131
, kmapTerms
3232
, internalSets
33+
, variableRebindMixedDeterminacy
34+
, injectionChildNarrowing
3335
]
3436

3537
symbols :: TestTree
@@ -66,10 +68,6 @@ symbols =
6668
subj = app con1 [d]
6769
in test "same constructor, different argument sorts" pat subj $
6870
failed (DifferentSorts x d)
69-
, let pat = app f1 [var "X" someSort]
70-
subj = dv someSort "something"
71-
in test "function and something else" pat subj $
72-
failed (DifferentSymbols pat subj)
7371
]
7472

7573
composite :: TestTree
@@ -97,8 +95,8 @@ composite =
9795
b = var "B" someSort
9896
pat = app con3 [var "X" someSort, var "X" someSort] -- same!
9997
subj = app con3 [a, b]
100-
in test "Matching two constructor argument to be the same (failing)" pat subj $
101-
failed (VariableConflict (Variable someSort "X") a b)
98+
in test "Matching two constructor argument to be the same (indeterminate)" pat subj $
99+
remainderWith [("X", someSort, a)] [(a, b)]
102100
]
103101

104102
varsAndValues :: TestTree
@@ -115,8 +113,20 @@ varsAndValues =
115113
success [("X", someSort, inj aSubsort someSort v2)]
116114
, let v1 = var "X" aSubsort
117115
v2 = var "Y" someSort
118-
in test "two variables (v1 subsort v2)" v1 v2 $
116+
in test "two variables (v1 subsort v2): indeterminate, Y may narrow" v1 v2 $
117+
remainder [(v1, v2)]
118+
, let v1 = var "X" aSubsort
119+
v2 = var "Y" differentSort
120+
in test "two variables (disjoint sorts): fail" v1 v2 $
119121
failed (DifferentSorts v1 v2)
122+
, let v1 = var "X" aSubsort
123+
f = app f1 [dv someSort "y"]
124+
in test "var against function call of wider sort: indeterminate, result may narrow" v1 f $
125+
remainder [(v1, f)]
126+
, let v1 = var "X" differentSort
127+
f = app f1 [dv someSort "y"]
128+
in test "var against function call of disjoint sort: fail" v1 f $
129+
failed (DifferentSorts v1 f)
120130
, let v1 = var "X" someSort
121131
v2 = var "X" differentSort
122132
in test "same variable name, different sort" v1 v2 $
@@ -306,6 +316,109 @@ internalSets =
306316
(success [])
307317
]
308318

319+
{- | When a pattern variable is bound first to one term and then to
320+
another where the two terms are not both constructor-like (e.g. a
321+
domain value and a function application), the verdict must be
322+
'MatchIndeterminate', because the function application could simplify
323+
into the constructor-like term.
324+
325+
A decisive 'MatchFailed VariableConflict' here would be a soundness gap
326+
for function-equation priorities: 'handleFunctionEquation'
327+
(Pattern.ApplyEquations) routes @FailedMatch _@ to @continue@ but
328+
@IndeterminateMatch{}@ to @abort@, so a spurious failure silently skips
329+
a higher-priority equation and commits to a lower-priority one. The
330+
tests below pin both orderings of the rebind.
331+
332+
The companion soundness regression test lives in
333+
"Test.Booster.Pattern.ApplyEquations.test_soundnessGap".
334+
-}
335+
variableRebindMixedDeterminacy :: TestTree
336+
variableRebindMixedDeterminacy =
337+
testGroup
338+
"Variable rebinding with mixed-determinacy subject"
339+
[ let d = dv someSort "1"
340+
fnApp = app f1 [dv someSort "x"]
341+
t1 = app con3 [var "X" someSort, var "X" someSort]
342+
t2 = app con3 [d, fnApp]
343+
in test
344+
"Rebind X to a domain value then to a function application is indeterminate"
345+
t1
346+
t2
347+
(remainderWith [("X", someSort, d)] [(d, fnApp)])
348+
, let d = dv someSort "1"
349+
fnApp = app f1 [dv someSort "x"]
350+
t1 = app con3 [var "X" someSort, var "X" someSort]
351+
t2 = app con3 [fnApp, d]
352+
in test
353+
"Rebind X to a function application then to a domain value is indeterminate"
354+
t1
355+
t2
356+
(remainderWith [("X", someSort, fnApp)] [(fnApp, d)])
357+
]
358+
359+
{- | Two injections with the same target but different source sorts can
360+
only be decisively distinguished when neither child can change sort: a
361+
function-application child may evaluate to a term of a narrower sort,
362+
and a variable child may be instantiated with one. Whenever the
363+
narrowable child sits on the wider-sorted side, the verdict is
364+
'MatchIndeterminate'; only rigid children at incompatible sorts fail
365+
decisively.
366+
-}
367+
injectionChildNarrowing :: TestTree
368+
injectionChildNarrowing =
369+
let dSub = dv aSubsort "x"
370+
dSome = dv someSort "y"
371+
varSome = var "Y" someSort
372+
varSub = var "Z" aSubsort
373+
fnSome = app f1 [dSome]
374+
in testGroup
375+
"Injection children that may narrow"
376+
[ test
377+
"subject variable child of wider sort is indeterminate"
378+
(Injection aSubsort kItemSort dSub)
379+
(Injection someSort kItemSort varSome)
380+
(remainder [(dSub, varSome)])
381+
, test
382+
"subject function child of wider sort is indeterminate"
383+
(Injection aSubsort kItemSort dSub)
384+
(Injection someSort kItemSort fnSome)
385+
(remainder [(dSub, fnSome)])
386+
, test
387+
"pattern function child of wider sort is indeterminate"
388+
(Injection someSort kItemSort fnSome)
389+
(Injection aSubsort kItemSort dSub)
390+
(remainder [(fnSome, dSub)])
391+
, test
392+
"rigid children at incompatible sorts fail"
393+
(Injection aSubsort kItemSort dSub)
394+
(Injection someSort kItemSort dSome)
395+
( failed $
396+
DifferentSorts
397+
(Injection aSubsort kItemSort dSub)
398+
(Injection someSort kItemSort dSome)
399+
)
400+
, -- Mirror of the wider-sort cases above, but with the non-rigid
401+
-- child on the *narrower-sorted* side. Here no evaluation or
402+
-- instantiation can bridge the sorts: normalising the narrower
403+
-- injection wraps the child in an inj{aSubsort -> someSort}(...),
404+
-- which the rigid wider-sorted pattern child (a domain value) can
405+
-- never equal, whatever the variable resolves to. So the decisive
406+
-- failure is kept rather than deferred. This pins the regression
407+
-- behind keeping matchInj's catch-all decisive: broadening it to
408+
-- addIndeterminate on any non-rigid child made a KEVM execution
409+
-- abort instead of branch (rpc-integration test-3934-smt, where a
410+
-- subject function child of a narrower sort took this same path).
411+
test
412+
"subject variable child of narrower sort fails"
413+
(Injection someSort kItemSort dSome)
414+
(Injection aSubsort kItemSort varSub)
415+
( failed $
416+
DifferentSorts
417+
(Injection someSort kItemSort dSome)
418+
(Injection aSubsort kItemSort varSub)
419+
)
420+
]
421+
309422
----------------------------------------
310423

311424
test :: String -> Term -> Term -> MatchResult -> TestTree
@@ -323,6 +436,23 @@ success assocs =
323436
failed :: FailReason -> MatchResult
324437
failed = MatchFailed
325438

439+
remainder :: [(Term, Term)] -> MatchResult
440+
remainder = MatchIndeterminate mempty . NE.fromList
441+
442+
{- | Like 'remainder' but also asserts a non-empty partial substitution
443+
from pairs that the matcher resolved before reaching the indeterminate
444+
pairs.
445+
-}
446+
remainderWith :: [(VarName, Sort, Term)] -> [(Term, Term)] -> MatchResult
447+
remainderWith assocs pairs =
448+
MatchIndeterminate
449+
( Map.fromList
450+
[ (Variable{variableSort, variableName}, term)
451+
| (variableName, variableSort, term) <- assocs
452+
]
453+
)
454+
(NE.fromList pairs)
455+
326456
errors :: String -> Term -> Term -> TestTree
327457
errors name pat subj =
328458
testCase name $

booster/unit-tests/Test/Booster/Pattern/MatchImplies.hs

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ test_match_implies =
2828
[ constructors
2929
, functions
3030
, varsAndValues
31+
, variableRebindMixedDeterminacy
3132
, andTerms
3233
, sorts
3334
, injections
@@ -126,9 +127,8 @@ constructors =
126127
z = var "Z" someSort
127128
t1 = app con3 [var "X" someSort, var "X" someSort]
128129
t2 = app con3 [y, z]
129-
in test "Matching the same variable in a constructor (fail)" t1 t2 $
130-
failed $
131-
VariableConflict (Variable someSort "X") y z
130+
in test "Matching the same variable in a constructor (indeterminate)" t1 t2 $
131+
remainderWith [("X", someSort, y)] [(y, z)]
132132
]
133133

134134
functions :: TestTree
@@ -181,7 +181,11 @@ varsAndValues =
181181
success [("X", someSort, inj aSubsort someSort v2)]
182182
, let v1 = var "X" aSubsort
183183
v2 = var "Y" someSort
184-
in test "two variables (v1 subsort v2)" v1 v2 $
184+
in test "two variables (v1 subsort v2): indeterminate, Y may narrow" v1 v2 $
185+
remainder [(v1, v2)]
186+
, let v1 = var "X" aSubsort
187+
v2 = var "Y" differentSort
188+
in test "two variables (disjoint sorts): fail" v1 v2 $
185189
failed (DifferentSorts v1 v2)
186190
, let v1 = var "X" someSort
187191
v2 = var "X" differentSort
@@ -213,6 +217,44 @@ varsAndValues =
213217
failed (DifferentSorts v d)
214218
]
215219

220+
{- | A pattern variable that gets matched against two different subject
221+
positions — one a domain value (constructor-like), one a function
222+
application (not constructor-like) — must be reported as
223+
'MatchIndeterminate', because the second term might simplify into
224+
something equivalent to the first (the comment on 'bindVariable' says
225+
exactly this).
226+
227+
A decisive 'MatchFailed VariableConflict' here (as 'Implies' mode
228+
returned before this was fixed) would be a soundness gap: the implies
229+
handler trusts the matcher's decisive verdict and returns a
230+
non-'indeterminate' @valid:false@, even though simplifying the function
231+
application could reveal the subsumption. The tests below pin both
232+
orderings of the rebind.
233+
-}
234+
variableRebindMixedDeterminacy :: TestTree
235+
variableRebindMixedDeterminacy =
236+
testGroup
237+
"Variable rebinding with mixed-determinacy subject"
238+
[ let d = dv someSort "1"
239+
fnApp = app f1 [dv someSort "x"]
240+
t1 = app con3 [var "X" someSort, var "X" someSort]
241+
t2 = app con3 [d, fnApp]
242+
in test
243+
"Rebind X to a domain value then to a function application is indeterminate"
244+
t1
245+
t2
246+
(remainderWith [("X", someSort, d)] [(d, fnApp)])
247+
, let d = dv someSort "1"
248+
fnApp = app f1 [dv someSort "x"]
249+
t1 = app con3 [var "X" someSort, var "X" someSort]
250+
t2 = app con3 [fnApp, d]
251+
in test
252+
"Rebind X to a function application then to a domain value is indeterminate"
253+
t1
254+
t2
255+
(remainderWith [("X", someSort, fnApp)] [(fnApp, d)])
256+
]
257+
216258
andTerms :: TestTree
217259
andTerms =
218260
testGroup
@@ -517,6 +559,19 @@ failed = MatchFailed
517559
remainder :: [(Term, Term)] -> MatchResult
518560
remainder = MatchIndeterminate mempty . NE.fromList
519561

562+
{- | Like 'remainder' but also asserts a non-empty partial substitution from
563+
pairs that the matcher resolved before reaching the indeterminate pairs.
564+
-}
565+
remainderWith :: [(VarName, Sort, Term)] -> [(Term, Term)] -> MatchResult
566+
remainderWith assocs pairs =
567+
MatchIndeterminate
568+
( Map.fromList
569+
[ (Variable{variableSort, variableName}, term)
570+
| (variableName, variableSort, term) <- assocs
571+
]
572+
)
573+
(NE.fromList pairs)
574+
520575
sortErr :: SortError -> MatchResult
521576
sortErr = MatchFailed . SubsortingError
522577

0 commit comments

Comments
 (0)