Repository navigation
Memoize comparisons inside recursive types and generic functions - #275
Merged
Merged
Conversation
Rigidity
force-pushed
the
rigidity/issue-170-compare-memo
branch
from
November 14, 2026 04:36
1c2a3c3 to
ca184a9
Compare
Comparing a recursive type cached almost nothing, since nearly every result relied on a comparison still in progress, and caching was off while a generic function was being instantiated. When the types are contractive and each generic function's parameters and return type are either its generics or don't reference them, comparing is the least fixed point of monotone equations, so results that relied on comparisons in progress are reused while those are still in progress, which gives the same result for the whole comparison. Otherwise the existing memo is used, now also for comparisons that don't refer to a generic being instantiated. The cycle check also keys on the semantics passed down, like the memo.
- Walk the types once to decide whether a comparison is monotone - Drop the rule about generics made rigid during inference, which was redundant - Make the regression tests fail quickly on a regression, with tighter bounds - Run the differential with the memo on and off for every case, and add the seeds that caught broken soundness conditions to the CI run - Test which generic functions instantiate their generics directly
- Only hide the step limit panics that the differential expects, so a regression test that hits the limit still says why - Renumber unnamed generics before comparing results, since substituting allocates new ones
Comparing it recurses more deeply than the snapshot test thread's stack allows in debug builds. The rue-types test covers it on a larger stack.
Rigidity
force-pushed
the
rigidity/issue-170-compare-memo
branch
from
November 14, 2026 04:56
ca184a9 to
01f62eb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #170
Problem
comparecached almost nothing inside recursive types.Memoonly reuses a result that didn't rely on a comparison still in progress, and inside a cycle nearly every result does. It also turned caching off entirely while a generic function's generics were being instantiated (#140). So theGprogram from the issue never finished, andintersect's repeated comparisons of theN0/N1types took most of a second.What changed
Of the issue's two directions, this takes the first. It keys the cycle check on everything a result depends on, and makes the relaxed reuse rule exact. A "top-level comparison" below means one that starts from an empty stack, which is every call from
compare,check,intersectandsubtract. Each top-level comparison now runs in one of two modes.Fixpoint mode (new
FixpointMemoinmemo.rs). It applies when all of these hold, whichComparisonContext::is_monotonechecks with one walk per side:Here a result is cached even if it relied on comparisons in progress, together with those comparisons. It's reused while they're all still on the stack. This is what makes
Gfast.Exact mode. Otherwise the existing
Memois used unchanged. It now also caches comparisons that happen while a generic function is being instantiated, as long as the comparison's own key doesn't refer to a generic being instantiated. Before, nothing was cached in that case.The cycle check's stack key now includes the semantics passed down (it already included the binder pattern), so a comparison is only assumed to hold when it's revisited exactly. The stack key is now the same as the memo key.
The
instantiatingcounter is gone, since nothing reads it any more.Why fixpoint mode is exact
In fixpoint mode, the result of every comparison is a fixed equation over the comparisons it reaches, built from
max,minand constants over the chainAssign < Cast < Invalid.(Generic, Union)and the struct fallback in(_, Union)) aremins whose value doesn't depend on the fallback when it isn't compared.Assign, the least value.compare(U, X)orcompare(X, U)). Those never recurse, so they're never revisited or merged, and the bounds checked when the function finishes are the same every time the function is compared.Write
μfor the least fixed point, andμ[D]for the least fixed point with the comparisons inDfixed toAssign. Then:μ. Early returns give the least value, and reused values are at mostμby induction. The equations are monotone andμis their fixed point.μ[D], whereDis the set of in-progress comparisons below it that it relied on.μ[D ∪ {itself}].hbuilt frommax,minand constants. Such a function satisfiesh(y) = h(⊥) ∨ (y ∧ h(⊤)), soh(h(⊥)) = h(⊥).h(⊥)is the least fixed point ofh, which isμ[D].Dis on the stackS. Computing it again there would giveμ[S] ≤ μ[D], since fixing more comparisons toAssigncan only lower the fixed point.A top-level comparison has an empty
D, so its result is exactlyμwith or without reuse. Results inside a cycle can differ between memo on and off, but callers only ever see top-level results.The doc comments on
FixpointMemoandis_monotonehave the full argument. Two conditions are there because the proof needs them, not because of a known counterexample:instantiates_directly(a unit test pins which function types it accepts) and no fixpoint mode under inference. Dropping the contractiveness check, on the other hand, gives a real mismatch, which the differential test now catches (seed 4706).I didn't compute per strongly connected component. With instance bounds and merges in the mix, that would have meant rewriting
compare_impl, and the change would no longer be a memo over the existing algorithm.Tests
crates/rue-types/tests/memo.rs, differential. Random modules of up to three mutually recursive aliases, with:For each module, every case compares
compareboth ways,compare_with_inferenceand its candidates,checkandintersectwith the memo on and off (without_compare_memo). Generic ids are renumbered first.Exponential cases give up after a fixed number of steps (
with_step_limit). The test asserts that a case never gives up only with the memo on (it's 0 now).The CI run is 400 seeds plus seeds 1978 and 4706. Those two catch a broken exact-mode gate and a missing contractiveness check, which I confirmed by mutating the code. It takes 1.6s in release and about 33s in debug.
The large run is
#[ignore]d; run it withcargo test --release -p rue-types --test memo -- --ignored: 20,000 seeds × 8 queries gave 129,180 identical cases and 0 that gave up only with the memo on (5.5 minutes in release).Regression tests with step bounds rather than timings, so they don't flake. Both fail within seconds on main, since a step limit makes them panic:
test_rotating_generic_function(theGprogram): about 97,000 steps, bound 300,000. It never finishes on main.test_mutually_recursive_intersection(theN0/N1program):intersect1,080,000 steps on main → 3,600;compare89,000 → 1,700. Bound 20,000 each.Snapshot
tests/types/recursive_intersection_errors.ruecovers the second program end to end.G. Comparing it recurses deeper than the snapshot test's 2 MiB thread allows in debug builds, though it's fine in release and in the CLI. The unit test runs it on a larger stack.No existing snapshot changed.
Timings (release, M-series Mac)
GprogramN0/N1programI couldn't reproduce the issue's corpus (#160's
/tmpfiles aren't available), so I wrote a similar generator for Rue source. It makes two or three recursive aliases with generic parameters, generic function types, pairs and unions, followed by an assignment or anischeck. Its programs are more aggressive than the issue's (75 of 2,500 time out on main, against 21 there). The counts come from running, one at a time, every program that timed out on either binary in a first parallel pass.The programs that still time out also time out on main. They're generic functions whose generic appears nested inside a recursive type, such as
fn<U>(...) -> U | N0<...>, so they run in exact mode. Compile time of the examples is unchanged (for exampleexamples/json.ruetakes 0.27s against 0.49s on main).Found along the way (not fixed here)
Recursion depth exceededis a separate issue incheck, not this bug.(nil, N1) | N0 | Bytes | N1 | nilagainstN0 | Bytes | N0and then againstN1, recurs on its own path every two levels. So no finite runtime check exists, and the error ischeck_each's designed limit.is_non_contractivemisses a cycle through another alias's generic application:type T0 = T1<Int>; type T1<A> = A | (nil, A) | T0;isn't rejected.let _y: T0 = xwithx: T0then fails with "Cannot assignT0toT0" (on main too). Fixpoint mode isn't affected, since it checks contractiveness withreaches_non_contractive_cycle.Decisions for you (provisional)
without_compare_memoandwith_step_limitare behind the existingstep-counterfeature, rather than a new one. Splitting them out means adding a feature torue-types/Cargo.tomland to its dev-dependency.matcharms, the semantics passed down never reach an arm that reads them, so adding them changes no result. I kept them so the key stays complete if that changes (main's memo key already included them). The alternative is to drop them from both keys.Gsnapshot because of the debug stack depth. The alternative is to run the snapshot test thread on a larger stack.