Skip to content

feat(746): static verification of refinement-type predicates (v0.0.174)#763

Merged
aallan merged 33 commits into
mainfrom
feat/746-refinement-predicates
Jun 20, 2026
Merged

feat(746): static verification of refinement-type predicates (v0.0.174)#763
aallan merged 33 commits into
mainfrom
feat/746-refinement-predicates

Conversation

@aallan

@aallan aallan commented Jun 19, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the Tier-0 silent failure where a user refinement type parsed but its predicate was never checked: type PosInt = { @Int | @Int.0 > 0 } accepted a violating value with vera verify reporting OK. The verifier now discharges the predicate as a Tier-1 obligation at every narrowing site and at refined return positions, and codegen emits a runtime guard for every refinement base — generalising the proven @Nat machinery from the baked-in >= 0 to an arbitrary translated predicate.

Ships as v0.0.174 (bundling the #759/#760 guard-hardening already in [Unreleased]).

Static verification

A refinement predicate is structurally identical to a requires clause, so the existing SMT translation + discharge spine is reused. The binder is substituted by pushing the refined value's Z3 term onto a fresh SlotEnv under the base type-name, then check_valid discharges exactly like the @Nat path.

  • Binding-site discharge at let, call arguments (incl. piped and generic-instantiated formals), constructor fields, effect-operation arguments, match bindings, ADT sub-pattern binds, and literal / projected tuple-destructure components.
  • Return-position discharge when the declared return type is refined.
  • Param-assume: a refined parameter's predicate is assumed into the body — the matched half of the call-site obligation.
  • Refined-return propagation: a caller assumes a refined return's predicate on the call result, so a verified refined return is usable in caller proofs (not write-only).
  • Diagnostics: E505 (predicate may be violated) renders the predicate + counterexample; E506 (informational — a non-primitive base or undecidable predicate is statically Tier-3 but checked at run time). New refine_bind ObligationKind.

Runtime guards (#762, closed here)

Every refinement predicate is also guarded at runtime, reusing the existing contract machinery (translate_expr + $vera.contract_fail): a refined parameter is checked at function entry, a refined return at exit. So a program compiled without vera verify traps on a refinement-violating value at a public/FFI boundary.

  • Verified end-to-end via vera run: param/return guards trap on violating values, pass on valid ones.
  • Call arguments are covered transitively by the callee's entry guard.
  • Generic functions: the guard fires on the monomorphised instance.
  • Non-primitive bases too (e.g. @Array<Int>): Z3 can't decide array_length, so it's Tier-3 statically, but codegen compiles the predicate to WASM directly — head([]) into a @NonEmptyArray parameter traps.

Soundness risks covered (with tests)

R1 (param-assume ↔ call-site), R3 (already-refined source via predicate-AST equality, not types_equal), R5 (return binder actually bound), R7 (untranslatable → Tier-3, never silent), R9 (@Nat / refine_bind disjointness).

Validation

Full suite green. mypy + both ruff gates clean. All doc gates pass: conformance (90), examples (35), version-sync (6 files), doc-counts, limitations-sync, site-assets, changelog, spec-examples. Runtime guards exercised by TestRefinementRuntimeGuards (primitive, String, Array, generic-monomorphised).

Bare @Nat return positions remain under #758 (out of scope here — @Nat stays on its own path).

Closes #746
Closes #762

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Refinement-type predicates are now enforced at narrowing sites and refined return positions (Tier-1), with violations reported as E505 (including counterexamples) and non-statically-checkable cases reported as E506.
    • Runtime boundary enforcement now emits fail-closed WebAssembly guards for refined parameters and refined returns, including transitive argument guarding; predicate guard codegen failures are reported as E617.
  • Bug Fixes

    • Improved refinement handling across generics, aliasing, pattern matching/binding, and tuple component guarding, with more precise soundness and error reporting.
  • Documentation / Tests

    • Updated spec and changelogs, plus expanded verifier and code generation test coverage and release metrics.

aallan and others added 4 commits June 19, 2026 09:26
Add the verifier helpers #746 builds on, with no behaviour change (unused until
Step 2):
- _refined_parts(ty): (base, predicate) of a user RefinedType, or None. @nat (a
  PrimitiveType, not a RefinedType) is deliberately excluded so the nat_bind and
  refine_bind paths stay disjoint (one obligation per site, never both).
- _base_slot_name(base): the slot type-name the predicate's binder uses (the
  base primitive's name, e.g. the @Int.0 binder -> "Int"); None for a
  non-primitive base (predicate left untranslatable -> Tier 3, never silent).
- _translate_refined_predicate(smt, refined_ty, value_term): translate the
  predicate to Z3 with the binder substituted by value_term, against a fresh
  SlotEnv keyed on the base name.

Pinned the binder convention empirically: the predicate's SlotRef carries the
BASE name ("Int"), not the alias ("PosInt"), so a wrong push-key would leave the
binder unconstrained and silently "verify". Unit tests prove the substitution
(v=5 -> True, v=-1 -> False) and that a builtin-call predicate translates.

Co-Authored-By: Claude <[email protected]>
Generalises the proven @nat binding-discharge machinery (#552/#747) from
the baked-in `>= 0` to an arbitrary translated refinement predicate, closing
the Tier-0 silent failure where `type PosInt = { @int | @Int.0 > 0 }` parsed
but its predicate was never checked.

Implementation (verifier.py + smt reuse):
- `_is_refined_type` / extended `_translate_refined_predicate` (folds the
  base-@nat intrinsic `>= 0` into the obligation so a refinement over @nat
  discharges `>= 0 && P`, never silently dropping P).
- `_narrows_into_refined` — R3 exemption via predicate-AST equality (NOT
  types_equal, which ignores predicates), so an already-`@PosInt` source
  raises no obligation while `@Percentage` into `@PosInt` still obligates.
- `_check_refined_binding_obligation` + `_record_refined_bind_tier3` mirror
  the @nat discharge: verified -> Tier 1; counterexample -> E505;
  untranslatable value/predicate/non-primitive base or timeout -> E506
  Tier-3 warning, excluded from totals, never a silent pass (R7; refined
  narrowings have no codegen runtime guard yet).
- Param-assume: a refined param's predicate is assumed into the body (the
  matched half of the call-site discharge, R1).
- Wired refined-first at every binding site (let / call-arg / ctor-field /
  effect-op-arg / tuple-destructure / match-bind) and at refined return
  positions. Bare @nat stays on its own path (#758 untouched).

Diagnostics: E505 (predicate may be violated) renders the predicate source +
counterexample; E506 (unverified, not runtime-guarded). New refine_bind
ObligationKind.

Two existing tests updated for the now-correct behaviour: the example
tier-count golden (refinement_types.vera gains 2 Tier-1 + 1 tier3_unguarded)
and a tester coverage test whose old ensures now verifies via param-assume
(swapped for one that still fails, preserving the error-path coverage).

Refs #746.

Co-Authored-By: Claude <[email protected]>
Tests:
- tests/test_verifier.py: TestRefinementPredicateVerification — discharge /
  E505 violation / E506 Tier-3 trio across call-arg, let, ctor-field, return,
  match-bind, tuple sites; plus the soundness-risk pins: R3 already-refined
  source -> zero obligations (and the distinct/stronger-refinement variants),
  R5 violating-return-is-caught, R7 non-primitive-base -> E506-not-silent, R8
  multi-slot/string_length predicates, R9 bare-@Nat-stays-nat_bind, and the
  refinement-over-@nat `>= 0 && P` cases.  Plus a translation-primitive unit
  test for the base-@nat conjoin.
- tests/test_obligations.py: refine_bind kind enumerated + E505 violation
  carries a counterexample, both summary-consistent.
- tests/test_tester_coverage.py / test_verifier.py golden already updated in
  the implementation commit.

Conformance: ch02_refinement_types.vera header documents that the predicates
are now statically discharged (param-assume, call-site, and return).

Docs:
- spec/02-types.md §2.6.4 "Predicate Verification" — when/where predicates are
  discharged + the deferred runtime-guard limitation.
- vera/types.py RefinedType docstring no longer says "not verified in C3".
- Limitation documented across the gated tiers (KNOWN_ISSUES.md, vera/README.md,
  SKILL.md, spec) referencing the codegen-guard follow-up #762.
- examples/refinement_types.vera comments explain the Tier-1 discharges and the
  array-base Tier-3 boundary (#762).
- TESTING.md / ROADMAP.md test counts refreshed.

Refs #746, #762.

Co-Authored-By: Claude <[email protected]>
Bumps the version across all six sites (pyproject.toml, vera/__init__.py,
docs/index.html badge, README.md, uv.lock) and cuts the CHANGELOG.

- CHANGELOG: promote [Unreleased] -> [0.0.174] (bundling the #759/#760
  guard-hardening already there) and add the #746 refinement-predicate
  Added entry; recreate an empty [Unreleased]; add the [0.0.174] compare
  link.
- HISTORY.md: new Stage 14 row for v0.0.174 (#746).
- README.md: status line v0.0.174 + refreshed test/release counts.
- docs/ site assets regenerated (llms.txt, llms-full.txt, index.md,
  docs/SKILL.md) to pick up the #746 limitation row.

Refs #746, #762.

Co-Authored-By: Claude <[email protected]>
@coderabbitai

coderabbitai Bot commented Jun 19, 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

Walkthrough

Generalises the @Nat binding-site narrowing obligation machinery to arbitrary { @base | P } refinement predicates across all binding forms (call arguments, constructor fields, effect-op arguments, let bindings, tuple destructures, match bindings, pipe arguments, ADT sub-patterns, and return positions). Introduces the refine_bind obligation kind and E505/E506/E617 error codes, adds Z3 translation helpers with base-invariant reintroduction, seeds refined-parameter predicate assumptions into the solver, and emits WASM runtime guards via codegen. Bumps version to 0.0.174 with specification and release documentation aligned.

Changes

Refinement-predicate static verification and runtime guards

Layer / File(s) Summary
Obligation kind, error codes, and type-level refinement support
vera/obligations/core.py, vera/errors.py, vera/types.py, vera/__init__.py, pyproject.toml, vera/ast.py
ObligationKind gains "refine_bind" literal for predicate-based narrowing at binding sites and return positions; ObligationStatus documentation clarified to distinguish runtime-guarded refinement boundaries from unguarded sites; E505 (violation with counterexample), E506 (runtime-checked Tier-3), and E617 (predicate not compilable to runtime guard) registered in error code registry; RefinedType docstring updated to describe deferred predicate discharge at verifier time; project version incremented to 0.0.174; AST module adds predicate_binder_name helper for extracting refinement predicate binder type-name via iterative graph traversal.
SMT refined-type mapping, call postconditions, and match fact threading
vera/smt.py
ADT and sort mapping unwrap refined types to carrier bases, preventing None sort outcomes from nested refined components; call translation treats refined returns over primitive bases as implicit postconditions with predicate assertions; match translation computes per-arm source facts via verifier-injected hook, pushing and popping arm-local assumptions for default and non-default arms and adding solver-level global implications so facts hold when patterns match.
Verifier function setup, refined parameter assumptions, and return discharge
vera/verifier.py
Adds _refined_binding_target to compute concretely-instantiated refined targets at binding sites via checker-instantiated side tables for generic TypeVar formals; function-level verification accumulates refined_param_assumptions from translated parameter predicates and seeds them into solver assumptions; wires subpattern fact hooks for constructor-pattern source invariants; discharges refined returns post-body by translating membership goals and checking validity with seeded assumptions, recording Tier-1 successes, emitting E505 with counterexamples on violations, recording Tier-3 E506 on translation failures or solver timeouts, handling @Unit as unguarded; includes generic-function concrete-return fast path.
Refined obligation infrastructure, diagnostics, and exemption rules
vera/verifier.py
Adds refined obligation checking helpers (_check_refined_binding_obligation, _check_refined_binding_obligation_term), guarded/unguarded Tier-3 accounting (_record_refined_bind_tier3), generic fast-path discharge (_check_generic_refined_return), refinement-specific diagnostics with E505 violation rendering and guarded/unguarded E506 outcome messaging, stricter exemption rules requiring both base-type equality (types_equal) and predicate-AST equality, _is_unit_refinement for unguarded distinction, and _translate_refined_predicate for membership goal construction with @Nat base-invariant reintroduction.
Refined-first narrowing across callsites, blocks, destructures, and patterns
vera/verifier.py
Piped and direct call-argument narrowing prioritises refined-first discharge with generic instantiation recovery; constructor-field narrowing attempts refined-first obligations with fallback deriving component targets from expected types; effect-operation argument narrowing uses refined-first discharge; block handling introduces scoped block_assumptions preventing refined-parameter facts leaking across sibling scopes; let tuple destructuring refactored with per-component refined-first checks, conservative source-fact seeding, and explicit Tier-3 handling for unprojectable components; match and ADT sub-pattern logic computes arm-local facts as return values rather than shared mutation, splits refined vs @Nat obligations disjointly, and uses projection-aware accessor terms for discharge when possible, recording unprojectable refined outcomes as unguarded Tier-3 (excluded from totals).
Runtime refinement guard generation and postcondition ordering
vera/codegen/contracts.py
Adds _refinement_guard_parts resolving alias chains with cycle detection down to refined predicates, computing canonical binder base slot-names including generic instantiation, conjoining implicit @Nat >= 0 checks, returning None for @Unit, rejecting refinement-over-refinement with E600; _emit_refinement_check lowers predicates to WASM with contract-fail trap on falsehood and fail-closed E617 on lowering failure; _emit_component_refinement_guards handles tuple-component boundaries with recursion depth bounds (_MAX_COMPONENT_GUARD_DEPTH = 16); helpers _resolve_type_alias and _resolve_tuple_type support alias unwrapping and type inspection; _format_refinement_message formats violation diagnostics; postcondition compilation treats refined returns as implicit guards before explicit ensures, with special handling for i32_pair save/restore and tuple-return per-component boundary checks.
Function entry guard wiring and precondition sequencing
vera/codegen/functions.py
Function compilation records refined_param_checks and component_param_checks during parameter allocation, enforces @Unit parameter invariant rejecting refinement guards on non-WASM-representable values, and rewires function-entry instruction emission order: compiles requires(...) into precond_instrs, then builds refine_guard_instrs from component and top-level parameter guards, and sets pre_instrs = refine_guard_instrs + precond_instrs so runtime refinement guards execute before precondition checks.
Verifier, obligation, and tier-count test updates
tests/test_verifier.py, tests/test_obligations.py, tests/test_tester_coverage.py
Adds TestRefinementPredicateTranslation with _predicate_of helper and unit tests for _translate_refined_predicate covering binder/value substitution, builtin-call predicates, None returns for non-primitive bases, and @Nat invariant re-conjunction; TestRefinementPredicateVerification provides comprehensive end-to-end suite validating Tier-1 discharge and E505 violation reporting across call/let/binding/return positions, predicate-AST-matching exemption semantics with base-mismatch non-exemption, Tier-3 E506 outcomes for untranslatable refinements, and projection/destructure/rebind/generic soundness; four TestObligationKinds tests validate refine_bind status enumeration (verified, violated, tier3, tier3_unguarded), error-code assignment (E505, E506), counterexample recording, and _assert_summary_consistent bookkeeping; tier-count test updated (258 verified, 29 Tier-3, 287 total); test-tester-coverage fixture modified to exercise verifier-error classification under #746 with renamed function and predicate mismatch.
Codegen runtime guard trap and diagnostic test coverage
tests/test_codegen.py, scripts/check_spec_examples.py
Adds _run_refine_trap helper asserting refinement-guard failures raise RuntimeError with Refinement violation message; TestRefinementRuntimeGuards suite covers runtime trapping for refined parameters on entry and refined returns on exit across type aliases, valid values passing guards without false traps, transitive callee-guard rejection, generic post-monomorphisation guarding, evaluation-order assertions ensuring guards fire before dependent contracts, @Nat base-invariant conjunction, primitive-base boundary behaviour, and fail-closed E617 infinite-recursion handling; spec example line-number references updated to reflect specification additions.
Specification, versioning, release notes, and metric alignment
spec/02-types.md, CHANGELOG.md, HISTORY.md, KNOWN_ISSUES.md, README.md, ROADMAP.md, TESTING.md
Specification adds §2.6.4 Predicate Verification (assignability via base type, Tier-1 obligations at narrowing/return sites, parameter assumptions, counterexample/solver-failure diagnostics, non-decidable/non-primitive downgrades, binder-substitution restriction to primitive bases) and §2.6.5 Runtime Guards (entry/exit checks, contract-failure signalling, boundary composition, internal-narrowing Tier-3 distinction, correct-program guarantee); release notes document refinement predicate verification and runtime guards; roadmap tier-0 reordered with #746 completion and #732 exception noted; known-issue rows clarify nested sub-pattern guarding gap, generic-function ensures skipping alongside E502 underflow, and let-destructure E501 omission mechanism; spec example line references, README version (0.0.174) and test metrics (4,550), test aggregate counts, and test-file coverage narratives refreshed for warm/cold session semantics, #727 dedup pin, obligation families, and fail-closed infinite-recursion behaviour.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

  • aallan/vera#765: This PR implements verifier-side refinement predicate obligation discharge including for nested constructor sub-patterns via _obligate_subpattern_narrowings recursion, and documents in KNOWN_ISSUES.md a sound-but-runtime-unguarded limitation for nested constructor sub-pattern narrowing, which is the exact gap that issue #765 identifies.

Possibly related PRs

  • aallan/vera#716: The main PR builds directly on the Phase A reified proof-obligation machinery by extending vera/obligations/core.py with a new ObligationKind (refine_bind) and updating vera/verifier.py to record refinement narrowing/return-site obligations (E505/E506) into the same VerifyResult.obligations stream.
  • aallan/vera#756: The main PR generalises the existing @Nat binding-site narrowing/guard infrastructure into a broader refinement-predicate obligation + runtime-guard system, directly building on the same @Nat narrowing-site mechanics introduced in the retrieved PR.
  • aallan/vera#761: The main PR incorporates the same @Nat narrowing-guard robustness hardening and pinned generic-instantiated constructor-field runtime-deferral regression test, with direct overlap in guard/codegen/data-structure changes.

Suggested labels

compiler, tests, spec, ci, docs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title accurately summarises the primary change: implementing static and runtime verification of refinement-type predicates, closing issues #746 and #762 with version bump to v0.0.174.
Linked Issues check ✅ Passed All coding requirements from #746 and #762 are met: predicates translate to Z3 at narrowing sites with Tier-1/E505/E506 discharge [#746], runtime guards emit at boundaries with correct base-invariant handling [#762], aliased binder names resolve via ast.predicate_binder_name, ADT sorts unwrap correctly, and generic refined returns verify independently.
Out of Scope Changes check ✅ Passed All changes are directly scoped to #746 and #762 objectives: refinement-predicate static verification, runtime guards, supporting infrastructure (SMT, codegen, AST helpers), conformance tests, and documentation reflecting v0.0.174 release and known limitations.
Docstring Coverage ✅ Passed Docstring coverage is 95.07% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/746-refinement-predicates

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

@codecov

codecov Bot commented Jun 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.05527% with 46 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.67%. Comparing base (993963b) to head (a78c0dc).

Files with missing lines Patch % Lines
vera/verifier.py 90.88% 33 Missing ⚠️
vera/codegen/contracts.py 91.27% 13 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #763      +/-   ##
==========================================
+ Coverage   91.63%   91.67%   +0.04%     
==========================================
  Files          70       70              
  Lines       25463    26021     +558     
  Branches      321      321              
==========================================
+ Hits        23332    23855     +523     
- Misses       2123     2158      +35     
  Partials        8        8              
Flag Coverage Δ
javascript 65.33% <ø> (ø)
python 94.79% <92.05%> (-0.03%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
vera/verifier.py (3)

1560-1568: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Recover refined targets from the checker side-table, not only concrete formals.

These refined-first checks miss targets that are only known after desugaring or generic instantiation: piped calls still only use _nat_binding_target, and TypeVar formals/fields instantiated as PosInt never enter the refined path. Add a refined analogue of _nat_binding_target(arg, formal) and use it for pipe, call, constructor, and effect-op arguments so T = PosInt and left |> use() get E505/E506 instead of being skipped.

Suggested shape
+    def _refined_binding_target(
+        self, arg: ast.Expr, formal: Type | None
+    ) -> Type | None:
+        if formal is not None and self._is_refined_type(formal):
+            return formal
+        if formal is not None and not contains_typevar(formal):
+            return None
+        target = self._target_type_of(arg)
+        return target if target is not None and self._is_refined_type(target) else None
+
-                    if (formal is not None
-                            and self._is_refined_type(formal)
-                            and self._narrows_into_refined(arg, formal)):
+                    refined_target = self._refined_binding_target(arg, formal)
+                    if (refined_target is not None
+                            and self._narrows_into_refined(arg, refined_target)):
                         self._check_refined_binding_obligation(
-                            decl, arg, formal, smt, slot_env, assumptions,
+                            decl, arg, refined_target, smt, slot_env, assumptions,
                             site="call argument",
                         )

Also applies to: 1599-1605, 1635-1641, 1672-1678

🤖 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 `@vera/verifier.py` around lines 1560 - 1568, The `_nat_binding_target(arg,
None)` calls in the pipe/call/constructor/effect-op argument checking blocks
miss refined targets from desugaring and generic instantiation (e.g., TypeVar
formals instantiated as PosInt). Create a new refined analogue method (such as
`_nat_binding_target_refined`) that recovers refined targets from the checker
side-table, then replace or supplement the `_nat_binding_target` checks at all
four locations (in the pipe call block around line 1560, and the similar blocks
at lines 1599-1605, 1635-1641, and 1672-1678) with calls to this new method to
ensure E505/E506 errors are properly raised for refined instantiations.

657-684: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Do not let generic functions bypass refined return obligations.

The decl.forall_vars branch returns before the new refined-return check runs, so a generic function with a concrete refined return can silently produce an invalid inhabitant when its contracts are trivial, e.g. forall<T> fn bad(@t->@Posint) { 0 }. Please make the generic path still emit a refine_bind result for refined return types — verified when translatable, otherwise E506 — instead of returning before Line 984.

Also applies to: 973-984

🤖 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 `@vera/verifier.py` around lines 657 - 684, The early return statement in the
decl.forall_vars branch prevents refined return type verification from being
performed on generic functions. Add refined return type checking within the
generic function handling block before the return statement, ensuring that
refine_bind results are emitted for any refined return types in the function
signature. When a refined return type is present, record it as verified if it is
translatable to Z3, otherwise record it with error code E506 to prevent generic
functions with concrete refined returns from silently bypassing verification.

1797-1802: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Add refined projection obligations for tuple and ADT pattern binds.

Literal tuple destructures and top-level match binds are covered, but the projection paths still call Nat-only helpers. let Tuple<@Posint> = make_tuple(...) and match opt { Some(@Posint) -> ... } can therefore bind an Int field into a refined slot without any E505/E506; add a term-based refined checker analogous to _check_nat_binding_obligation_term and call it from the non-literal destructure and constructor sub-pattern walkers.

Also applies to: 1905-1912

🤖 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 `@vera/verifier.py` around lines 1797 - 1802, The method
_obligate_destructure_narrowings and related code around lines 1905-1912
currently only call Nat-only helpers for handling projection paths in
non-literal destructures, missing refined type projections for tuple and ADT
pattern binds. Create a new term-based refined checker method analogous to
_check_nat_binding_obligation_term that handles refined projections generally
(not just Nat), then update _obligate_destructure_narrowings and the constructor
sub-pattern walkers to call this new method from the non-literal destructure and
constructor sub-pattern walkers to ensure refined type obligations are generated
for cases like let Tuple<`@PosInt`> = make_tuple(...) and match opt {
Some(`@PosInt`) -> ... }.
🤖 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 `@spec/02-types.md`:
- Around line 253-255: The example using array_length in the refinement type
contradicts the specification because array_length is already listed as allowed
in the decidable fragment §2.6.1. Replace the array_length example with a
genuinely undecidable refinement predicate that actually falls outside the
decidable fragment, or alternatively rephrase the limitation description to
clarify that this is an unsupported lowering case rather than claiming the
refinement falls outside the decidable fragment. Ensure the example and
explanation are consistent with what is defined as permitted in §2.6.1.

In `@tests/test_verifier.py`:
- Around line 3661-3678: The test currently only asserts that at least one E506
warning exists but does not pin the expected count or verify there are no
errors. Tighten the assertion by replacing the generic check `assert warns,
"expected an E506 Tier-3 warning"` with a more specific check that verifies
exactly one E506 warning exists (using `len(warns) == 1`). Additionally, add an
assertion to verify that there are zero error diagnostics in the result object
to catch unexpected verifier errors that might otherwise be masked by the loose
assertion.

---

Outside diff comments:
In `@vera/verifier.py`:
- Around line 1560-1568: The `_nat_binding_target(arg, None)` calls in the
pipe/call/constructor/effect-op argument checking blocks miss refined targets
from desugaring and generic instantiation (e.g., TypeVar formals instantiated as
PosInt). Create a new refined analogue method (such as
`_nat_binding_target_refined`) that recovers refined targets from the checker
side-table, then replace or supplement the `_nat_binding_target` checks at all
four locations (in the pipe call block around line 1560, and the similar blocks
at lines 1599-1605, 1635-1641, and 1672-1678) with calls to this new method to
ensure E505/E506 errors are properly raised for refined instantiations.
- Around line 657-684: The early return statement in the decl.forall_vars branch
prevents refined return type verification from being performed on generic
functions. Add refined return type checking within the generic function handling
block before the return statement, ensuring that refine_bind results are emitted
for any refined return types in the function signature. When a refined return
type is present, record it as verified if it is translatable to Z3, otherwise
record it with error code E506 to prevent generic functions with concrete
refined returns from silently bypassing verification.
- Around line 1797-1802: The method _obligate_destructure_narrowings and related
code around lines 1905-1912 currently only call Nat-only helpers for handling
projection paths in non-literal destructures, missing refined type projections
for tuple and ADT pattern binds. Create a new term-based refined checker method
analogous to _check_nat_binding_obligation_term that handles refined projections
generally (not just Nat), then update _obligate_destructure_narrowings and the
constructor sub-pattern walkers to call this new method from the non-literal
destructure and constructor sub-pattern walkers to ensure refined type
obligations are generated for cases like let Tuple<`@PosInt`> = make_tuple(...)
and match opt { Some(`@PosInt`) -> ... }.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: da20dd76-ee2f-4d08-bcdd-1df036ad6c91

📥 Commits

Reviewing files that changed from the base of the PR and between 993963b and aaabc3b.

⛔ Files ignored due to path filters (8)
  • docs/SKILL.md is excluded by !docs/**
  • docs/index.html is excluded by !docs/**
  • docs/index.md is excluded by !docs/**
  • docs/llms-full.txt is excluded by !docs/**
  • docs/llms.txt is excluded by !docs/**
  • examples/refinement_types.vera is excluded by !**/*.vera
  • tests/conformance/ch02_refinement_types.vera is excluded by !**/*.vera
  • uv.lock is excluded by !**/*.lock, !uv.lock
📒 Files selected for processing (18)
  • CHANGELOG.md
  • HISTORY.md
  • KNOWN_ISSUES.md
  • README.md
  • ROADMAP.md
  • SKILL.md
  • TESTING.md
  • pyproject.toml
  • spec/02-types.md
  • tests/test_obligations.py
  • tests/test_tester_coverage.py
  • tests/test_verifier.py
  • vera/README.md
  • vera/__init__.py
  • vera/errors.py
  • vera/obligations/core.py
  • vera/types.py
  • vera/verifier.py

Comment thread spec/02-types.md Outdated
Comment thread tests/test_verifier.py
From the /review-pr pass (code-reviewer, silent-failure-hunter, pr-test-analyzer,
type-design-analyzer, comment-analyzer) — no correctness/soundness defects found;
these are accuracy + coverage improvements.

Documentation accuracy:
- Correct the Array-base Tier-3 reason in spec §2.6.4 and the example header: the
  cause is the non-primitive BASE (the predicate translator only encodes primitive
  bases), not "array_length is uninterpreted" (array_length IS translatable; the
  base check short-circuits before the predicate is reached).
- Fix the match-binding pattern syntax in spec §2.6.4 (`@PosInt`, not `@PosInt.0` —
  a binding pattern carries no slot index).
- Widen the `tier3_unguarded` ObligationStatus docstring: it is now shared by
  nat_bind (E504) and refine_bind (E506).

Test coverage (new verify-level tests for previously-untested binding sites — each
fires a refine_bind obligation, so a broken gate there would have shipped green):
- effect-operation argument, match binding, and tuple-destructure components
  (discharge + violation each); let-binding discharge direction.
- refine_bind Tier-3 bookkeeping: a non-primitive-base narrowing is excluded from
  summary.total and not counted as tier3_runtime (the total -= 1 path
  _assert_summary_consistent does not otherwise exercise).

Cosmetic: hoist PrimitiveType to the module-level import; pass list(assumptions)
at the return-position discharge to match the binding-site convention.

Test counts refreshed (4489).

Refs #746.

Co-Authored-By: Claude <[email protected]>

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ROADMAP.md (1)

23-24: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove the completed #746 item from Tier 0.

This work now belongs in HISTORY.md, not the forward-looking roadmap. Keeping it here makes the roadmap stale.

As per coding guidelines, completed items get deleted from ROADMAP.md and noted in HISTORY.md.

🤖 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 `@ROADMAP.md` around lines 23 - 24, Remove the completed item `#746`
(Refinement-type predicate verification) from the ROADMAP.md file since it
belongs in the completed items history, not the forward-looking roadmap. Delete
the entire row containing the `#746` issue and its description about user
refinement predicates from ROADMAP.md, then add this completed item with its
details to HISTORY.md to maintain an accurate record of finished work.

Source: Coding guidelines

🤖 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.

Outside diff comments:
In `@ROADMAP.md`:
- Around line 23-24: Remove the completed item `#746` (Refinement-type predicate
verification) from the ROADMAP.md file since it belongs in the completed items
history, not the forward-looking roadmap. Delete the entire row containing the
`#746` issue and its description about user refinement predicates from ROADMAP.md,
then add this completed item with its details to HISTORY.md to maintain an
accurate record of finished work.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b560e3d6-38d1-4320-904b-1fa314a949a5

📥 Commits

Reviewing files that changed from the base of the PR and between aaabc3b and cb33f4f.

⛔ Files ignored due to path filters (1)
  • examples/refinement_types.vera is excluded by !**/*.vera
📒 Files selected for processing (8)
  • README.md
  • ROADMAP.md
  • TESTING.md
  • spec/02-types.md
  • tests/test_obligations.py
  • tests/test_verifier.py
  • vera/obligations/core.py
  • vera/verifier.py

… (CR)

Addresses CodeRabbit's review (commit aaabc3b). The refined path now matches
the @nat path's #552/#747 site coverage everywhere it can be done soundly.

Coverage gaps closed (each fired no obligation before, so a violating value was
silently accepted):
- Piped arguments into a refined formal (`(0 - 5) |> use()`): the pipe branch
  only ran `_nat_binding_target`. Added a refined check via the new
  `_refined_binding_target(arg, formal)`, which also recovers a generic formal
  instantiated to a RefinedType (`T = PosInt`) from the checker side-table —
  wired at the pipe, call-argument, constructor-field, and effect-op sites.
- ADT sub-pattern binds (`match opt { Some(@Posint) -> }`) and non-literal
  tuple-destructure components (`let Tuple<@Posint> = f()`): the projection
  walkers called Nat-only helpers. Added a term-based
  `_check_refined_binding_obligation_term` (mirroring the @nat term checker)
  and a type-level `_refined_field_narrows` gate, wired into both walkers
  (refined-first, R9-disjoint; untranslatable/unprojectable -> E506 Tier-3,
  never a silent pass).

Deferred (documented): a concrete refined return on a *generic* function
(`forall<T> fn f(@t -> @Posint)`) is not obligated — the verifier's early
return for generics skips all static checks (#555). Noted on the #555 row.

Inline findings:
- spec §2.6.4: separate the two Tier-3 causes — a predicate outside the
  decidable fragment vs a non-primitive base the translator does not lower
  (array_length IS in the fragment; the base is the gate), so the array
  example no longer reads as a contradiction of §2.6.1.
- test_non_primitive_base_is_tier3_e506: assert zero error diagnostics and pin
  the E506 count to 1 (avoid a false pass if an unexpected error appears).

Tests: pipe / ADT-sub-pattern / non-literal-destructure discharge-or-exempt +
violation. Counts refreshed (4492); site assets regenerated.

Refs #746, #555.

Co-Authored-By: Claude <[email protected]>

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
vera/verifier.py (2)

3391-3441: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not treat Byte refinements as invariant-free primitive refinements.

Line 3417 says only @Nat contributes an intrinsic base invariant, but Byte is also range-constrained. Since the verifier declaration path has no Byte branch and falls through to declare_int, a predicate like { @byte|@Byte.0 < 256 } is checked over an unconstrained integer, producing false E505s for valid byte values. Either inject 0 <= value <= 255 for Byte and declare byte slots with the same bounds, or make unsupported primitive bases return None so they surface as E506 instead of using the wrong SMT semantics.

As per coding guidelines, Byte is an unsigned 8-bit integer with values 0–255.

🤖 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 `@vera/verifier.py` around lines 3391 - 3441, The
`_translate_refined_predicate` method currently only injects an intrinsic
invariant for `@Nat` (>= 0), but `@Byte` is also a range-constrained primitive
that needs similar treatment since it has bounds 0-255. After the existing check
for `base == NAT` that returns `z3.And(value_term >= 0, translated)`, add a
similar check for `Byte` that returns `z3.And(z3.And(value_term >= 0, value_term
<= 255), translated)` to ensure byte refinement predicates are checked against
the correct SMT semantics with the proper bounds constraint.

Source: Coding guidelines


998-1039: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Propagate refined return predicates to caller-side SMT assumptions.

Line 1022 records the callee’s refined return predicate as verified, but caller translation still receives only an unconstrained fresh base return value: vera/smt.py::_translate_call_with_info strips RefinedType to base_ret and only assumes explicit Ensures clauses. A caller using make_pos() as a @NonNeg argument, or proving an ensures from a @PosInt result, can therefore get a false E505/E500 unless the callee duplicated the refinement as an explicit ensures. Add the refined return predicate as a modular postcondition on the fresh call result, or synthesise an equivalent call-site assumption when translating calls returning RefinedType.

🤖 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 `@vera/verifier.py` around lines 998 - 1039, The verified refined return
predicate from lines 998-1039 is only recorded locally and not propagated to
callers, so when `_translate_call_with_info` in smt.py translates a call to this
function, it strips the RefinedType constraint and leaves the fresh return value
unconstrained. After the refined return predicate is verified and recorded by
`_record_obligation` around line 1022, you need to ensure this constraint is
also added to the caller-side SMT assumptions when a function returning a
RefinedType is called. Either synthesize a call-site assumption that encodes the
verified refined predicate on the fresh call result, or add it as a modular
postcondition that the SMT translation layer can use when processing calls to
functions with verified refined return types.
🤖 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.

Outside diff comments:
In `@vera/verifier.py`:
- Around line 3391-3441: The `_translate_refined_predicate` method currently
only injects an intrinsic invariant for `@Nat` (>= 0), but `@Byte` is also a
range-constrained primitive that needs similar treatment since it has bounds
0-255. After the existing check for `base == NAT` that returns
`z3.And(value_term >= 0, translated)`, add a similar check for `Byte` that
returns `z3.And(z3.And(value_term >= 0, value_term <= 255), translated)` to
ensure byte refinement predicates are checked against the correct SMT semantics
with the proper bounds constraint.
- Around line 998-1039: The verified refined return predicate from lines
998-1039 is only recorded locally and not propagated to callers, so when
`_translate_call_with_info` in smt.py translates a call to this function, it
strips the RefinedType constraint and leaves the fresh return value
unconstrained. After the refined return predicate is verified and recorded by
`_record_obligation` around line 1022, you need to ensure this constraint is
also added to the caller-side SMT assumptions when a function returning a
RefinedType is called. Either synthesize a call-site assumption that encodes the
verified refined predicate on the fresh call result, or add it as a modular
postcondition that the SMT translation layer can use when processing calls to
functions with verified refined return types.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7afd1e16-503c-44e0-83cc-a9d36aacfdde

📥 Commits

Reviewing files that changed from the base of the PR and between cb33f4f and 9282bab.

📒 Files selected for processing (7)
  • KNOWN_ISSUES.md
  • README.md
  • ROADMAP.md
  • TESTING.md
  • spec/02-types.md
  • tests/test_verifier.py
  • vera/verifier.py

aallan and others added 2 commits June 19, 2026 11:33
Addresses the user's pushback on deferring #762: the runtime guard does NOT
need new infrastructure. Vera already compiles boolean expressions to WASM
(translate_expr — how requires/ensures become runtime checks) and already has
the $vera.contract_fail host channel. A refinement predicate is structurally a
contract on the value, so the guard reuses both directly.

Implementation (codegen):
- `_refinement_guard_parts` / `_emit_refinement_check` (contracts mixin):
  resolve an alias chain to the underlying RefinementType, bind the predicate's
  `@<base>.0` binder to the value's local in a fresh slot env, translate the
  predicate, and trap via $vera.contract_fail when false.
- Refined parameters are guarded at function entry (after the explicit
  preconditions); refined returns at function exit (alongside postconditions,
  incl. the String i32_pair return by saving both halves around the check).

Coverage (verified end-to-end via `vera run`):
- Refined params over every primitive base (Int/Nat/Bool/Float64/String) trap
  on a violating value, pass on a valid one. This covers the public/FFI
  boundary — an untrusted caller can't bypass the callee's entry check.
- Refined scalar + String returns trap on a violating result.
- Call arguments are guarded transitively by the callee's entry guard (no
  separate call-site guard needed).
- Generic functions: the guard fires on the monomorphised instance, so a
  concrete refined return on a `forall<T>` fn is runtime-guarded even though
  its static obligation is skipped (#555).

Scope: refinements over a NON-PRIMITIVE base (e.g. `{ @array<Int> | ... }`)
stay Tier-3 statically (the predicate translator does not lower a collection
binder) and are not refinement-runtime-guarded — the underlying operations'
checks (e.g. array bounds) are the safety net. #762 is narrowed to exactly
that residual.

Docs updated to reflect runtime guards are implemented (spec §2.6.5,
KNOWN_ISSUES, vera/README, SKILL, CHANGELOG, example); the E506 reporter and
the #555 note corrected accordingly. 8 codegen runtime-guard tests added.

Refs #746. Narrows #762.

Co-Authored-By: Claude <[email protected]>
… refined returns

Addresses the user's direction to close #762 in this PR (not defer/narrow) plus
the latest CodeRabbit findings.

Runtime guards now cover EVERY refinement base, not just primitives (closes
#762):
- The guard compiles the predicate to WASM and resolves the binder by its
  canonical slot name (`Array<Int>`, not a bare `Array`) — the bug that made
  the array predicate silently fail to compile. Threading the function's slot
  env (rather than a bare one) gives a pair value its `(ptr, len)` context.
- Verified end-to-end: `head([])` into a `@NonEmptyArray` parameter, and a
  `@NonEmptyArray` return, both trap with a refinement-violation diagnostic
  (Z3 can't decide `array_length`, but codegen can).
- Accounting reconciled: a Tier-3 refinement narrowing is now recorded as a
  runtime-checked `tier3` (informational E506 "checked at run time"), not
  `tier3_unguarded`/excluded — every refinement narrowing is guarded, directly
  at the boundary or transitively. Example tier counts 258/29/287/0.

Refined returns propagate to callers (CodeRabbit): `_translate_call_with_info`
now assumes a refined return's predicate on the fresh call result, so a caller
can rely on a verified refined return (`let @Posint = mk(...); ...` now proves
`@PosInt.0 > 0`).

Other CodeRabbit findings:
- ROADMAP.md: removed the completed #746 row (it's in HISTORY).
- @byte intrinsic bound: NOT changed — `@Byte`'s 0-255 isn't modeled at
  declaration (no `declare_byte`; not masked in codegen), so conjoining it only
  into the refinement predicate would false-positive a `@Byte` source and
  mismatch the codegen guard. Proper enforcement is a separate `@Byte`-semantics
  task; `{@byte|P}` (no occurrences) checks P consistently in both tiers.

CI lint fix: refreshed the two stale `check_spec_examples.py` allowlist line
numbers (the §2.6.4/2.6.5 additions shifted the `forall sort`/`swap` blocks).

Docs updated to reflect #762 closed (spec §2.6.5, CHANGELOG, example; #762 rows
removed from KNOWN_ISSUES/README/SKILL). 2 array runtime-guard tests added.

Closes #762. Refs #746.

Co-Authored-By: Claude <[email protected]>
@aallan

aallan commented Jun 19, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai Thanks — addressed the three outside-diff findings:

  1. Pipe / generic-instantiated refined formals — added _refined_binding_target(arg, formal) (recovers a concrete refined formal and a generic formal instantiated to a RefinedType from the checker side-table) and wired it at the pipe, call-argument, constructor-field, and effect-op sites. (0 - 5) |> use() and a T = PosInt instantiation now obligate (E505/E506).

  2. Refined projection binds — added a term-based _check_refined_binding_obligation_term and a type-level _refined_field_narrows gate, wired into _obligate_subpattern_narrowings and _obligate_destructure_narrowings. match opt { Some(@PosInt) -> ... } on an Option<Int> and let Tuple<@PosInt, @Int> = f() over a Tuple<Int, Int> now raise E505; an already-refined source (Option<PosInt>, Tuple<PosInt, ...>) is R3-exempt.

  3. Generic refined return — the static obligation is still skipped for generics (Generic functions bypass E502 underflow obligation (follow-up to #520) #555: the verifier early-returns past all static checks for forall<T> functions). However, this PR now also emits a runtime refinement guard, and codegen monomorphises — so a concrete refined return on a generic function is runtime-guarded on its monomorphised instance (coerce$Int(@Int -> @PosInt) { 0 - 1 } traps). So the only residual is the verify-time static obligation, tracked under Generic functions bypass E502 underflow obligation (follow-up to #520) #555/Per-monomorphization static verification for generic functions #732.

@aallan

aallan commented Jun 19, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai Addressed the latest outside-diff findings in commit 5a2706f:

  1. ROADMAP.md Implement refinement-type predicate verification #746 row — removed. Implement refinement-type predicate verification #746 is closed by this PR; the completed item is recorded in the v0.0.174 row of HISTORY.md (Stage 14), per the project's "completed items move to HISTORY" convention.

  2. Refined return propagation to callers — fixed. _translate_call_with_info (vera/smt.py) now assumes a refined return type's predicate on the fresh call result, right after it assumes the callee's explicit ensures. So let @PosInt = mk(@Int.0); ... can now prove @PosInt.0 > 0 at the call site (verified end-to-end), making a refined return usable by callers rather than write-only. Sound because the producing function discharges the predicate at its return position (Implement refinement-type predicate verification #746).

  3. @Byte intrinsic invariant — declining: the conjoin is unnecessary here and would only add false positives. Unlike @Nat (where @Int -> @Nat narrowing IS permitted, so the >= 0 must be proved), the type checker rejects @Int -> { @Byte | P }: a bare @Int into a @Byte-based refinement is [E170]. The only source the checker admits is a @Byte value, which is already 0..255 by its own type — so the byte-ness is guaranteed by the type system and the predicate need only check P. Conjoining 0 <= v <= 255 would instead reject a valid @Byte source (the verifier doesn't model @Byte's bound at declaration — no declare_byte — so it can't prove a bound the value structurally has), closing no real gap. (A genuine @Int -> @Byte value-narrowing obligation would belong with modeling @Byte's bound throughout — a separate @Byte-semantics concern, not the refinement predicate.)

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@aallan — all three look correct, thank you for the detailed notes.

  1. Pipe / generic-instantiated refined formals: _refined_binding_target recovering both the concrete and checker-side-table-instantiated RefinedType is the right approach — it keeps the call-arg, constructor-field, effect-op, and pipe paths uniform and avoids special-casing generics at each site.

  2. Refined projection binds: _check_refined_binding_obligation_term + _refined_field_narrows at both _obligate_subpattern_narrowings and _obligate_destructure_narrowings is the correct split — type-level gate decides whether to obligate, term-level helper does the Z3 discharge. Predicate-AST equality for the already-refined exemption is properly strict.

  3. Generic refined return: the reasoning is sound. The verifier hard-exits for forall<T> functions at the existing #555 boundary, so the static obligation is genuinely unavailable here (not a silent pass). Runtime guard + monomorphisation (coerce$Int(@int->@Posint) trapping on -1) provides the required safety at the concrete call site. Tracking the static gap under #555/Per-monomorphization static verification for generic functions #732 is the right place.

No further concerns on this PR from my side.

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@aallan — all three are clear, thanks for the detailed write-up.

  1. ROADMAP.md #746 row — Understood; the project convention of retiring completed items to HISTORY.md is the right place for this. No concern here.

  2. Refined return propagation — The fix in _translate_call_with_info is the correct shape: the producing function discharges the predicate at its return position (the obligation introduced by #746), so the caller is entitled to assume it on the fresh result term, exactly as it would assume an explicit ensures. End-to-end verification of let @Posint = mk(@Int.0) confirms the wiring is live.

  3. @Byte intrinsic invariant — The declination is well-reasoned. The E170 gate at the type-checker phase means the verifier is never presented with a bare @Int value flowing into a @Byte-based refinement; every admitted source already carries @Byte typing, whose 0..255 bound is a type-system invariant rather than a verifier-modelled one. Without a declare_byte model, conjoining the range constraint would produce unprovable (and therefore noisy) obligations with zero soundness benefit. Withdrawn.

…unctions

Addresses the last of the six outside-diff CodeRabbit findings (verifier.py
657-684): a generic function's early return skipped ALL static checks, so a
concrete refined return like `forall<T> fn bad(@t -> @Posint) { 0 }` was only
runtime-guarded (on its monomorphised instance), never caught at verify time.

A concrete refined-return obligation is independent of the type parameter, so
`_check_generic_refined_return` sets up a minimal SMT context (TypeVar params
fall back to declare_int), translates the body, and discharges the predicate —
`{ 0 }` is now an E505, `{ 5 }` verifies. The rest of a generic body's
obligations still fall to Tier 3 (the broader #555/#732 per-monomorphization
work); the #555 note is updated accordingly.

Refs #746, #555.

Co-Authored-By: Claude <[email protected]>
@aallan

aallan commented Jun 19, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai Consolidated status on all six outside-diff findings (latest head 4847230):

  1. vera/verifier.py 1560-1568 — pipe / generic-instantiated refined formals. ✅ Fixed (9282bab). Added _refined_binding_target(arg, formal), which recovers a concrete refined formal and a generic formal instantiated to a RefinedType from the checker side-table, wired at the pipe, call-argument, constructor-field, and effect-op sites. (0 - 5) |> use() and T = PosInt now obligate.

  2. vera/verifier.py 657-684 — generic functions bypass refined-return verification. ✅ Fixed (4847230). A concrete refined return is type-parameter-independent, so _check_generic_refined_return sets up a minimal SMT context (TypeVar params fall back to declare_int), translates the body, and discharges the predicate: forall<T> fn bad(@T -> @PosInt) { 0 } is now an E505 statically, { 5 } verifies. (The rest of a generic body stays Tier 3 — the broader Generic functions bypass E502 underflow obligation (follow-up to #520) #555/Per-monomorphization static verification for generic functions #732 per-monomorphization work.)

  3. vera/verifier.py 1797-1802 — refined projection binds. ✅ Fixed (9282bab). Added the term-based _check_refined_binding_obligation_term + a type-level _refined_field_narrows gate, wired into _obligate_subpattern_narrowings and _obligate_destructure_narrowings. match opt { Some(@PosInt) -> } on Option<Int> and let Tuple<@PosInt, @Int> = f() over Tuple<Int, Int> now raise E505; an already-refined source is R3-exempt.

  4. ROADMAP.md 23-24 — remove completed Implement refinement-type predicate verification #746. ✅ Fixed (5a2706f). Removed from the roadmap; recorded in the v0.0.174 row of HISTORY.md.

  5. vera/verifier.py 3391-3441 — @Byte intrinsic invariant. Declining: unlike @Nat (where @Int -> @Nat narrowing is permitted, so >= 0 must be proved), the checker rejects @Int -> { @Byte | P } ([E170]). The only admissible source is a @Byte, already 0..255 by its type — so the byte-ness is guaranteed and the predicate need only check P. Conjoining 0 <= v <= 255 would only false-positive a valid @Byte source (the verifier doesn't model @Byte's bound at declaration), closing no real gap.

  6. vera/verifier.py 998-1039 — refined return not propagated to callers. ✅ Fixed (5a2706f). _translate_call_with_info (vera/smt.py) now assumes a refined return type's predicate on the fresh call result, so let @PosInt = mk(@Int.0); ... proves @PosInt.0 > 0 at the call site.

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@aallan — clean consolidated status, thanks. Notes on each:

  1. Pipe / generic-instantiated refined formals — acknowledged in the previous round; _refined_binding_target wired uniformly across all four sites is the right shape. ✅

  2. Generic refined-return — this is the one new fix since our last exchange. _check_generic_refined_return isolating the return-type obligation from the TypeVar parameters (falling back to declare_int for those) is exactly the right split: the refined return type is concretely known regardless of T, so there is no reason to defer the entire obligation to per-monomorphisation time. forall<T> fn bad(@t->@Posint) { 0 } producing E505 statically while the rest of the generic body remains Tier 3 is a sound and useful boundary. ✅

  3. Refined projection binds — acknowledged previously; term-based discharge + type-level gate, predicate-AST equality for the already-refined exemption. ✅

  4. ROADMAP.md #746 row — acknowledged previously; retired to HISTORY.md per project convention. ✅

  5. @Byte intrinsic invariant — declination stands. E170 at the type-checker phase means the verifier is never presented with a bare @Int flowing into a @Byte-based refinement; 0..255 is a type-system invariant, not a verifier-modelled one, so conjoining the range would produce false positives with no soundness gain. Withdrawn. ✅

  6. Refined return propagation to callers — acknowledged previously; _translate_call_with_info assuming the refined return predicate on the fresh result term, parallel to ensures. ✅

No further concerns. This is a solid closure of all six items.

@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: 5

🤖 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 `@CHANGELOG.md`:
- Line 13: The CHANGELOG.md entry contains two inaccuracies in the Tier-3
explanation section. First, correct the explanation that states Tier-3 warnings
are triggered by undecidable predicates like array_length - the actual reason
for Tier-3 is the non-primitive base type, not predicate decidability (the
array_length example is fine, but the reasoning is wrong). Second, clarify that
the runtime guard applies at every function boundary (refined parameters at
entry, refined returns at exit), not only at public/FFI boundaries - remove or
revise the parenthetical comment that suggests the guard only covers public/FFI
entry points.

In `@spec/02-types.md`:
- Around line 255-261: The Runtime Guards section currently states that the
compiler emits predicate checks only at public/FFI boundaries, but the actual
implementation emits guards at every function entry/exit. Revise the
documentation to clarify that refined parameters are checked at entry and
refined returns are checked at exit for all functions (not just public/FFI
boundaries), and that this broader scope provides more comprehensive coverage of
refinement violations. Update the relevant sentences in the paragraph to
accurately reflect that the guard applies universally at function boundaries
rather than limiting the statement to only public/FFI contexts.

In `@vera/codegen/contracts.py`:
- Around line 80-83: When translate_expr returns None for the predicate at the
guard creation point (where cond is None after calling
ctx.translate_expr(predicate, guard_env)), the code currently silently returns
None without any safeguard. Replace the silent return with a diagnostic emission
that indicates the refinement guard could not be lowered, then either skip
processing the function or raise an exception to fail closed. Apply this same
pattern to all other locations where refinement guards are created and
translate_expr is called (including the parameter guards handling), ensuring no
refinement boundary is left unguarded at runtime due to a failed predicate
translation.
- Around line 47-54: The current generic type formatting logic in the code that
builds arg_names for base.type_args only extracts the immediate type name
(ta.name) without recursively handling nested generics. This causes
Array<Option<Int>> to be formatted as Array<Option>, breaking predicate
resolution. Modify the logic that appends to arg_names to recursively format
nested type arguments: when processing each type argument ta, check if it has
its own type_args and recursively format them instead of just appending ta.name,
so the full nested structure like Option<Int> is preserved in the final name
string.

In `@vera/verifier.py`:
- Around line 2265-2282: The `_record_refined_bind_tier3()` method currently
increments `tier3_runtime` for all refined narrowing fallbacks, but this
conflates guarded sites (refined parameters at entry, refined returns at exit)
with unguarded sites (let bindings, destructures, match patterns, ADT fields,
effect arguments). To fix this, either add a `guarded` parameter to
`_record_refined_bind_tier3()` (mirroring the pattern used in
`_record_nat_bind_tier3()`) and only increment `tier3_runtime` when `guarded` is
True, or ensure codegen emits runtime guards for all local refinement sites.
Update all call sites to pass the appropriate `guarded` flag value based on
whether the refinement site has a corresponding codegen guard.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 96c91e48-6f3e-487e-b3c6-e02c747c7cde

📥 Commits

Reviewing files that changed from the base of the PR and between 9282bab and 5a2706f.

⛔ Files ignored due to path filters (2)
  • docs/llms-full.txt is excluded by !docs/**
  • examples/refinement_types.vera is excluded by !**/*.vera
📒 Files selected for processing (14)
  • CHANGELOG.md
  • KNOWN_ISSUES.md
  • README.md
  • ROADMAP.md
  • TESTING.md
  • scripts/check_spec_examples.py
  • spec/02-types.md
  • tests/test_codegen.py
  • tests/test_obligations.py
  • tests/test_verifier.py
  • vera/codegen/contracts.py
  • vera/codegen/functions.py
  • vera/smt.py
  • vera/verifier.py

Comment thread CHANGELOG.md Outdated
Comment thread spec/02-types.md
Comment thread vera/codegen/contracts.py
Comment thread vera/codegen/contracts.py
Comment thread vera/verifier.py Outdated
@aallan

aallan commented Jun 19, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai Re: the ROADMAP.md 23-24 outside-diff finding (review of cb33f4f) — Remove the completed #746 item from Tier 0 — done in commit 5a2706f. The #746 row is deleted from the Tier-0 table in ROADMAP.md, and the completed work is recorded in the v0.0.174 row of HISTORY.md (Stage 14), per the project's "completed items move to HISTORY" convention.

@aallan

aallan commented Jun 19, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai Re: the two vera/verifier.py outside-diff findings (review of 9282bab):

3391-3441Byte refinements need the 0..255 intrinsic. Declining: unlike @Nat (where @Int -> @Nat narrowing is permitted, so >= 0 must be proved), the type checker rejects @Int -> { @Byte | P } with [E170]. The only source it admits is a @Byte value, already 0..255 by its own type — so the byte-ness is guaranteed and the predicate need only check P. Conjoining 0 <= v <= 255 would only false-positive a valid @Byte source (the verifier doesn't model @Byte's bound at declaration — no declare_byte), closing no real gap.

998-1039 — refined return not propagated to callers. Fixed in commit 5a2706f. _translate_call_with_info (vera/smt.py) now assumes a refined return type's predicate on the fresh call result, right after the callee's explicit ensures, so let @PosInt = mk(@Int.0); ... proves @PosInt.0 > 0 at the call site (verified end-to-end).

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@aallan — confirmed. The #746 row is cleanly removed from the Tier-0 table and the completion is recorded in the v0.0.174 / Stage 14 row of HISTORY.md, consistent with the project convention. No further action needed here.

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@aallan — both are clear.

  1. @Byte intrinsic invariant (lines 3391–3441) — Declination is correct. The E170 gate at the type-checker phase means the verifier is never presented with a bare @Int flowing into a @Byte-based refinement; the only admitted source already carries @Byte typing, whose 0..255 bound is a type-system invariant rather than a verifier-modelled one. Without declare_byte, conjoining the range would produce unprovable obligations — false positives with no soundness gain. Withdrawn.

  2. Refined return propagation to callers (lines 998–1039) — The fix in _translate_call_with_info is the right shape. Assuming the refined predicate on ret_var immediately after the explicit ensures assumptions is sound precisely because the producing function discharges the predicate at its return position (the obligation introduced by #746). let @Posint = mk(@Int.0); ... being end-to-end verifiable confirms the wiring is live.

@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: 2

🤖 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 `@tests/test_verifier.py`:
- Around line 3936-3959: The "bad" case assertion is too loose, checking only
that at least one E505 exists without verifying the exact count or catching
spurious additional errors. In the
test_generic_concrete_refined_return_discharged method, replace the assertion
`assert errs, "expected E505 on the generic concrete refined return"` with a
tighter assertion that verifies exactly one E505 error exists and no other
error-level diagnostics are present. This should follow the pattern used in
test_non_primitive_base_is_tier3_e506, where the test asserts a specific count
of errors (e.g., `assert len([d for d in bad.diagnostics if d.error_code ==
"E505"]) == 1` and `assert [d for d in bad.diagnostics if d.severity == "error"]
== [<the E505>]`).

In `@vera/verifier.py`:
- Around line 2316-2341: The issue is that check_valid is being called with an
empty premise list, which means parameter predicates and requires clauses are
not being considered when validating the return type. To fix this, after setting
up the parameter variables in the loop over decl.params, collect the assumptions
from these parameters (their refined predicates) and any requires clauses from
the function declaration. Before calling smt.check_valid on the goal, pass these
collected assumptions as the second argument instead of the empty list, ensuring
the validity check properly seeds with the function's assumptions.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7aa5ad43-0f55-424a-bbe8-6647ff55c7ea

📥 Commits

Reviewing files that changed from the base of the PR and between 5a2706f and 4847230.

📒 Files selected for processing (6)
  • KNOWN_ISSUES.md
  • README.md
  • ROADMAP.md
  • TESTING.md
  • tests/test_verifier.py
  • vera/verifier.py

Comment thread tests/test_verifier.py
Comment thread vera/verifier.py Outdated
Two were real bugs I introduced:

- **Generic refined-return false positive** (verifier.py): `_check_generic_refined_return` discharged with an EMPTY premise, so a return justified by a refined param — `forall<T> fn keep(@Posint, @t -> @Posint) { @PosInt.0 }` — was a false E505. Now seeds the refined-param predicates and translatable `requires` clauses into the assumptions and passes them to `check_valid`. Verified, with a regression test.

- **Tier-3 accounting conflated guarded/unguarded sites** (verifier.py): `_record_refined_bind_tier3` counted ALL Tier-3 refinement fallbacks as `tier3_runtime`, but codegen only guards param/return boundaries. Added a `guarded` flag (mirroring `_record_nat_bind_tier3`): a return narrowing and a call argument (caught by the callee's entry guard) are guarded -> `tier3` (informational E506); an internal narrowing — let / ctor-field / effect-op-arg / match-bind / destructure / ADT-sub-pattern — is unguarded -> `tier3_unguarded`, excluded from totals, so the verifier no longer overstates runtime safety. Regression test added.

Doc-wording fixes:
- CHANGELOG + spec §2.6.5: the Tier-3 reason is the non-primitive base (not `array_length` being "undecidable"), and the runtime guard applies at every function boundary, not only `public`/FFI entry points.
- Hardened the generic-return "bad" test (pin E505 count, no other errors).

Declined (with reasons in the PR replies):
- Nested generic slot names: `_refinement_guard_parts` and `_translate_slot_ref` use the SAME one-level canonicalization, so they match — a nested `Array<Option<Int>>` guard resolves and fires (verified end-to-end), so the premise doesn't hold.
- Fail-closed on un-lowerable guards: refinement predicates are constrained to the §2.6.1 decidable fragment (whose only slot is the binder, and whose builtins all lower to WASM), so `_emit_refinement_check` returns None only for a predicate that wouldn't type-check — unreachable for valid programs.

Refs #746.

Co-Authored-By: Claude <[email protected]>
@aallan

aallan commented Jun 19, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

All seven inline threads from the previous round are confirmed above (5 fixes, 2 declines accepted). Requesting a fresh top-level review of the current head 100f938 so the walkthrough and actionable tally are stamped to it explicitly. Thanks!

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

@aallan — confirmed, all six outside-diff findings are resolved (five fixed, one declined on @Byte/#746 row which was withdrawn). Triggering a fresh full-review pass against 100f938 now.

✅ Action performed

Full review finished.

@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: 6

♻️ Duplicate comments (1)
spec/02-types.md (1)

259-259: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reword the Tier-3 rationale.

array_length(...) > 0 is still in the decidable fragment; the E506 here comes from the non-primitive @Array binder not being lowered, not from array_length being undecidable.

🤖 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 `@spec/02-types.md` at line 259, The explanation around line 259 incorrectly
attributes the Tier 3 classification to Z3 being unable to decide array_length.
Reword the rationale to clarify that array_length(...) > 0 is actually in the
decidable fragment and remains decidable; the Tier 3 classification instead
comes from the non-primitive `@Array` binder not being lowered, which causes error
E506, not from array_length being undecidable. Update the text to accurately
reflect that the limitation is in the lowering of the `@Array` binder, not in the
decidability of the comparison predicate itself.
🤖 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 `@tests/test_codegen.py`:
- Around line 5407-5424: The current test has a false positive because head([])
traps both due to the NonEmptyArray guard and due to the out-of-bounds indexing
when accessing `@NonEmptyArray.0`[0]. Modify the body of the head function to
perform an operation that would return normally for an empty array (such as
returning a default value without accessing the array, or checking the array
length), so that the test specifically demonstrates the refinement guard is
firing rather than just the indexing failing. This ensures the test proves the
guard is actually working as intended.

In `@vera/codegen/contracts.py`:
- Around line 228-234: The comment block explaining pair and array return guard
behavior is outdated and contradicts the current implementation. Update the
comment in the section starting with "# Pair returns (String/Array) don't
support general ensures checks" to correctly reflect that array refined returns
ARE now guarded by _refinement_guard_parts() when they have refinements, rather
than stating they get no guard. Remove or correct the statement about array
bases being non-primitive and getting no guard, as this no longer matches the
actual behavior demonstrated by test_array_return_guard_traps_on_empty.

In `@vera/codegen/functions.py`:
- Around line 198-217: The current code compiles preconditions first via
_compile_preconditions and then adds refinement parameter guards via the for
loop that processes value_local and param_te pairs. However, refinement guards
must execute before preconditions since a requires expression may depend on the
refined parameter invariant. Reorder the logic to collect refinement parameter
guards first (move the for loop before the _compile_preconditions call), then
compile the preconditions, and combine both sets of instructions with the
refinement guards appearing first in pre_instrs.

In `@vera/errors.py`:
- Line 504: The E506 error message currently states "not runtime-guarded" which
is inaccurate because E506 is raised for both guarded and unguarded Tier-3
refinement cases. Modify the error message string for E506 to remove the "not
runtime-guarded" claim and instead use guard-neutral wording that only describes
the core issue of unverified refinement narrowing, without making assumptions
about the presence or absence of runtime guards.

In `@vera/obligations/core.py`:
- Around line 68-73: The comment for tier3_unguarded contains outdated semantics
regarding E506 warnings for refinement narrowing. The current comment implies
that refine_bind refinement narrowing warnings only occur at sites with no
runtime guard, but the current refinement flow now emits E506 warnings for both
runtime-checked and non-runtime-checked Tier-3 sites. Update the comment to
accurately reflect that E506 can be emitted for refinement narrowing regardless
of whether a runtime guard is present, removing the outdated qualifier about "no
runtime guard yet".

In `@vera/verifier.py`:
- Around line 2351-2354: The Float64 parameter handling in the generic
refined-return path is incorrectly declaring float types as Int-sort variables
instead of Float64/Real-sort variables. In the conditional block starting with
`elif self._is_float64_type(param_ty):` around line 2351-2352, replace the
`smt.declare_int(z3_name)` call with the appropriate SMT float or real sort
declaration that matches the non-generic setup. This ensures that float values
like 0.5 are properly included in the refinement proof quantification.

---

Duplicate comments:
In `@spec/02-types.md`:
- Line 259: The explanation around line 259 incorrectly attributes the Tier 3
classification to Z3 being unable to decide array_length. Reword the rationale
to clarify that array_length(...) > 0 is actually in the decidable fragment and
remains decidable; the Tier 3 classification instead comes from the
non-primitive `@Array` binder not being lowered, which causes error E506, not from
array_length being undecidable. Update the text to accurately reflect that the
limitation is in the lowering of the `@Array` binder, not in the decidability of
the comparison predicate itself.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4a8fac17-2988-48c5-90f6-155ce8e13636

📥 Commits

Reviewing files that changed from the base of the PR and between 993963b and 100f938.

⛔ Files ignored due to path filters (7)
  • docs/index.html is excluded by !docs/**
  • docs/index.md is excluded by !docs/**
  • docs/llms-full.txt is excluded by !docs/**
  • docs/llms.txt is excluded by !docs/**
  • examples/refinement_types.vera is excluded by !**/*.vera
  • tests/conformance/ch02_refinement_types.vera is excluded by !**/*.vera
  • uv.lock is excluded by !**/*.lock, !uv.lock
📒 Files selected for processing (21)
  • CHANGELOG.md
  • HISTORY.md
  • KNOWN_ISSUES.md
  • README.md
  • ROADMAP.md
  • TESTING.md
  • pyproject.toml
  • scripts/check_spec_examples.py
  • spec/02-types.md
  • tests/test_codegen.py
  • tests/test_obligations.py
  • tests/test_tester_coverage.py
  • tests/test_verifier.py
  • vera/__init__.py
  • vera/codegen/contracts.py
  • vera/codegen/functions.py
  • vera/errors.py
  • vera/obligations/core.py
  • vera/smt.py
  • vera/types.py
  • vera/verifier.py

Comment thread tests/test_codegen.py Outdated
Comment thread vera/codegen/contracts.py Outdated
Comment thread vera/codegen/functions.py Outdated
Comment thread vera/errors.py Outdated
Comment thread vera/obligations/core.py Outdated
Comment thread vera/verifier.py
…d-return-from-match, generic hook, refinement-over-tuple

Full audit of all 18 outside-diff findings across the PR (the user flagged a
missed one). Verified each of the round's findings against current code; fixed
the valid ones, skipped the rest with reasons.

Fixed:
- smt.py refined-return binder (outside-diff, missed last round): `_translate_
  call` assumed a callee's refined return under `ret_type.base.name`, so an
  ALIAS-base refinement (`{ @Age | @Age.0 >= 18 }`, `type Age = Nat`) lost its
  predicate (`@Age.0` won't resolve against `Nat` → z3_pred None). Now uses the
  predicate binder via a SHARED `ast.predicate_binder_name` (the verifier's
  static method now delegates to it, so verifier/codegen/SMT can't drift).
  Confirmed: `needs_adult(mk_adult(...))` was E501, now clean.
- Refined return FROM a match arm (`Some(@Posint) -> @PosInt.0` returned as
  @Posint) false-E505'd on BOTH the normal and generic paths — the arm
  source-fact was a transient path condition, gone before the refined-return
  goal is checked. `_translate_match` now also adds a global
  `arm-matched => source-fact` implication, so the goal sees it. This is the
  real fix behind the generic-path finding; the generic fast path now also
  installs the `_subpattern_fact_hook` (so its match translation contributes
  the implication). Soundness pinned: `Option<Int>` payload as @Posint still E505.
- contracts.py refinement-OVER-tuple: `{ @tuple<PosInt, Int> | true }` carries
  no top-level Tuple shape, so its components crossed unguarded behind the
  refinement. New `_resolve_tuple_type` unwraps a refinement to the tuple, used
  by the emit helper (component now does both its predicate AND recursion), the
  guardable-components predicate, and the param collection. Recurses through
  `Tuple<Pair, Int>`. Confirmed: `use_pair(Tuple(-5,3))` now traps.

Skipped (with reason):
- pyproject/CHANGELOG "[0.0.174] missing": stale — the section and its compare
  link are both present (CR reviewed a pre-changelog commit).
- verifier.py 1939-2003 "destructure facts → first-pass SMT (false-E501)": the
  predicted false-E501 does NOT reproduce. `_translate_block` returns None at a
  `LetDestruct`, truncating SMT block translation, so a call after a destructure
  isn't modelled at all (neither false-E501 nor checked statically; the callee's
  own runtime `requires` still guards it). That's a pre-existing, refinement-
  independent gap in SMT block translation, orthogonal to this PR — flagged for
  a separate issue rather than expanded here.

Full suite 4513 passed; conformance 90; examples 35; mypy/ruff/site-assets/
doc-counts clean.

Co-Authored-By: Claude <[email protected]>
@aallan

aallan commented Jun 20, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai resume
@coderabbitai full review

@aallan

aallan commented Jun 20, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai Addressing the two outside-diff findings from the latest review:

vera/smt.py (refined-return binder, _translate_call) — fixed in 3948d5c. It assumed a callee's refined return under ret_type.base.name, so an alias-base refinement ({ @Age | @Age.0 >= 18 }, type Age = Nat) silently dropped its predicate (@Age.0 cannot resolve against Nat, so z3_pred was None). Now uses the predicate's binder via a shared ast.predicate_binder_name (the verifier's static method delegates to it, so the verifier/codegen/SMT binder recovery can't drift). Confirmed: needs_adult(mk_adult(...)) went from E501 to clean; a non-alias { @Nat | ... } return was already clean.

vera/verifier.py 1939-2003 (destructure facts → first-pass SMT) — skipping, because the predicted false-E501 does not reproduce. _translate_block returns None at a LetDestruct, which truncates SMT block translation entirely, so a call after a destructure (let @Tuple = src; needs_pos(@PosInt.0)) isn't modelled at all — it is neither a false-E501 nor statically checked (the callee's own runtime requires still guards it). I confirmed: the violating variant verifies clean (tier1, no E501) and the no-destructure baseline correctly E501s. That's a pre-existing, refinement-independent gap in SMT block translation (call preconditions after any destructure), orthogonal to this PR — I'd rather track it as its own issue than bolt a partial fix on here. Let me know if you'd prefer it filed.

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@aallan — both are clear.

vera/smt.py refined-return binder (3948d5c) — the fix is correct and the centralisation approach is the right design. ret_type.base.name was only correct for the non-alias case; an alias-base refinement like { @Age|@Age.0 >= 18 } has binder @Age, not @Nat, so z3_pred was silently None and the assumption was a no-op. Moving to a shared ast.predicate_binder_name with the verifier's static method delegating to the same implementation is the proper single source of truth — verifier, codegen, and SMT can't independently drift on binder resolution, which is exactly the class of bug this was. The needs_adult(mk_adult(...)) regression confirms the wiring is live.

vera/verifier.py 1939–2003 (destructure facts → first-pass SMT) — the deferral is well-reasoned. The actual behaviour (_translate_block returning None at LetDestruct, truncating all subsequent SMT modelling) is strictly weaker than a false positive; a call after a destructure is simply not statically checked rather than falsely flagged. Soundness is preserved by the callee's own runtime requires guard. The gap is pre-existing, refinement-independent, and fixing it properly requires _translate_block to handle LetDestruct with continuation — a non-trivial, scoped piece of work that doesn't belong here. Agreed: defer and track separately.

Would you like me to file a tracking issue for the _translate_block / LetDestruct continuation gap? I can open it against the repo with a link back to this PR and the relevant line range.

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@aallan — both look correct.

vera/smt.py refined-return binder (3948d5c) — the fix is the right shape. ret_type.base.name would yield Nat for type Age = Nat; { @Age|@Age.0 >= 18 }, so z3_pred silently became None and the refined-return postcondition was never assumed on the fresh result term. Recovering the binder via ast.predicate_binder_name (matching the predicate's own first SlotRef) is consistent with how _translate_refined_predicate handles the same alias case. Centralising onto the shared AST helper is the right move — it makes verifier, codegen, and SMT binder recovery a single point of truth, so a future rename or alias-chain extension doesn't require three-site coordination.

Destructure-facts gap (vera/verifier.py 1939-2003) — the skip reasoning is sound. _translate_block returning None at LetDestruct pre-dates this PR and affects all destructures uniformly; it isn't a refinement regression and doesn't produce a silent Tier-1 pass (the block is simply untranslated and the callee's runtime requires still guards the call). Patching it here would pull in a non-trivial block-translation fix that's orthogonal to #746. No objection to deferral.

Would you like me to file a tracking issue for the _translate_block / LetDestruct truncation gap? I can open it now with a pointer back to this PR and the relevant line range.

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

@aallan — resuming automatic reviews and triggering a fresh full pass against the current head now.

✅ Action performed

Reviews resumed.


Full review finished.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
vera/verifier.py (1)

865-866: ⚠️ Potential issue | 🟡 Minor

Block LetDestruct source facts do not reach SMT body translation.

vera/smt.py line 1246–1267 (_translate_block) returns None for LetDestruct statements without injecting source facts, whereas match arms receive refined sub-pattern facts via the _subpattern_fact_hook (lines 1273–1287) during translation. The verifier's narrowing walk (lines 1837–2003) does seed block_assumptions with _term_source_fact (lines 1997–2003), but this happens only in the second pass, after the initial body translation. A block like let Tuple<@Posint, @int> = @Box.0; needs_positive(@PosInt.0) will perform body translation without the destructured @PosInt binding's invariant (> 0) in scope, potentially triggering false E501/E505 on the call to needs_positive during the first SMT pass.

Fix: Either have _translate_block emit the equivalent source facts before translating the block's final expression (mirroring the match-arm pattern), or extend the narrowing walk to inject those facts into smt before the initial body translation at line 866.

Also applies to: 1939–2003

🤖 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 `@vera/verifier.py` around lines 865 - 866, The LetDestruct block processing in
_translate_block (vera/smt.py lines 1246-1267) does not inject destructured
pattern source facts before translating the block's body expression, unlike
match arms which use _subpattern_fact_hook to provide refined sub-pattern facts.
To fix this, modify _translate_block to extract and emit source facts from
LetDestruct pattern bindings (similar to how _subpattern_fact_hook injects facts
for match arms at lines 1273-1287) before calling smt.translate_expr on the
block body at line 866 in verifier.py. This ensures invariants from destructured
values (like `@PosInt` from Tuple destructuring) are available in the SMT context
during the initial body translation pass, preventing false E501/E505 errors on
dependent function calls.
vera/codegen/contracts.py (1)

45-49: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reuse the substituting alias resolver for refinement guard decisions.

Both alias walks still replace a NamedType with _type_aliases[name] without applying use-site type_args. For type Id<T> = T; type Small = { @id<Nat> | @id<Nat>.0 < 10 }, base_node remains the unresolved alias body instead of Nat, so the runtime guard omits the implicit >= 0 membership check and can accept -1 at a public boundary. Use _resolve_type_alias() here too so guard extraction shares the substituting alias path already added below.

Suggested fix
-        node: ast.TypeExpr = te
-        seen: set[str] = set()
-        while (isinstance(node, ast.NamedType)
-               and node.name in self._type_aliases
-               and node.name not in seen):
-            seen.add(node.name)
-            node = self._type_aliases[node.name]
+        node = self._resolve_type_alias(te)
@@
-                base_node: ast.TypeExpr = base
-                bseen: set[str] = set()
-                while (isinstance(base_node, ast.NamedType)
-                       and base_node.name in self._type_aliases
-                       and base_node.name not in bseen):
-                    bseen.add(base_node.name)
-                    base_node = self._type_aliases[base_node.name]
+                base_node = self._resolve_type_alias(base)

Also applies to: 77-83

🤖 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 `@vera/codegen/contracts.py` around lines 45 - 49, The while loop that walks
through type aliases is directly replacing the node with
_type_aliases[node.name] without applying use-site type_args substitution,
causing refinement guards to work with unresolved alias bodies instead of
properly substituted types. Replace the current alias resolution logic in this
while loop with a call to the _resolve_type_alias() method, which already
handles proper type argument substitution. Apply the same fix to the similar
alias resolution code at lines 77-83 to ensure consistent behavior and proper
guard extraction across all alias resolution paths.
🤖 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 `@TESTING.md`:
- Around line 61-63: The markdown table in TESTING.md has unescaped literal pipe
characters (|) within the table cell content in the coverage descriptions for
test_obligations.py, test_verifier.py, and test_codegen.py rows, which causes
the markdown parser to treat them as cell delimiters and break the table
structure. Fix this by escaping each literal pipe character in the cell content
with a backslash (change | to \|), or alternatively move the long coverage
descriptions outside the table into separate paragraphs or a different structure
to keep the table properly formatted.

In `@tests/test_codegen.py`:
- Around line 5605-5619: The test method
test_nested_refinement_over_tuple_guard_traps currently only validates the
violating case where a negative value fails the refinement guard on the nested
Pair type. To ensure component recursion does not over-trap valid values, add a
happy-path assertion by creating an additional test case or check that passes a
valid nested tuple value (such as Tuple with a positive integer in the inner
Tuple) to the entry function and verifies it executes successfully without
trapping, ensuring the guard logic correctly handles valid nested refinement
paths.

In `@tests/test_verifier.py`:
- Around line 3963-3979: The test test_generic_refined_return_from_match_arm
covers the positive case with Option<PosInt>, but a matching negative case with
Option<Int> is needed to ensure soundness and prevent generic-specific bugs from
laundering unrefined payloads. Add a new test method that mirrors
test_generic_refined_return_from_match_arm but uses Option<Int> instead of
Option<PosInt> in the function signature, parameter matching, and return type to
verify that the generic refined-return fast path properly rejects unrefined
payloads and produces appropriate error diagnostics.

---

Outside diff comments:
In `@vera/codegen/contracts.py`:
- Around line 45-49: The while loop that walks through type aliases is directly
replacing the node with _type_aliases[node.name] without applying use-site
type_args substitution, causing refinement guards to work with unresolved alias
bodies instead of properly substituted types. Replace the current alias
resolution logic in this while loop with a call to the _resolve_type_alias()
method, which already handles proper type argument substitution. Apply the same
fix to the similar alias resolution code at lines 77-83 to ensure consistent
behavior and proper guard extraction across all alias resolution paths.

In `@vera/verifier.py`:
- Around line 865-866: The LetDestruct block processing in _translate_block
(vera/smt.py lines 1246-1267) does not inject destructured pattern source facts
before translating the block's body expression, unlike match arms which use
_subpattern_fact_hook to provide refined sub-pattern facts. To fix this, modify
_translate_block to extract and emit source facts from LetDestruct pattern
bindings (similar to how _subpattern_fact_hook injects facts for match arms at
lines 1273-1287) before calling smt.translate_expr on the block body at line 866
in verifier.py. This ensures invariants from destructured values (like `@PosInt`
from Tuple destructuring) are available in the SMT context during the initial
body translation pass, preventing false E501/E505 errors on dependent function
calls.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d2016bb4-4863-4025-be49-bbf7a62164b9

📥 Commits

Reviewing files that changed from the base of the PR and between 88b4581 and 3948d5c.

📒 Files selected for processing (11)
  • CHANGELOG.md
  • README.md
  • ROADMAP.md
  • TESTING.md
  • tests/test_codegen.py
  • tests/test_verifier.py
  • vera/ast.py
  • vera/codegen/contracts.py
  • vera/codegen/functions.py
  • vera/smt.py
  • vera/verifier.py

Comment thread TESTING.md Outdated
Comment thread tests/test_codegen.py
Comment thread tests/test_verifier.py

@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

♻️ Duplicate comments (1)
vera/codegen/contracts.py (1)

45-49: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Apply generic alias substitution in the guard-decision walks.

These two alias walks still use the raw alias body, unlike _resolve_type_alias(). For type Id<T> = T; type Small = { @id<Nat> | @id<Nat>.0 < 10 }, base_node becomes T rather than Nat, so the implicit >= 0 membership guard is omitted and -1 can satisfy the boundary check.

Suggested fix
-        node: ast.TypeExpr = te
-        seen: set[str] = set()
-        while (isinstance(node, ast.NamedType)
-               and node.name in self._type_aliases
-               and node.name not in seen):
-            seen.add(node.name)
-            node = self._type_aliases[node.name]
+        node: ast.TypeExpr = self._resolve_type_alias(te)
@@
-                base_node: ast.TypeExpr = base
-                bseen: set[str] = set()
-                while (isinstance(base_node, ast.NamedType)
-                       and base_node.name in self._type_aliases
-                       and base_node.name not in bseen):
-                    bseen.add(base_node.name)
-                    base_node = self._type_aliases[base_node.name]
+                base_node: ast.TypeExpr = self._resolve_type_alias(base)

Also applies to: 77-83

🤖 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 `@vera/codegen/contracts.py` around lines 45 - 49, Apply generic alias
substitution in the two guard-decision walk functions at the specified
locations, similar to how _resolve_type_alias() handles type aliases. When
processing generic type aliases like Id<T>, ensure that type parameters are
properly substituted so that base_node correctly resolves to the concrete type
(e.g., Nat instead of T). This will ensure that implicit membership guards like
the >= 0 check are properly included and prevent invalid values like -1 from
satisfying boundary checks.
🤖 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 `@vera/codegen/functions.py`:
- Around line 245-265: The guard emission order for refined-tuple parameters
needs to be reordered to ensure component refinements are established before
top-level predicates execute. In the code section around the refined-tuple
parameter handling in functions.py, locate where top-level predicate guards are
emitted before _emit_component_refinement_guards is called. Reorder the emission
sequence so that _emit_component_refinement_guards executes first to establish
the component refinements, followed by the top-level refined guards, and ensure
both guard emissions occur before any requires(...) call. This ensures that
predicates like P in patterns such as { `@Tuple`<NonZero, Int> | P } have access
to properly established component refinements when they execute.

In `@vera/verifier.py`:
- Around line 1726-1732: At tuple construction and destructure sites (around
lines 1726-1732, 1948-1952, and 2885-2888), before extracting type_args from
tuple types, unwrap any RefinedType wrappers by accessing the base property
first. When processing tuples that may be wrapped in RefinedType (like {
`@Tuple`<PosInt, Int> | P }), use RefinedType.base to get the underlying AdtType
before reading component types, ensuring component obligations are properly
tracked instead of being skipped due to the refined type hiding the actual tuple
base.
- Around line 2721-2724: The current sub-pattern handling in the loops at lines
2721-2724 and 2794-2824 only processes direct BindingPattern children and does
not recursively handle ConstructorPattern children, causing nested patterns like
Some(Some(`@PosInt`)) to miss inner refine_bind obligations and source facts.
Refactor the sub-pattern obligation and fact generation logic into a recursive
helper function that accepts the projected field term and declared field type as
parameters, then have this helper recursively process both BindingPattern and
ConstructorPattern children so that nested constructor patterns generate the
appropriate refine_bind obligations and source facts at each nesting level.

---

Duplicate comments:
In `@vera/codegen/contracts.py`:
- Around line 45-49: Apply generic alias substitution in the two guard-decision
walk functions at the specified locations, similar to how _resolve_type_alias()
handles type aliases. When processing generic type aliases like Id<T>, ensure
that type parameters are properly substituted so that base_node correctly
resolves to the concrete type (e.g., Nat instead of T). This will ensure that
implicit membership guards like the >= 0 check are properly included and prevent
invalid values like -1 from satisfying boundary checks.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d2016bb4-4863-4025-be49-bbf7a62164b9

📥 Commits

Reviewing files that changed from the base of the PR and between 993963b and 3948d5c.

⛔ Files ignored due to path filters (7)
  • docs/index.html is excluded by !docs/**
  • docs/index.md is excluded by !docs/**
  • docs/llms-full.txt is excluded by !docs/**
  • docs/llms.txt is excluded by !docs/**
  • examples/refinement_types.vera is excluded by !**/*.vera
  • tests/conformance/ch02_refinement_types.vera is excluded by !**/*.vera
  • uv.lock is excluded by !**/*.lock, !uv.lock
📒 Files selected for processing (22)
  • CHANGELOG.md
  • HISTORY.md
  • KNOWN_ISSUES.md
  • README.md
  • ROADMAP.md
  • TESTING.md
  • pyproject.toml
  • scripts/check_spec_examples.py
  • spec/02-types.md
  • tests/test_codegen.py
  • tests/test_obligations.py
  • tests/test_tester_coverage.py
  • tests/test_verifier.py
  • vera/__init__.py
  • vera/ast.py
  • vera/codegen/contracts.py
  • vera/codegen/functions.py
  • vera/errors.py
  • vera/obligations/core.py
  • vera/smt.py
  • vera/types.py
  • vera/verifier.py

Comment thread vera/codegen/functions.py
Comment thread vera/verifier.py
Comment thread vera/verifier.py
aallan and others added 2 commits June 20, 2026 12:04
…ISSUES (#764)

Filed #764 for the pre-existing gap surfaced during the #763 review: a call's
precondition isn't statically checked when the call is at/after a let-destructure
(_translate_block returns None at the LetDestruct, truncating SMT body
translation), so it verifies tier1-clean though E501 never fires. Runtime-guarded
by the callee's own requires(...); narrowing obligations (separate AST walks) are
unaffected. Same class as #730, distinct root cause.

Added a Limitations row next to #730. Limitation-sync + doc-counts green.

Co-Authored-By: Claude <[email protected]>
… guard ordering + table/test fixes (CR re-review of 3948d5c)

CR's re-review of 3948d5c found 6 more; fixed 5, the 6th (Critical, heavy-lift)
is surfaced separately.

- (Major, false Tier-1) verifier: the tuple-construction and tuple-destructure
  obligation sites only accept `AdtType`, so a refinement OVER a tuple
  (`{ @tuple<PosInt, Int> | P }`) hid the `Tuple` base and skipped the component
  obligation — `use_pair(Tuple(@Int.0, 3))` verified clean though codegen
  runtime-traps it. Now unwraps `RefinedType.base` before reading `type_args` at
  both sites. Confirmed: that case is now E505 statically; the valid case clean.
- (Major, ordering) functions.py / contracts.py: emit the tuple-component
  boundary guards BEFORE the enclosing top-level refined-tuple predicate (param
  entry and return exit), so a refinement that reads its own components sees
  established values rather than running P against an unchecked FFI tuple.
- (formatting) TESTING.md: escaped the literal `|` in `{ @Age | ... }` /
  `{ @tuple | true }` refinement examples that were breaking the table.
- (tests) added a happy-path assertion to the nested refinement-over-tuple
  codegen test, and a generic-fast-path NEGATIVE match-arm soundness test
  (`Option<Int>` payload as `@PosInt` still E505).

Full suite 4514 passed; conformance 90; examples 35; mypy/ruff/site-assets/
doc-counts clean.

Co-Authored-By: Claude <[email protected]>
…atterns (CR finding 6 — closes an unguarded false Tier-1)

CR's Critical finding on 3948d5c: a refinement/@nat narrowing bound in a NESTED
constructor sub-pattern (`Some(Some(@Posint))` on `Option<Option<Int>>`) was
neither obligated nor runtime-guarded — an UNGUARDED false Tier-1. (Partially
pre-existing for @nat; #746 extended the non-recursive sub-pattern path to
refinements, widening it.) Maintainer chose the verifier-recursion fix.

- `_obligate_subpattern_narrowings` now recurses into nested ConstructorPattern
  sub-patterns via a new term-keyed `_obligate_subpattern_term`, obligating each
  inner narrowing against its projected field accessor. `_subpattern_source_facts`
  likewise recurses (`_subpattern_source_facts_term`) so a nested bind's source
  fact is carried. Cycle/infinite-type backstop at depth 16.
- Soundness: a verified program now rejects a bad nested narrowing — confirmed
  `Some(Some(@Posint))` on `Option<Option<Int>>` is E505; the valid
  `Option<Option<PosInt>>` case (no narrowing) stays clean (no over-obligation).
- The nested-bind RUNTIME guard stays a #758-class deferral (codegen's
  `_extract_constructor_fields` binds only direct sub-patterns) — filed #765 and
  added a KNOWN_ISSUES Limitations row. So a verified program is sound; an
  unverified compile is honestly unchecked at the nested site.

Tests: nested-narrowing-obligated (E505) + nested-no-false-positive (clean).
Full suite 4516 passed; conformance 90 (incl. ch04_match_nested); examples 35;
mypy/ruff/limitations-sync/doc-counts/site-assets clean.

Co-Authored-By: Claude <[email protected]>
@aallan

aallan commented Jun 20, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai Two outside-diff findings, both verified — declining both, with evidence:

vera/codegen/contracts.py _refinement_guard_parts alias walks — declining: the suggested swap to _resolve_type_alias() would break the guard. substitute_type_vars (which _resolve_type_alias uses) substitutes a RefinementType's base_type but deliberately leaves the predicate untouched (predicates are Expr, not TypeExpr — see its docstring). So for type NE<T> = { @Array<T> | array_length(@Array<T>.0) > 0 } instantiated as NE<Int>, it yields base Array<Int> but predicate still @Array<T>.0 — the guard's base-name key (Array<Int>) and the predicate's binder (@Array<T>) then mismatch, the predicate fails to resolve, and no guard is emitted. I verified this empirically: with the current consistent-unsubstituted walk, an empty array into an @NE<Int> param traps correctly; swapping in _resolve_type_alias makes it silently pass. The current walks keep base-name and predicate-binder consistent on purpose. The only residual is cosmetic — the trap message renders @Array<@T> rather than @Array<@Int> because the predicate Expr isn't type-substituted — not a functional gap, and not worth risking the guard to prettify.

_translate_block / destructure source facts → first-pass SMT — this is the same gap as #764, which I filed earlier this session. I verified it does NOT produce the false E501/E505 described: _translate_block returns None at a LetDestruct, truncating SMT body translation, so a call after a destructure isn't modelled at all (a violating needs_pos(@Int.1) is missed, not falsely flagged; a valid needs_pos(@PosInt.0) verifies clean — no false positive). Injecting destructure facts only helps once _translate_block is taught to translate past the destructure, which is the broader, pre-existing, refinement-independent fix tracked in #764.

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@aallan — both declinations are well-founded; withdrawing both.

_refinement_guard_parts alias walks: The TypeExpr / Expr split is the decisive constraint here. substitute_type_vars is typed to operate on TypeExpr nodes only; a RefinementType.predicate is an Expr tree and is left untouched by design. Using _resolve_type_alias for the alias walk therefore produces an incoherent result for a generic refinement alias: a substituted base_type (Array<Int>) paired with an unsubstituted predicate binder (@Array<T>), which is exactly the key/binder mismatch that silently suppresses the guard. The current walk keeping both unsubstituted is the correct design choice, not a gap. The cosmetic @Array<@t> in the trap message is an acceptable residual.

_translate_block / destructure continuation: Confirmed as the same pre-existing gap tracked in #764. A None return from _translate_block at LetDestruct truncates translation of the whole remainder of the block — subsequent calls are simply not modelled, not falsely checked. Source-fact injection cannot help here without first fixing the continuation; #764 is the right home for that work.


✏️ Learnings added
Learnt from: aallan
URL: https://github.com/aallan/vera/pull/763

Timestamp: 2026-06-20T12:59:48.967Z
Learning: In `vera/codegen/contracts.py`, `_refinement_guard_parts` deliberately walks alias chains without using `_resolve_type_alias()`. The reason: `substitute_type_vars` (used by `_resolve_type_alias`) is typed to operate on `TypeExpr` nodes only and leaves `RefinementType.predicate` (an `Expr` tree) untouched. For a generic refinement alias like `type NE<T> = { Array<T> | array_length(Array<T>.0) > 0 }` instantiated as `NE<Int>`, using `_resolve_type_alias` would yield base `Array<Int>` but predicate binder still `Array<T>`, causing a base-name/predicate-binder mismatch that silently suppresses the runtime guard. The current unsubstituted walks keep base-name and predicate-binder consistent on purpose. The cosmetic side effect is that trap messages render `Array<T>` rather than `Array<Int>`, which is not a functional gap.

Learnt from: aallan
URL: https://github.com/aallan/vera/pull/763

Timestamp: 2026-06-20T12:59:48.967Z
Learning: In `vera/smt.py` / `vera/verifier.py`, the `_translate_block` function returns `None` at a `LetDestruct` node, which truncates SMT body translation for the entire remainder of the block. This means any calls appearing after a `let`-destructure in the same block are not statically modelled (they are missed obligations, not false positives). This is a pre-existing, refinement-independent gap tracked in GitHub issue `#764`. Injecting destructure source facts into the truncated block is futile without first fixing `_translate_block` to handle continuation past `LetDestruct`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
vera/verifier.py (1)

2677-2680: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Unwrap refined ADT scrutinees before constructor-field instantiation.

_is_adt_type() now unwraps refined ADT bases for SMT declarations, but _instantiated_field_types() still requires the scrutinee itself to be an AdtType when substituting generic constructor fields. A scrutinee such as { @option<Int> | P } therefore returns None for Some, and the Some(@Posint) sub-pattern obligation/fact is skipped entirely.

🐛 Proposed fix
         if ci is None or ci.field_types is None:
             return None
         field_types = ci.field_types
+        if isinstance(scrut_ty, RefinedType):
+            scrut_ty = scrut_ty.base
         if ci.parent_type_params:
             # Generic constructor: its declared field types carry the parent's
             # TypeVars, which only the scrutinee's instantiation resolves.  If
🤖 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 `@vera/verifier.py` around lines 2677 - 2680, In the
_instantiated_field_types() function, the isinstance check for AdtType fails for
refined ADT scrutinees (like { `@Option`<Int> | P }) because they are not direct
AdtType instances. Before checking if scrut_ty is an AdtType with type_args,
unwrap the refined ADT base from the scrutinee using the same logic that
_is_adt_type() uses, then perform the isinstance check on the unwrapped type to
ensure refined ADT scrutinees are properly handled for constructor field type
instantiation.
vera/codegen/contracts.py (2)

43-49: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the substituting alias resolver for refinement guard decisions.

These two local alias walks still drop generic type arguments, even though _resolve_type_alias() below preserves them. A top-level generic refinement alias such as NonEmpty<T> = { @array<T> | ... } can therefore resolve to a predicate over Array<T> and return None at Line 64, skipping the boundary guard; similarly Id<Nat> will not be recognised as Nat-based for the implicit membership check.

Proposed fix
-        node: ast.TypeExpr = te
-        seen: set[str] = set()
-        while (isinstance(node, ast.NamedType)
-               and node.name in self._type_aliases
-               and node.name not in seen):
-            seen.add(node.name)
-            node = self._type_aliases[node.name]
+        node: ast.TypeExpr = self._resolve_type_alias(te)
@@
-                base_node: ast.TypeExpr = base
-                bseen: set[str] = set()
-                while (isinstance(base_node, ast.NamedType)
-                       and base_node.name in self._type_aliases
-                       and base_node.name not in bseen):
-                    bseen.add(base_node.name)
-                    base_node = self._type_aliases[base_node.name]
+                base_node: ast.TypeExpr = self._resolve_type_alias(base)

Also applies to: 77-83

🤖 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 `@vera/codegen/contracts.py` around lines 43 - 49, The manual type alias
resolution in the while loop that iterates through self._type_aliases drops
generic type arguments, whereas the _resolve_type_alias() method below preserves
them. This causes issues with generic refinement aliases like NonEmpty<T> losing
their type parameters during resolution. Replace the manual while loop that
walks through self._type_aliases with a call to _resolve_type_alias() instead.
This same issue also appears in another location (lines 77-83), so apply the
same fix to both occurrences to ensure generic type arguments are preserved for
refinement guard decisions.

110-129: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve Byte base membership in refinement guards.

Byte falls through to return (predicate, name) with only the user predicate. For { @byte|@Byte.0 < 10 }, an FFI/public caller can pass -1 or 300 and satisfy the lowered predicate without satisfying the Byte base range. Either conjoin 0 <= @Byte.0 <= 255 like the Nat invariant, or classify Byte as unguarded consistently rather than emitting a partial guard.

🤖 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 `@vera/codegen/contracts.py` around lines 110 - 129, The code currently handles
Unit and Nat base types with special guards in the boundary predicate check, but
Byte type is not explicitly handled and falls through with only the user
predicate, allowing invalid values outside the 0-255 range to pass. Add a new
conditional block after the Nat handling to detect when base_node is an
ast.NamedType with name equal to "Byte", and construct a BinaryExpr similar to
the Nat case that conjuncts two range checks (0 <= the slot reference AND the
slot reference <= 255) with the existing predicate, then return the combined
predicate with the name tuple.
♻️ Duplicate comments (1)
vera/verifier.py (1)

1955-1960: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unwrap refined tuple sources before seeding component facts.

This source-fact path still extracts type_args only from a bare AdtType. A guaranteed source typed as { @tuple<PosInt, Int> | P } binds fresh component slots but drops the source component invariant, so a later valid re-narrowing can false-E505. This appears to be the same tuple-unwrapping gap previously flagged for this area.

🐛 Proposed fix
                     src_tuple_ty = self._resolved_type_of(stmt.value)
+                    if isinstance(src_tuple_ty, RefinedType):
+                        src_tuple_ty = src_tuple_ty.base
                     src_args = (
                         src_tuple_ty.type_args
                         if isinstance(src_tuple_ty, AdtType)
🤖 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 `@vera/verifier.py` around lines 1955 - 1960, The code extracting type_args
from src_tuple_ty only handles bare AdtType instances but misses refined tuple
types like { `@Tuple`<PosInt, Int> | P }. Before the isinstance check for AdtType
in the block starting at line 1955, unwrap any type refinements or constraints
from src_tuple_ty to expose the underlying tuple type, then extract type_args
from the unwrapped tuple type. This ensures component invariants are preserved
when seeding component facts and prevents false E505 errors during later
re-narrowing of the source value.
🤖 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 `@tests/test_verifier.py`:
- Around line 3879-3880: Replace the loose `assert any(d.error_code == "E505"
for d in result.diagnostics if d.severity == "error")` assertions at both
locations with more precise checks that verify the exact count of E505 error
diagnostics and ensure no other unexpected error diagnostics are present.
Instead of just checking that at least one E505 exists, count the filtered
results (e.g., using `assert len([d for d in result.diagnostics if d.severity ==
"error" and d.error_code == "E505"]) == <expected_count>`) and separately assert
that the total error count matches expectations or that only E505 errors are
present in the error diagnostics to prevent false-positive passes from unrelated
error diagnostics.

In `@vera/verifier.py`:
- Around line 2732-2733: The depth guard using `_depth > 16` silently drops
obligations for deeper nested patterns by returning an empty list, which allows
legitimate pattern checking to be bypassed. Since the pattern AST is finite,
remove the depth cap check from the condition that includes `_depth > 16`,
allowing the obligation walk to continue at any depth. Alternatively, instead of
returning an empty list, emit an unguarded Tier-3 diagnostic to report the deep
nesting issue rather than silently ignoring it. Apply the same fix to the
similar condition also mentioned around lines 2779-2780.
- Around line 2881-2896: The nested constructor pattern recursion in the branch
checking `if sort is not None and idx is not None` only handles opaque path
cases but excludes literal constructor scrutinees where `lit_args` is set and
`sort` remains `None`. Add an additional condition to also recurse when handling
literal nested constructor arguments by checking if `lit_args` exists, and call
`_obligate_subpattern_term` with the appropriate literal argument accessor
instead of the sort-based accessor, ensuring the inner narrowing obligation is
verified for both opaque and literal nested constructors.

---

Outside diff comments:
In `@vera/codegen/contracts.py`:
- Around line 43-49: The manual type alias resolution in the while loop that
iterates through self._type_aliases drops generic type arguments, whereas the
_resolve_type_alias() method below preserves them. This causes issues with
generic refinement aliases like NonEmpty<T> losing their type parameters during
resolution. Replace the manual while loop that walks through self._type_aliases
with a call to _resolve_type_alias() instead. This same issue also appears in
another location (lines 77-83), so apply the same fix to both occurrences to
ensure generic type arguments are preserved for refinement guard decisions.
- Around line 110-129: The code currently handles Unit and Nat base types with
special guards in the boundary predicate check, but Byte type is not explicitly
handled and falls through with only the user predicate, allowing invalid values
outside the 0-255 range to pass. Add a new conditional block after the Nat
handling to detect when base_node is an ast.NamedType with name equal to "Byte",
and construct a BinaryExpr similar to the Nat case that conjuncts two range
checks (0 <= the slot reference AND the slot reference <= 255) with the existing
predicate, then return the combined predicate with the name tuple.

In `@vera/verifier.py`:
- Around line 2677-2680: In the _instantiated_field_types() function, the
isinstance check for AdtType fails for refined ADT scrutinees (like {
`@Option`<Int> | P }) because they are not direct AdtType instances. Before
checking if scrut_ty is an AdtType with type_args, unwrap the refined ADT base
from the scrutinee using the same logic that _is_adt_type() uses, then perform
the isinstance check on the unwrapped type to ensure refined ADT scrutinees are
properly handled for constructor field type instantiation.

---

Duplicate comments:
In `@vera/verifier.py`:
- Around line 1955-1960: The code extracting type_args from src_tuple_ty only
handles bare AdtType instances but misses refined tuple types like {
`@Tuple`<PosInt, Int> | P }. Before the isinstance check for AdtType in the block
starting at line 1955, unwrap any type refinements or constraints from
src_tuple_ty to expose the underlying tuple type, then extract type_args from
the unwrapped tuple type. This ensures component invariants are preserved when
seeding component facts and prevents false E505 errors during later re-narrowing
of the source value.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1e10f925-a4f4-46c5-87d9-ee0495636717

📥 Commits

Reviewing files that changed from the base of the PR and between f1f28e9 and 0dd385f.

📒 Files selected for processing (10)
  • CHANGELOG.md
  • KNOWN_ISSUES.md
  • README.md
  • ROADMAP.md
  • TESTING.md
  • tests/test_codegen.py
  • tests/test_verifier.py
  • vera/codegen/contracts.py
  • vera/codegen/functions.py
  • vera/verifier.py

Comment thread tests/test_verifier.py Outdated
Comment thread vera/verifier.py Outdated
Comment thread vera/verifier.py
…sion depth cap (CR re-review of 0dd385f)

CR posted 3 outside-diff + inline + a duplicate. Verified each; fixed 4, skipped 3.

Fixed:
- (Major, false Tier-1) `_instantiated_field_types`: the `isinstance(scrut_ty,
  AdtType)` gate failed for a REFINED ADT scrutinee (`{ @option<Int> | P }`), so
  a generic constructor's fields went uninstantiated and a sub-pattern narrowing
  was MISSED. Now unwraps `RefinedType.base` first — `Some(@Posint)` on a refined
  `Option<Int>` is E505; the valid `Option<PosInt>` case stays clean.
- (Major, false E505) the destructure source-fact seeding (`src_tuple_ty`) had
  the same gate; a REFINED tuple source (`{ @tuple<PosInt, Int> | P }`) wasn't
  unwrapped, so a component's source fact wasn't seeded and a later re-narrowing
  (`@PosInt.0` into `@NonNeg`) wrongly E505'd. Now unwraps the refined base.
- (depth cap) removed the `_depth > 16` cap from `_obligate_subpattern_term` /
  `_subpattern_source_facts_term`. The recursion descends the FINITE pattern AST
  (each step a strict sub-pattern), so it terminates without a cap — and the cap
  was a silent-drop for deep patterns.
- (tests) tightened the loose `assert any(E505)` soundness assertions to
  `assert [codes] == ["E505"]` (exact, no unrelated-error false-passes).

Skipped (with reason):
- `contracts._refinement_guard_parts` alias-walk -> `_resolve_type_alias`
  (duplicate of the prior round): would BREAK the guard. `substitute_type_vars`
  substitutes a refinement's base but not its predicate (Expr, not TypeExpr), so
  base-name and predicate-binder would mismatch -> no guard emitted (verified:
  an empty array into `@NonEmpty<Int>` stops trapping). Only cosmetic today.
- Byte base range guard: `{ @byte | P }` runtime guards hit a deeper codegen
  support gap (the type is unsupported, the guard doesn't compile at all), so a
  0..255 conjoin is premature dead code until Byte refinement codegen lands.
- literal nested constructor scrutinee (`match Some(Some(-5)) {}`): the literal
  isn't Z3-translated (scrut_z3 None) so the term recursion can't reach it, and
  the repro is entangled with the #755 Int<op>Nat->Nat mistyping. A degenerate
  case (no real program matches a literal); the opaque nested case IS obligated.

New tests: refined-ADT-scrutinee obligated + no-false-positive; refined-tuple-
source facts seeded. Full suite 4519 passed; conformance 90; examples 35;
mypy/ruff/doc-counts/site-assets clean.

Co-Authored-By: Claude <[email protected]>
@aallan

aallan commented Jun 20, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai The outside-diff + duplicate findings, all verified:

_instantiated_field_types — refined ADT scrutinee (outside-diff) — fixed in 0668499. The isinstance(scrut_ty, AdtType) gate failed for a refined ADT scrutinee ({ @Option<Int> | P }), so a generic constructor's fields went uninstantiated and the sub-pattern narrowing was MISSED — a false Tier-1. Now unwraps RefinedType.base first; confirmed Some(@PosInt) on a refined Option<Int> is E505, the valid Option<PosInt> case stays clean.

src_tuple_ty — refined tuple source (duplicate) — fixed in 0668499, same root cause. The destructure source-fact seeding had the same gate, so a refined tuple source ({ @Tuple<PosInt, Int> | P }) wasn't unwrapped and a later component re-narrowing (@PosInt.0 into @NonNeg) wrongly E505'd. Now unwraps the refined base.

_refinement_guard_parts alias walk -> _resolve_type_alias (outside-diff) — declining (duplicate of the prior round, same reason): it would BREAK the guard. substitute_type_vars substitutes a refinement's base_type but deliberately leaves the predicate (an Expr, not TypeExpr) untouched, so the base-name key (Array<Int>) and the predicate's binder (@Array<T>) would mismatch and no guard is emitted. Verified empirically: with the swap, an empty array into @NonEmpty<Int> stops trapping. The current consistent-unsubstituted walk is correct; the only residual is a cosmetic @T in the trap message.

Byte base range guard (outside-diff) — declining: { @Byte | P } runtime guards hit a deeper codegen support gap — the Byte type is unsupported and the guard doesn't compile at all (E602), regardless of the range. A 0 <= x <= 255 conjoin would be untestable dead code until Byte refinement codegen lands, so it's premature here.

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@aallan — all four are clear.

_instantiated_field_types unwrap (0668499) — the fix is the correct pattern. isinstance(scrut_ty, AdtType) failing silently on a RefinedType-wrapped scrutinee is the same class of miss as the earlier _is_adt_type / declare_adt unwrap from ef5ce41: a { @option<Int> | P } scrutinee is an AdtType modulo the refinement wrapper, so the gate must unwrap first. A missed field instantiation means the sub-pattern narrowing gets no obligation at all — a false Tier-1 that E505 on Some(@Posint) now correctly catches.

src_tuple_ty unwrap (same commit) — same root cause, same fix, same correctness argument. A refined tuple source ({ @tuple<PosInt, Int> | P }) carrying PosInt in its base isn't recognised as a tuple until the RefinedType.base is unwrapped; without that, the source-fact seeding skips the component, and a subsequent re-narrowing into @NonNeg has no > 0 fact to discharge against — false E505 on a valid pattern.

_refinement_guard_parts_resolve_type_alias (declining, duplicate) — consistent with the prior round withdrawal. The TypeExpr/Expr split makes this unsafe; already recorded.

@Byte base range guard (declining) — the reasoning is sound. With { @byte | P } codegen currently hitting E602 (Byte type unsupported), the guard emission path never reaches the predicate compilation stage regardless of what is conjoined. A 0 <= x <= 255 prefix would be dead code with no test coverage until @Byte refinement codegen is implemented; adding it now risks a latent correctness hazard when that work lands (e.g., a future declare_byte model whose 0..255 invariant is already structurally guaranteed would need the conjoin re-evaluated). Agreed on deferral.


✏️ Learnings added
Learnt from: aallan
URL: https://github.com/aallan/vera/pull/763

Timestamp: 2026-06-20T13:36:16.379Z
Learning: In `vera/codegen/contracts.py` and `vera/verifier.py`, runtime guards for `{ Byte | P }` refinements cannot be emitted because the `Byte` type is unsupported in codegen (E602 is raised). Conjoining a `0 <= x <= 255` range invariant into the guard predicate is premature and would produce untestable dead code until `Byte` refinement codegen is fully implemented. The `0..255` bound for `Byte` is a type-system invariant (guaranteed by the type checker), not a verifier-modelled one; there is no `declare_byte` model in the SMT layer.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

…_ISSUES (#766)

Filed #766 for the bug surfaced during the #763 review: a `{ @byte | P }`
parameter passes check/verify/compile but fails to instantiate at `vera run` —
the refinement guard lowers the predicate as an i64 comparison while the Byte
value is i32 (an i32/i64 stack-type mismatch wasmtime rejects). A plain @byte
param runs fine, so it's specific to the refinement guard. The CR range-conjoin
suggestion was declined because the guard doesn't compile at all; #766 covers
both the type fix and the 0..255 range conjoin.

Added a Bugs row. Loud failure (module won't instantiate), not a silent wrong
answer.

Co-Authored-By: Claude <[email protected]>
@aallan

aallan commented Jun 20, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai Follow-up on the Byte range-guard finding: I dug into why the guard doesn't compile and filed it as #766. Root cause confirmed from the WAT — @Byte is i32 but the refinement guard lowers the predicate as an i64 comparison (local.get i32 then i64.lt_s), an i32/i64 mismatch wasmtime rejects at instantiation. #766 covers both the type fix (widen i32→i64 or lower with i32 ops) and your 0 <= @Byte.0 <= 255 range conjoin, and it's recorded in KNOWN_ISSUES.

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@aallan — good root-cause diagnosis. The local.get i32 / i64.lt_s mismatch is exactly what you'd expect if the guard lowering path unconditionally selects the i64 comparison instruction based on the SMT sort (Inti64) without consulting the WASM type of the local being guarded. @Byte maps to i32 at the WASM level, so the comparison instruction must be either i32.lt_s (lower with i32 ops) or the local must be sign-extended to i64 before the i64.lt_s — either is valid, with the i32-ops path being the more direct fix.

#766 is the right home for both the type fix and the 0 <= @Byte.0 <= 255 conjoin; the conjoin only becomes meaningful once the comparison instruction matches the local's width. KNOWN_ISSUES recording is the correct handling for now.


✏️ Learnings added
Learnt from: aallan
URL: https://github.com/aallan/vera/pull/763

Timestamp: 2026-06-20T13:41:34.986Z
Learning: In `vera/codegen/contracts.py`, the root cause of `{ Byte | P }` refinement guard compilation failure (E602) is a WASM type mismatch: `Byte` locals are i32 but the guard lowering path unconditionally selects i64 comparison instructions (`i64.lt_s`) based on the SMT `Int` sort, producing an i32/i64 mismatch that wasmtime rejects at instantiation. The fix (tracked in GitHub issue `#766` on the `aallan/vera` repository) requires either lowering with i32 ops (`i32.lt_s`) or sign-extending the i32 local to i64 before the i64 comparison. The `0 <= Byte.0 <= 255` range conjoin (also in `#766`) is deferred until the type-width fix lands, since the conjoin is meaningless while the guard cannot compile.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

…ion (missed CR outside-diff)

Full audit of all 26 unique outside-diff findings across the PR: 25 were already
addressed (fixed / declined-with-evidence / tracked / in-code); 1 was genuinely
missed. CR re-flagged my (E)-fix's tautological `ensures(@Int.result ==
@Int.result)` as a Duplicate comment on 88b4581, then it dropped off on
line-drift without being picked up.

The ADT-sort regression test
(`test_refinement_over_adt_base_declared_with_adt_sort`) now uses a
NON-tautological postcondition `ensures(@Int.result == 0)` with body
`Pair(@int, @int) -> @Int.1 - @Int.1`, so the verifier must model the result
through the match projection and prove it cancels to 0 — genuinely exercising
the refined-ADT-sort declaration rather than passing vacuously.

mypy/ruff/doc-counts clean.

Co-Authored-By: Claude <[email protected]>
@aallan

aallan commented Jun 20, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai On the "Use a non-tautological postcondition to actually pin ADT-sort translation" finding (test_verifier.py, the refined-ADT-sort regression) — you were right, and I had missed it: my earlier fix used a tautological ensures(@Int.result == @Int.result), which can pass even if the projection path is unconstrained. Fixed in a78c0dc with your suggested shape — ensures(@Int.result == 0) and body Pair(@Int, @Int) -> @Int.1 - @Int.1, so the verifier must model the result through the match projection and prove it cancels to 0.

For the record, I re-audited every Outside-diff finding across the PR (26 unique). This was the only one that had slipped through — it was re-posted as a Duplicate on 88b4581 and then dropped on line-drift before I picked it up. The rest are all fixed, declined-with-evidence, or tracked (#764/#765/#766).

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@aallan — confirmed, the fix is the right shape. @Int.1 - @Int.1`` with ensures(@Int.result` == 0)` requires the verifier to actually resolve both field projections through the ADT sort — a tautology like `result == result` passes regardless of whether the ADT sort was declared correctly, making the regression test a no-op. The new form pins the projection path and the cancellation arithmetic, so a mis-sort (`declare_int` instead of the ADT sort) would break the field accessor and the test would catch it.

Good catch on the re-audit — 26 findings tracked and reconciled is a thorough sweep, and having #764/#765/#766 as homes for the three deferred items keeps the known gaps visible without blocking this PR.

@aallan aallan merged commit fa097f5 into main Jun 20, 2026
28 checks passed
@aallan aallan deleted the feat/746-refinement-predicates branch June 20, 2026 15:24
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.

Codegen runtime guards for general refinement predicates (#746 follow-up) Implement refinement-type predicate verification

1 participant