Skip to content

Do not force type constructors to be injective#4259

Open
nikswamy wants to merge 29 commits into
masterfrom
_core_vs_rel
Open

Do not force type constructors to be injective#4259
nikswamy wants to merge 29 commits into
masterfrom
_core_vs_rel

Conversation

@nikswamy

@nikswamy nikswamy commented May 14, 2026

Copy link
Copy Markdown
Contributor

Core type checker: blanket slprop injectivity and refactored check_relation (fixes #4239)

Summary

This PR brings FStarC.TypeChecker.Core's logic for deciding relations between applied terms closer to FStarC.TypeChecker.Rel, addressing issue
#4239 where Core would force argument-level equality (a == b) when comparing f a =?= f b, even when f is a type abbreviation that should be
unfolded.

The main change in Core:

Refactored check_relation when heads match — the logic is now:

  • Always try no_guard(structural()) first
  • If that fails, dispatch by head classification: - Injective head: per-arg comparison with emit_guard fallback per arg
  • Equatable head (guards OK, equality rel): no_guard(unfold_both_and_retry()), falling back to emit_guard t0 t1 on the full application
  • Otherwise: unfold_both_and_retry() directly (allows guards from recursive comparison)

Now, it turns out the Pulse slprop comparisons were relying on the implicit injective behavior of the Core checker. So, this PR adds a mechanism to treat, by default, all slprop predicates as injective. This is done by:

  1. A new attribute, [@@unifier_hint_injective_type], which if placed on a type, makes all function symbols returning that type be treated as [@@ unifier_hint_injective]
  2. Pulse.Lib.Core.slprop has [@@unifier_hint_injective_type]
  3. With an opt-out mechanism: [@@unifier_hint_not_injective] for functions like (**), exists*, and on that are match-based or where per-arg equality is too strong.

Additionally, adds a unit test suite (src/tests/FStarC.Tests.CoreVsRel.fst):

And some expected output updates: ~30 Pulse error message .expected files updated (VC form changes from full comparison to l_False when per-arg guards
normalize trivially).

nikswamy and others added 3 commits May 13, 2026 21:13
Create FStarC.Tests.CoreVsRel with 48 tests across 14 groups comparing
FStarC.TypeChecker.Core and FStarC.TypeChecker.Rel on subtyping and
equality of uvar-free types.

Key test groups that document the bug:
- Tests 700-706: Type abbreviation with binder variables — Core produces
  injectivity guard (i == n) instead of unfolding abbreviations
- Tests 800-803: Arrow types with variable abbrev args (exact issue #4239)
- Tests 900-903: Multi-arg abbreviation with variable args

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When comparing `f a == f b`, first compare structurally with no
guard to enable fast, guard-free proofs for common cases, e.g.
`f a == f a`, unfolding and retrying as a fallback.
When comparing type abbreviation applications (f a =?= f b) with
matching heads, use a multi-step fallback strategy:

1. Try structural comparison without guards (handles a == b case)
2. If unfolding exposes a refinement type, use the refinement
   comparison directly (produces correct weakened guards for
   types like natlt i <: natlt n)
3. If unfolding exposes a non-refinement type, try three fallbacks:
   a. Unfolded comparison without guards (structurally equal after
      unfolding)
   b. Structural with guards (handles equatable args like variables,
      producing simple arg-level guards for slprop types)
   c. Unfolded with guards (handles non-equatable args like constants,
      where unfolding may expose equatable functions)

This preserves the #4239 fix (unfolding refinement abbreviations)
while maintaining compatibility with Pulse slprop types and
non-equatable constant arguments (Bug4256 pattern).

Also adds 4 new unit tests for the constant-arg case (tests 1600-1603).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nikswamy and others added 18 commits May 20, 2026 15:18
Adds tests 1700-1704 showing why structural fallback (step 2) is needed
in the 3-way non-refinement fallback of compare_head_and_args:

- Test 1701: cvr_ha_like c a =?= cvr_ha_like c b (EQUALITY)
  Core produces simple guard (a == b) via step 2.
  Rel fails (can't prove type equality without SMT).

- Test 1703: cvr_ha_like c a <: cvr_ha_like c b (SUBTYPING)
  Core guard: a == b (simple, SMT-provable from context)
  Rel guard:  complex formula-level implications from the unfolded
  arrow domains (forall x. x > c+b ==> x > c+a, etc.)

This models ZetaHashAccumulator's ha_val_core pattern where the type
abbreviation body uses Pulse slprop connectives (star, exists_, pts_to)
in complex ways. Step 2's structural comparison produces a simple
arg-level guard that SMT can prove, while step 3 (unfolded with guards)
would produce connective-level guards that SMT cannot handle.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Both Core and Rel are now tested with the same settings:
- Core: check_term_equality guard_ok=true unfolding_ok=true
- Rel: try_teq smt_ok=true

Previously Rel was called with smt_ok=false, which made it appear
that Rel fails where Core succeeds. In reality, both succeed with
guards — just different ones. Core produces simpler arg-level guards
(e.g., a == b) while Rel produces formula-level guards from unfolded
comparison.

All previously RelFailCoreSucceed tests now show BothGuarded.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
In check_slprop_equiv_ext (the prover's final slprop equality proof),
first try Core with unfolding_ok=false before falling back to the full
check_equiv_now with unfolding.

This produces simpler arg-level guards (e.g., X == Y) without looking
through type abbreviations, rather than complex formula-level guards
from unfolded slprop connective bodies. The Pulse prover has already
matched atoms via MKeys at the right abstraction level, so implicit
unfolding is unnecessary.

Adds check_equiv_now_nounfold to Pulse.Typing.Util: allows SMT guards
but disables Core's unfolding (t_check_equiv true false).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…r slprops

Add a new Core API check_term_equality_head_injective that decomposes two terms
into head+args, verifies the heads match, then checks argument-wise equality
with unfolding and guards allowed. The head itself is never unfolded.

Expose this as check_equiv_head_injective in Pulse.RuntimeUtils (implemented
directly in OCaml calling Core). The implementation properly discharges SMT
guards via Rel.discharge_guard before calling commit().

Modify check_slprop_equiv_ext in the Pulse prover to try head-injective
equality first, falling back to full check_equiv_now. This avoids unfolding
slprop connectives (which produces complex formula-level guards) while still
allowing argument-level unfolding and SMT guards.

Remove the now-unused check_equiv_now_nounfold from Pulse.Typing.Util.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add tests 1800-1802 exercising check_term_equality_head_injective directly:
- 1800: reflexive case (identical terms) — trivial success
- 1801: different args with same head — produces guard
- 1802: mismatched heads — fails as expected

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The 3-way fallback in compare_head_and_args had a 'step 2' that fell
back to structural comparison with guards on the ORIGINAL (pre-unfold)
terms. This was needed for cases like op_forall_Plus where unfolding
produced complex terms (forevery_aux) that SMT couldn't handle.

The proper fix is to mark the head symbols as [@unifier_hint_injective]:
- op_forall_Star in Pulse.Lib.Forall.fsti
- op_forall_Plus in Pulse.Lib.ForEvery.fst

When is_marked_injective fires (line 1373), Core goes directly to
structural() WITHOUT unfolding, producing simple arg-level guards.
This handles the recursive case naturally: nested forall+ applications
trigger the injective path at each level.

With these annotations, step 2 is no longer needed. The remaining
fallback is: try no-guard on unfolded, fall back to unfolded with
guards — which is correct for non-injective unfoldable heads.

Full CI passes with only pre-existing failures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Introduce a mechanism where all functions returning a type marked with
[@@unifier_hint_injective_type] are treated as injective by default.
This avoids unnecessary unfolding in the Pulse prover for slprop
comparisons.

New attributes:
- unifier_hint_injective_type: marks a type so all functions returning
  it are treated as injective (placed on slprop)
- unifier_hint_not_injective: explicit opt-out for specific functions
  that need unfolding (placed on (**), exists*, on)

The is_marked_injective logic (in both Core and Rel) now checks:
1. Explicit [@@unifier_hint_injective] → true
2. Explicit [@@unifier_hint_not_injective] → false
3. Delta_equational (match/if functions) → false
4. Return type has [@@unifier_hint_injective_type] → true
5. Otherwise → false

For injective heads, the per-arg comparison strategy is:
- First try no_guard(structural) on all args at once
- If that fails: compare each arg individually:
  a. no_guard(check_relation) — handles definitionally equal args
  b. If both args are lambdas: full check_relation (opens binders)
  c. Otherwise: emit_guard on original arg pair for SMT

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…injective annotations

With blanket slprop injectivity (unifier_hint_injective_type on slprop),
the check_term_equality_head_injective API is now redundant: the full
check_term_equality path already handles injective heads via the
is_marked_injective check in compare_head_and_args.

Similarly, the explicit [@@unifier_hint_injective] annotations on
op_forall_Star and op_forall_Plus are redundant since both return slprop
and are not Delta_equational, so the blanket rule covers them.

Removed:
- check_term_equality_head_injective from Core.fst/fsti
- check_equiv_head_injective from Pulse.RuntimeUtils.fsti and ML impl
- The head-injective fast-path in Pulse.Checker.Prover (now uses
  check_equiv_now directly, which has the same injective logic built in)
- [@@unifier_hint_injective] from op_forall_Star (Pulse.Lib.Forall.fsti)
- [@@unifier_hint_injective] from op_forall_Plus (Pulse.Lib.ForEvery.fst)
- Group 18 tests from CoreVsRel (tested the removed API)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The injective path correctly emits per-arg equality guards:
for f a1..an =?= f b1..bn, each ai == bi is checked individually.
When concrete args mismatch (e.g., 0 vs 1), the guard normalizes
to l_False, which is correct since the terms genuinely don't match.

Bug110's 'foo' (an opaque assumed val) is marked with
unifier_hint_not_injective since rewrite needs full-app guard
(foo i == foo j from context, not i == j which isn't provable).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@nikswamy nikswamy marked this pull request as ready for review May 27, 2026 05:18
nikswamy and others added 7 commits May 27, 2026 07:23
Replace blanket per-arg injectivity for slprop-returning functions with an
equatable approach:

- Split is_marked_injective into two predicates:
  * is_marked_injective: only explicit [@@unifier_hint_injective] attribute
  * returns_equatable_type: checks if return type has [@@unifier_hint_injective_type]

- New dispatch for matching heads:
  1. no_guard(structural) always tried first
  2. Explicitly injective: per-arg comparison (unchanged)
  3. Equatable/returns_equatable_type: try unfold, then per-arg with
     full-application-guard fallback. For each arg: if no_guard(check_relation)
     succeeds, done; if both are lambdas, compare extensionally; otherwise
     bail to emit_guard on the full application.
  4. Otherwise: unfold_both_and_retry

- Remove [@@unifier_hint_not_injective] from (**) and on in Pulse.Lib.Core
- Remove [@@unifier_hint_not_injective] from foo in Bug110 test

The equatable approach produces weaker guards (full application equality rather
than per-arg equality), making them always provable by SMT via congruence. This
eliminates the need for opt-out attributes on non-injective slprop combinators.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When check_relation encounters a Tm_match on one side but not the other
(e.g., tkey =?= match (mk_spec r1) with Mkdtuple2 a _ -> a), normalize
the match with [Weak; HNF; Beta; Iota; Primops; UnfoldUntil delta_constant].
This allows delta-unfolding inside the scrutinee (e.g., mk_spec → constructor)
followed by iota reduction, eliminating the match entirely.

This fixes Bug4239Variant where Core previously got stuck: after unfolding
dfst, the result was a Tm_match whose scrutinee needed further reduction
that only delta + iota could provide. Rel handles this via maybe_inline
which normalizes with full delta; Core now does the same but only for
stuck Tm_match terms.

Also adds a general fallback: try maybe_unfold_and_retry for other stuck
term pairs before giving up entirely.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.

Pulse expects type constructors to be injective

1 participant