Skip to content

checking: backtrack instance-chain subgoals and bound recursive probes#125

Open
bneiswander wants to merge 14 commits into
purefunctor:mainfrom
bneiswander:pr/checking-instance-chain-backtracking
Open

checking: backtrack instance-chain subgoals and bound recursive probes#125
bneiswander wants to merge 14 commits into
purefunctor:mainfrom
bneiswander:pr/checking-instance-chain-backtracking

Conversation

@bneiswander

Copy link
Copy Markdown
Contributor

checking: backtrack instance-chain subgoals and bound recursive probes

Base branch: pr/checking-row-fundep-givens

Review Order

This PR is stacked on pr/checking-row-fundep-givens and should be reviewed after that PR.

Base branch for review: pr/checking-row-fundep-givens

It is otherwise independent from pr/checking-operator-expected-results; both branches can be reviewed after the row-fundep/givens PR is understood.

Summary

  • Backtrack instance-chain candidate probing when nested subgoals are unsatisfiable.
  • Refine else-candidate blocking so candidates are blocked only by unknown match results, not by known-apart or known-unsatisfiable subgoals.
  • Bound recursive instance probes to avoid infinite recursion while preserving the ability to reject impossible candidates.

Motivation

These fixes address several false positives from real PureScript project code involving overlapping/else instances, type-level comparison, row folds, and derived codec-like instance chains. The analyzer was either selecting the wrong else branch too early, keeping an impossible candidate alive, or recursively probing the same candidate shape without a useful bound.

The common concern is candidate probing: it needs transactional state, precise blocking semantics, and recursion protection. Failed nested probes should not leak solver state, known-unsatisfiable subgoals should reject a candidate, and recursive probes need a bound.

Motivating Example

For overlapping instances, a candidate with an impossible nested subgoal should be rejected so the valid else candidate can be considered:

class Pick a b result | a b -> result

instance pickSame :: Compare a a EQ => Pick a a a
else instance pickFallback :: Pick a b b

choose :: forall a b result. Pick a b result => Proxy a -> Proxy b -> Proxy result
choose _ _ = Proxy

Before this change, the checker could keep an impossible candidate alive, block on the wrong condition, or recursively reprobe the same candidate shape.

Implementation Notes

  • Adds canonical/probe state needed to recognize repeated recursive instance probes.
  • Uses checkpoint/restore behavior while testing nested candidate constraints so failed probes do not leak solver state.
  • Rejects candidates with unsatisfiable nested subgoals instead of leaving them ambiguous.
  • Keeps else-instance blocking focused on genuinely unknown match results.
  • This branch is stacked on pr/checking-row-fundep-givens because it shares the constraint matching/given infrastructure introduced there.

Tests

Added fixtures:

  • 1778700540_instance_subgoal_apart_compare
  • 1778701380_pick_max_compare_eval
  • 1778701500_pick_max_record_fold_order
  • 1778720160_example_function_fundep
  • 1778723460_pick_max_attachment_codec_binary
  • 1778805900_recursive_instance_chain_candidate_constraint

Validation run locally:

cargo check -p checking --tests

@coderabbitai

coderabbitai Bot commented May 15, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a candidate-constraint probe fast-path, checkpoint/restore for canonical and unification state, makes matching state-aware with per-argument unifications and unsatisfiability probing, adds given-elaboration substitution, adjusts row-kind inference for unions, and adds multiple PureScript integration fixtures.

Changes

Constraint Solver Enhancement

Layer / File(s) Summary
Probe key types and fast-path
compiler-core/checking/src/core/constraint.rs, compiler-core/checking/src/core/constraint/matching.rs
Introduce ProbeTypeKey/ProbeArgKey/ProbeKey and modify solve_constraints to compute a probe key for dequeued wanted constraints; matching active probe appends the wanted to residuals and skips normal matching/unblocking.
State checkpoint & restore
compiler-core/checking/src/state.rs, compiler-core/checking/src/core/constraint/canonical.rs
Add UnificationsCheckpoint, CanonicalsCheckpoint, and CheckStateCheckpoint plus checkpoint/restore to clone/restore unifications, canonical cache, canonical_errors, error-length marker, and probe cache.
State-aware matching and per-argument tracking
compiler-core/checking/src/core/constraint/matching.rs
Thread &mut CheckState into stuck/FD lookup, record per-argument indices and argument-scoped unification slices, compute non-determined unification IDs, and add cached unsatisfiability probing to prune candidates.
Elaborate given substitution
compiler-core/checking/src/core/constraint/elaborate.rs, compiler-core/checking/src/core/constraint.rs
Add elaborate_given_substitution that expands determined args, allocates fresh unification variables, interns & solves derived canonical constraints (temporarily truncating errors), zonks inferred replacements, and registers improvements; public wrapper canonicalizes input TypeIds.
Equation checking uses elaborated given substitution
compiler-core/checking/src/source/terms/equations.rs
Apply the elaborated given substitution (via SubstituteName) to skolemised constraints, arguments, and result during EquationMode::Check.
Row-kind inference for union fresh variable
compiler-core/checking/src/core/constraint/compiler/prim_row.rs
match_union now infers the fresh row kind from the (left, right, union) arguments using infer_row_constraint_kind and creates the fresh unification variable at that inferred kind.

Integration Test Fixtures

Layer / File(s) Summary
Row-type functional dependency & stripping
tests-integration/fixtures/checking/1778682360_row_fundep_determined_result/Main.purs
Adds row-list/row transformation helpers (ExtractType, StripColumnsRL, ListToRow, StripColumns) and example values exercising determined-result behavior.
Polykinded safe union
tests-integration/fixtures/checking/1778685420_polykinded_row_fundep_safe_union/Main.purs
Adds SafeUnion evaluation framework and runtime expand/composeFlipped helpers plus examples.
Compare-based Pick & PickMax
tests-integration/fixtures/checking/1778700540_instance_subgoal_apart_compare/Main.purs, tests-integration/fixtures/checking/1778701380_pick_max_compare_eval/Main.purs
Add Pick and PickMax fixtures driven by Prim.Int.Compare, Eval, and If with example proxies.
PickMax with record folding
tests-integration/fixtures/checking/1778701500_pick_max_record_fold_order/Main.purs
Integrate PickMax with FoldingWithIndex/FoldlRecord to build codec args from config records; exports build and example proxies.
Eval visible type application
tests-integration/fixtures/checking/1778701560_eval_given_visible_type_application/Main.purs
Add Eval/NotEq machinery and reflectBoolean to materialize type-level boolean evaluation.
Example function fundep
tests-integration/fixtures/checking/1778720160_example_function_fundep/Main.purs
Add Example class with overlapping instances and test spec wiring using Aff/Spec foreign imports.
Attachment codec selection folding
tests-integration/fixtures/checking/1778723460_pick_max_attachment_codec_binary/Main.purs
Full codec-argument folding, PickMax/IsCodec mapping, and exported helpers/tests.
Recursive instance chain fixture
tests-integration/fixtures/checking/1778805900_recursive_instance_chain_candidate_constraint/Main.purs
Add recursive Resolve a => Resolve a instance plus else fallback for Resolve Int to exercise candidate deferral and fallback resolution.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I sniff the probe and hop the trace,
I stash checkpoints in a secret place,
I nudge given types to bloom anew,
Per-arg traces guide the matching through,
Tiny tests twirl — a carrot-shaped grace!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The PR description clearly explains the changes, motivation, and implementation approach for handling instance-chain backtracking and recursive probe bounding.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
compiler-core/checking/src/core/constraint/matching.rs (1)

522-553: 💤 Low value

Minor: remove unnecessary reference on index.

Line 537 creates an unnecessary reference. Since index is already &usize from the tuple destructure, &index creates &&usize. The contains method accepts &usize directly.

♻️ Suggested fix
-        if determined.contains(&index) {
+        if determined.contains(index) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@compiler-core/checking/src/core/constraint/matching.rs` around lines 522 -
553, In non_determined_unification_ids, the contains check uses &index which
produces &&usize; change the call to determined.contains(index) (use index
directly) to pass a &usize as expected; update the tuple destructuring reference
(index) usage in the loop so determined.contains(index) is called and nothing
else needs to change in collect_blocking or other logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@compiler-core/checking/src/core/constraint/matching.rs`:
- Around line 522-553: In non_determined_unification_ids, the contains check
uses &index which produces &&usize; change the call to
determined.contains(index) (use index directly) to pass a &usize as expected;
update the tuple destructuring reference (index) usage in the loop so
determined.contains(index) is called and nothing else needs to change in
collect_blocking or other logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 10f2a0d8-1463-4034-a360-a62cda10bc5d

📥 Commits

Reviewing files that changed from the base of the PR and between 608aeb5 and b05b94c.

⛔ Files ignored due to path filters (10)
  • tests-integration/fixtures/checking/1772440440_prim_row_open/Main.snap is excluded by !**/*.snap
  • tests-integration/fixtures/checking/1778682360_row_fundep_determined_result/Main.snap is excluded by !**/*.snap
  • tests-integration/fixtures/checking/1778685420_polykinded_row_fundep_safe_union/Main.snap is excluded by !**/*.snap
  • tests-integration/fixtures/checking/1778700540_instance_subgoal_apart_compare/Main.snap is excluded by !**/*.snap
  • tests-integration/fixtures/checking/1778701380_pick_max_compare_eval/Main.snap is excluded by !**/*.snap
  • tests-integration/fixtures/checking/1778701500_pick_max_record_fold_order/Main.snap is excluded by !**/*.snap
  • tests-integration/fixtures/checking/1778701560_eval_given_visible_type_application/Main.snap is excluded by !**/*.snap
  • tests-integration/fixtures/checking/1778720160_example_function_fundep/Main.snap is excluded by !**/*.snap
  • tests-integration/fixtures/checking/1778723460_pick_max_attachment_codec_binary/Main.snap is excluded by !**/*.snap
  • tests-integration/fixtures/checking/1778805900_recursive_instance_chain_candidate_constraint/Main.snap is excluded by !**/*.snap
📒 Files selected for processing (16)
  • compiler-core/checking/src/core/constraint.rs
  • compiler-core/checking/src/core/constraint/canonical.rs
  • compiler-core/checking/src/core/constraint/compiler/prim_row.rs
  • compiler-core/checking/src/core/constraint/elaborate.rs
  • compiler-core/checking/src/core/constraint/matching.rs
  • compiler-core/checking/src/source/terms/equations.rs
  • compiler-core/checking/src/state.rs
  • tests-integration/fixtures/checking/1778682360_row_fundep_determined_result/Main.purs
  • tests-integration/fixtures/checking/1778685420_polykinded_row_fundep_safe_union/Main.purs
  • tests-integration/fixtures/checking/1778700540_instance_subgoal_apart_compare/Main.purs
  • tests-integration/fixtures/checking/1778701380_pick_max_compare_eval/Main.purs
  • tests-integration/fixtures/checking/1778701500_pick_max_record_fold_order/Main.purs
  • tests-integration/fixtures/checking/1778701560_eval_given_visible_type_application/Main.purs
  • tests-integration/fixtures/checking/1778720160_example_function_fundep/Main.purs
  • tests-integration/fixtures/checking/1778723460_pick_max_attachment_codec_binary/Main.purs
  • tests-integration/fixtures/checking/1778805900_recursive_instance_chain_candidate_constraint/Main.purs

@bneiswander bneiswander force-pushed the pr/checking-instance-chain-backtracking branch from b05b94c to 58bd63c Compare May 15, 2026 16:24

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@compiler-core/checking/src/core/constraint.rs`:
- Around line 280-289: The loop that checks for recursive probes currently scans
all frames in state.candidate_constraint_probes and therefore matches the
just-pushed current probe; change it to skip the current/top frame and only
compare against ancestor probe frames. Update the check around probe_key(state,
context, wanted) so candidate_constraint_probes iteration excludes the
most-recent frame (e.g., iterate all but the last entry or iterate with index
and skip last) when calling probe.iter().any(|active| active == &wanted_probe),
leaving the rest of the logic (residuals.push(wanted); continue 'work;)
unchanged.

In `@compiler-core/checking/src/state.rs`:
- Around line 103-108: The CheckStateCheckpoint currently omits
candidate_constraint_probe_cache so nested speculative probe memoization leaks
across restores; update the CheckStateCheckpoint struct to include a snapshot of
candidate_constraint_probe_cache, capture it when creating a checkpoint
(alongside unifications, canonicals, canonical_errors, error_count in the code
that constructs CheckStateCheckpoint), and restore it when rolling back (in the
code path that applies a CheckStateCheckpoint to the CheckState). Ensure the
same change is applied to the other checkpoint variant referenced (the one
around the other checkpoint creation/restore code block) so probe-cache entries
created during speculative probes are discarded on restore.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6d0aa66b-4612-4af3-b611-dd40122effbb

📥 Commits

Reviewing files that changed from the base of the PR and between b05b94c and 58bd63c.

⛔ Files ignored due to path filters (11)
  • tests-integration/fixtures/checking/1772440380_prim_row_apart/Main.snap is excluded by !**/*.snap
  • tests-integration/fixtures/checking/1772440500_prim_row_generalization/Main.snap is excluded by !**/*.snap
  • tests-integration/fixtures/checking/1772442660_prim_type_error_warn/Main.snap is excluded by !**/*.snap
  • tests-integration/fixtures/checking/1772442720_prim_type_error_fail/Main.snap is excluded by !**/*.snap
  • tests-integration/fixtures/checking/1777298340_top_level_fail_eager/Main.snap is excluded by !**/*.snap
  • tests-integration/fixtures/checking/1778700540_instance_subgoal_apart_compare/Main.snap is excluded by !**/*.snap
  • tests-integration/fixtures/checking/1778701380_pick_max_compare_eval/Main.snap is excluded by !**/*.snap
  • tests-integration/fixtures/checking/1778701500_pick_max_record_fold_order/Main.snap is excluded by !**/*.snap
  • tests-integration/fixtures/checking/1778720160_example_function_fundep/Main.snap is excluded by !**/*.snap
  • tests-integration/fixtures/checking/1778723460_pick_max_attachment_codec_binary/Main.snap is excluded by !**/*.snap
  • tests-integration/fixtures/checking/1778805900_recursive_instance_chain_candidate_constraint/Main.snap is excluded by !**/*.snap
📒 Files selected for processing (11)
  • compiler-core/checking/src/core/constraint.rs
  • compiler-core/checking/src/core/constraint/canonical.rs
  • compiler-core/checking/src/core/constraint/elaborate.rs
  • compiler-core/checking/src/core/constraint/matching.rs
  • compiler-core/checking/src/state.rs
  • tests-integration/fixtures/checking/1778700540_instance_subgoal_apart_compare/Main.purs
  • tests-integration/fixtures/checking/1778701380_pick_max_compare_eval/Main.purs
  • tests-integration/fixtures/checking/1778701500_pick_max_record_fold_order/Main.purs
  • tests-integration/fixtures/checking/1778720160_example_function_fundep/Main.purs
  • tests-integration/fixtures/checking/1778723460_pick_max_attachment_codec_binary/Main.purs
  • tests-integration/fixtures/checking/1778805900_recursive_instance_chain_candidate_constraint/Main.purs
✅ Files skipped from review due to trivial changes (2)
  • tests-integration/fixtures/checking/1778700540_instance_subgoal_apart_compare/Main.purs
  • tests-integration/fixtures/checking/1778701380_pick_max_compare_eval/Main.purs
🚧 Files skipped from review as they are similar to previous changes (4)
  • tests-integration/fixtures/checking/1778805900_recursive_instance_chain_candidate_constraint/Main.purs
  • compiler-core/checking/src/core/constraint/elaborate.rs
  • tests-integration/fixtures/checking/1778723460_pick_max_attachment_codec_binary/Main.purs
  • tests-integration/fixtures/checking/1778701500_pick_max_record_fold_order/Main.purs

Comment thread compiler-core/checking/src/core/constraint.rs Outdated
Comment thread compiler-core/checking/src/core/constraint.rs Outdated
Comment thread compiler-core/checking/src/state.rs Outdated
@bneiswander bneiswander changed the title Pr/checking instance chain backtracking checking: backtrack instance-chain subgoals and bound recursive probes May 15, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
compiler-core/checking/src/core/constraint.rs (1)

280-291: 💤 Low value

Collapse redundant nested if statements.

The outer !is_empty() check is redundant since split_last() returns None for empty slices. Collapsing improves readability.

♻️ Suggested simplification
-        if !state.candidate_constraint_probes.is_empty() {
-            let wanted_probe = probe_key(state, context, wanted);
-            if let Some((_, ancestors)) = state.candidate_constraint_probes.split_last() {
-                if ancestors
-                    .iter()
-                    .any(|probe| probe.iter().any(|active| active == &wanted_probe))
-                {
-                    residuals.push(wanted);
-                    continue 'work;
-                }
+        if let Some((_, ancestors)) = state.candidate_constraint_probes.split_last() {
+            let wanted_probe = probe_key(state, context, wanted);
+            if ancestors
+                .iter()
+                .any(|probe| probe.iter().any(|active| active == &wanted_probe))
+            {
+                residuals.push(wanted);
+                continue 'work;
             }
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@compiler-core/checking/src/core/constraint.rs` around lines 280 - 291, The
outer !state.candidate_constraint_probes.is_empty() check is redundant because
split_last() already returns None for an empty slice; remove that check and
collapse into a single if-let: call probe_key(state, context, wanted) to get
wanted_probe, then use if let Some((_, ancestors)) =
state.candidate_constraint_probes.split_last() and keep the existing
ancestors.iter().any(...) logic to push onto residuals and continue 'work when
matched; leave variable names (state, candidate_constraint_probes, probe_key,
wanted_probe, ancestors, residuals, and the 'work label) unchanged to make the
change minimal and localized.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@compiler-core/checking/src/core/constraint.rs`:
- Around line 280-291: The outer !state.candidate_constraint_probes.is_empty()
check is redundant because split_last() already returns None for an empty slice;
remove that check and collapse into a single if-let: call probe_key(state,
context, wanted) to get wanted_probe, then use if let Some((_, ancestors)) =
state.candidate_constraint_probes.split_last() and keep the existing
ancestors.iter().any(...) logic to push onto residuals and continue 'work when
matched; leave variable names (state, candidate_constraint_probes, probe_key,
wanted_probe, ancestors, residuals, and the 'work label) unchanged to make the
change minimal and localized.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c11d90b4-f2b0-46c7-bf94-d9b133d0a992

📥 Commits

Reviewing files that changed from the base of the PR and between 58bd63c and 36583e7.

📒 Files selected for processing (2)
  • compiler-core/checking/src/core/constraint.rs
  • compiler-core/checking/src/state.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • compiler-core/checking/src/state.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@compiler-core/checking/src/core/constraint/matching.rs`:
- Around line 487-490: The current branch treats an already-active exact
recursive probe as "not unsatisfiable" (returning Ok(false)), allowing infinite
re-enqueueing; change the logic in matching.rs so that if
state.candidate_constraint_probes.iter().any(|previous| previous == &probe) you
return Ok(true) (treat as unsatisfiable) instead of Ok(false), while leaving the
existing len() >= 2 check behavior intact — update the conditional around
state.candidate_constraint_probes and probe accordingly so exact recursive
probes short-circuit as unsatisfiable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ef3903a6-0ccb-494c-ba83-ebfda271e4bf

📥 Commits

Reviewing files that changed from the base of the PR and between 36583e7 and bd60722.

📒 Files selected for processing (3)
  • compiler-core/checking/src/core/constraint.rs
  • compiler-core/checking/src/core/constraint/matching.rs
  • tests-package-set/src/lib.rs

Comment thread compiler-core/checking/src/core/constraint/matching.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
compiler-core/checking/src/core/constraint.rs (1)

35-63: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make probe keys reflect solved and distinct variables.

Line 107 still folds every Rigid and Unification into one Variable shape and never consults state for solved unifications. That lets structurally different obligations alias, and a key can stay stale after a variable solves, which can misfire the recursive-probe fast path or any probe cache keyed by this shape.

Also applies to: 65-132

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@compiler-core/checking/src/core/constraint.rs` around lines 35 - 63, The
ProbeTypeKey currently collapses all Rigid and Unification variables into the
single Variant Variable (and ProbeKey arguments) which ignores the solver state
and solved values; update the probe-key generation logic that constructs
ProbeTypeKey/ProbeArgKey/ProbeKey so that it (1) distinguishes rigid vs
unification variables (use separate enum variants or include the original
SmolStrId for Rigid and a unique id for Unification), and (2) consults the
solver/state when encountering a unification variable to fold it into its solved
representation (or a canonical representative id) so keys reflect solved values
and distinct variables; change any code paths that currently map variables to
ProbeTypeKey::Variable to instead produce the new specific variants or resolved
forms so caches and the recursive-probe fast-path remain correct.
🧹 Nitpick comments (1)
compiler-core/checking/src/core/constraint.rs (1)

280-286: ⚡ Quick win

Avoid allocating a ProbeKey when there are no ancestor probes.

Line 280 builds the full structural key for every dequeued wanted, even when candidate_constraint_probes is empty or only contains the current frame. On this hot path, move probe_key(...) inside the ancestor check so the common case stays allocation-free.

♻️ Proposed change
-        let wanted_probe = probe_key(state, context, wanted);
-        if let Some((_, ancestors)) = state.candidate_constraint_probes.split_last()
-            && ancestors.iter().any(|probe| probe.iter().any(|active| active == &wanted_probe))
-        {
-            residuals.push(wanted);
-            continue 'work;
+        if let Some((_, ancestors)) = state.candidate_constraint_probes.split_last() {
+            if !ancestors.is_empty() {
+                let wanted_probe = probe_key(state, context, wanted);
+                if ancestors.iter().any(|probe| probe.iter().any(|active| active == &wanted_probe))
+                {
+                    residuals.push(wanted);
+                    continue 'work;
+                }
+            }
         }
As per coding guidelines, `compiler-core/**/*.rs`: "Leverage interning and arena allocation for caching; avoid unnecessary allocations in Rust code".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@compiler-core/checking/src/core/constraint.rs` around lines 280 - 286, The
code currently computes let wanted_probe = probe_key(state, context, wanted)
before checking state.candidate_constraint_probes, causing unnecessary ProbeKey
allocations; move the call to probe_key(state, context, wanted) inside the
branch that runs only when there are ancestor probes (i.e., after the
split_last() and ancestors.iter().any(...) check) so that wanted_probe is only
constructed when ancestors exist and you actually compare probes; keep the
existing residuals.push(wanted) and continue 'work behavior unchanged and ensure
you still compare the produced ProbeKey against probe entries when performing
the any(...) check.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@compiler-core/checking/src/core/constraint.rs`:
- Around line 35-63: The ProbeTypeKey currently collapses all Rigid and
Unification variables into the single Variant Variable (and ProbeKey arguments)
which ignores the solver state and solved values; update the probe-key
generation logic that constructs ProbeTypeKey/ProbeArgKey/ProbeKey so that it
(1) distinguishes rigid vs unification variables (use separate enum variants or
include the original SmolStrId for Rigid and a unique id for Unification), and
(2) consults the solver/state when encountering a unification variable to fold
it into its solved representation (or a canonical representative id) so keys
reflect solved values and distinct variables; change any code paths that
currently map variables to ProbeTypeKey::Variable to instead produce the new
specific variants or resolved forms so caches and the recursive-probe fast-path
remain correct.

---

Nitpick comments:
In `@compiler-core/checking/src/core/constraint.rs`:
- Around line 280-286: The code currently computes let wanted_probe =
probe_key(state, context, wanted) before checking
state.candidate_constraint_probes, causing unnecessary ProbeKey allocations;
move the call to probe_key(state, context, wanted) inside the branch that runs
only when there are ancestor probes (i.e., after the split_last() and
ancestors.iter().any(...) check) so that wanted_probe is only constructed when
ancestors exist and you actually compare probes; keep the existing
residuals.push(wanted) and continue 'work behavior unchanged and ensure you
still compare the produced ProbeKey against probe entries when performing the
any(...) check.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 85c83781-2698-495b-b612-0b0d9b7a893c

📥 Commits

Reviewing files that changed from the base of the PR and between bd60722 and b0e9584.

📒 Files selected for processing (1)
  • compiler-core/checking/src/core/constraint.rs

Fixes CannotUnify diagnostics when using polykinded row type classes
like SafeUnion (forall k. Row k -> Row k -> Row k -> Constraint) with
higher-kinded effect rows (Row (Type -> Type)).

Two separate issues were addressed:

1. Functional-dependency positions in canonical constraint elaboration
   (elaborate_given_substitution) were indexing into canonical argument
   lists without accounting for implicit kind binders. The canonical
   representation interleaves kind arguments (from forall k.) before
   type arguments, so position 0 in a class's type parameters actually
   maps to canonical argument index 1 when a kind binder is present.
   Fixed in both elaborate.rs (speculative solving) and matching.rs
   (can_determine_stuck).

2. The Prim.Row.Union compiler solver created residual open-row tails
   (the | r part of (a :: A | r)) using a hardcoded Row Type kind.
   For polykinded classes, this caused the kind to be stuck as Row Type
   even when the constraint involved Row (Type -> Type). Fixed by
   inferring the correct row element kind from the other arguments and
   using that to construct the fresh tail variable.

Also adds a regression fixture that reproduces the SafeUnion pattern
using Eval/RowList/FromRow/ToRow (mimicking Type.Eval.Row.Util.SafeUnion)
and verifies it type-checks correctly.
When functional-dependency elaboration produces a substitution for a
forall-bound rigid variable, the improved givens now extend the original
givens rather than replacing them. This allows visible type applications
against those variables in the function body (e.g. Proxy @x) to still
resolve against the explicit constraint while the improved forms are
available for argument and result substitution.

Regression test: eval_given_visible_type_application
…else candidates

non_determined_unification_ids was collecting blocking unification
variables from ALL non-FD-determined arguments, including those whose
match was already known (Match or Apart). This caused false
NoInstanceFound errors when an earlier else candidate matched some
arguments but had unification variables in already-resolved positions.
…sted subgoals

Add candidate subgoal probing with rollback of solver-local state
(unifications, canonical cache, canonical errors, diagnostics) so
that invalid branches are rejected before commitment instead of
leaking stale NoInstanceFound diagnostics. Extend non-final candidate
blocking to track per-argument unifications, deferring candidates
that would unify unresolved non-determined input positions.
…ot probe cache in checkpoint

The recursion check matched the just-pushed current probe against itself,
causing false-positive recursion rejection. Use split_last() to only
compare against ancestor probe frames.

CheckStateCheckpoint omitted candidate_constraint_probe_cache, so
speculative probe memoization leaked across checkpoint restores.
Include it in checkpoint/restore to discard cache entries on rollback.
@bneiswander bneiswander force-pushed the pr/checking-instance-chain-backtracking branch from 354b2e1 to 4d0c08f Compare May 19, 2026 01:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant