Skip to content

Add "Heap reference monotone" post condition for procedures that modify the Heap#1099

Open
thanhnguyen-aws wants to merge 11 commits into
strata-org:mainfrom
thanhnguyen-aws:heapcountermono
Open

Add "Heap reference monotone" post condition for procedures that modify the Heap#1099
thanhnguyen-aws wants to merge 11 commits into
strata-org:mainfrom
thanhnguyen-aws:heapcountermono

Conversation

@thanhnguyen-aws
Copy link
Copy Markdown
Contributor

@thanhnguyen-aws thanhnguyen-aws commented May 1, 2026

Issue #, if available: #1098

Problem

When two distinct class instances are allocated on the heap, the analyzer cannot prove that their heap references are different. For example:

class ClassA:
    def __init__(self, n: int):
        self.val : int = n

a1 = ClassA(1)
a2 = ClassA(2)

a1.val = 1
a2.val = 2

assert a1.val != a2.val  # ❓ unknown — should be ✅ pass

Root cause

During the heap-parameterization pass (HeapParameterization.lean), every procedure that writes to the heap is rewritten to accept an input heap ($heap_in) and produce an output heap ($heap). However, there was no postcondition constraining the relationship between the two heaps' internal reference counters. Without such a constraint, the verifier cannot determine that references returned by separate allocations are distinct — a1 and a2 could be assigned the same heap reference, making a1.val != a2.val unprovable.

Solution

This PR injects a "heap reference counter monotone" postcondition into every heap-writing procedure during the heapTransformProcedure pass in HeapParameterization.lean.

How it works

Three new expressions are constructed before the body match:

Expression Definition Purpose
inHeapRef Heap..nextReference!($heap_in) The reference counter of the input heap state
outHeapRef Heap..nextReference!($heap) The reference counter of the output heap state
monoCond outHeapRef >= inHeapRef A Condition asserting the counter never decreases, with summary "Heap reference counter monotone"

This monoCond is prepended to the postcondition list of both Opaque and Abstract body variants. The other body kinds are unaffected:

  • Transparent — exposes its full implementation to the verifier, which can derive the monotonicity property directly from the body.
  • External — has no heap parameters, so the postcondition does not apply.

The postcondition tells the verifier that each heap-writing call advances (or at least preserves) the heap's reference counter. Since each allocation increments this counter, references obtained from different calls are guaranteed to be distinct.

Note: The base branch already disallows transparent bodies on non-functional procedures (merged in #1076), so Python functions (including __main__) are emitted with Opaque bodies. This ensures the monotonicity postcondition is present at every call boundary where heap allocations occur. An earlier revision of this PR included a PythonToLaurel.lean change to force Opaque bodies, but that was reverted since #1076 made it unnecessary.

Changes

File Change
Strata/Languages/Laurel/HeapParameterization.lean Construct monoCond and prepend it to Opaque and Abstract postcondition lists
StrataTest/Languages/Python/tests/test_heap_reference_monotone.py New test: the motivating example from #1098 — two ClassA instances with different values, asserting a1.val != a2.val
StrataTest/Languages/Python/run_py_analyze_sarif.py Register the new test
StrataTest/Languages/Python/expected_laurel/test_heap_reference_monotone.expected Expected output for the new test (5 passed, 0 failed, 0 inconclusive)
StrataTest/Languages/Python/expected_interpret/test_heap_reference_monotone.expected Expected interpreter output for the new test
18 existing expected_laurel/*.expected files Updated to include the additional "Heap reference counter monotone" verification results now emitted by the analyzer

Testing

  • New test (test_heap_reference_monotone.py): The motivating example from Python Class objects does not have unique Heap reference #1098. Two ClassA instances are allocated with different values, and assert a1.val != a2.val is verified successfully — the assertion now passes thanks to the monotonicity postcondition establishing that a1 and a2 have distinct heap references.
  • Existing tests: 18 expected-output files are updated to reflect the additional "Heap reference counter monotone" results now emitted by the analyzer. All changes are additive — no previously passing assertions regress.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@thanhnguyen-aws
Copy link
Copy Markdown
Contributor Author

@keyboardDrummer-bot Please rewrite and add more details to the PR description.

@keyboardDrummer-bot
Copy link
Copy Markdown
Collaborator

I've rewritten the PR description. Key improvements:

  • Removed the section about the PythonToLaurel.lean change, which was reverted and is no longer part of this PR. Added a note explaining that Various Laurel resolver improvements - including disallowing transparent procedures #1076 (already merged into main) made that change unnecessary.
  • Added a table summarizing the three new expressions (inHeapRef, outHeapRef, monoCond) and their purposes.
  • Added a file-level change summary table covering all 25 changed files.
  • Clarified which body variants are affected (Opaque, Abstract) and why the others (Transparent, External) are not.


procedure modifyContainerWithoutPermission1(c: Container, d: Container)
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ error: modifies clause does not hold
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ error: modifies clause could not be proved
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This error message changed from "modifies clause does not hold" to "modifies clause could not be proved". The former means the SMT solver found a counterexample; the latter means it timed out or returned unknown.

This is a diagnostic quality regression caused by the additional monotonicity postcondition making the VC harder for the solver. The same regression occurs at lines 130 and 155.

Please investigate whether the solver timeout can be avoided — e.g., by increasing the timeout for these VCs, or by structuring the postcondition differently so it doesn't interfere with the solver's ability to find counterexamples for unrelated assertions.

Copy link
Copy Markdown
Contributor Author

@thanhnguyen-aws thanhnguyen-aws May 11, 2026

Choose a reason for hiding this comment

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

I acknowledge this one and have been woking with ATS to find a solution for it. It is more complicated than I think.

Comment thread Strata/Languages/Laurel/HeapParameterization.lean Outdated
Comment thread Strata/Languages/Laurel/HeapParameterization.lean
Comment thread StrataTest/Languages/Python/tests/test_heap_reference_monotone.py
@@ -0,0 +1,7 @@
unknown location: ✅ pass - Heap reference counter monotone
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The unknown location prefix confirms that the synthesized postcondition has no source location. This will be confusing for users when the monotonicity check fails — they won't know which procedure it relates to.

Also: this test has two Heap reference counter monotone results (one for ClassA@__init__ and one for __main__). In a program with many classes, this could produce a lot of noise. Consider whether the monotonicity postcondition should be suppressed from user-facing output (e.g., by marking it as an internal/infrastructure check).

Copy link
Copy Markdown
Contributor

@tautschnig tautschnig left a comment

Choose a reason for hiding this comment

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

Earlier feedback — status:

  • ⚠️ Thread #1 (diagnostic regression "does not hold" → "could not be proved" in T2_ModifiesClauses.lean:71/130/155) — still open, no response from the author. This is a real SMT solver observability regression: the monotonicity postcondition is making the solver slower on previously-fast counterexample-finding VCs, and the tests lost a tighter error-message assertion. I'd like the author to acknowledge it even if the fix is deferred; the new test expectations entrench the weaker diagnostic.

  • ⚠️ Thread #2 (source location + proof opportunity) — marked resolved. The procedure-name half was addressed (summary now reads Heap reference counter monotone ({proc.name.text}) in d23b9b7c6), but the proof opportunity part was not. My earlier review proposed theorems for updateField (preserves nextReference) and increment (increases it by 1); the PR adds an axiomatic postcondition on Opaque (impl = none) and Abstract bodies that is only sound because of these unit lemmas. Since thread was resolved without them, flagging here. Proof skeleton suggestions below under "Proof coverage".

  • ⚠️ Thread #5 (unknown location + noise) — still open. The procedure-name suffix change in d23b9b7c6 will help when it's visible, but:

    1. Every expected_laurel/*.expected file currently shows unknown location: ✅ pass - Heap reference counter monotone (no proc-name suffix). These are probably stale and need regeneration once CI is green — worth confirming whether the summary change in d23b9b7c6 actually flows through to the output format before declaring the noise concern addressed.
    2. The underlying unknown location is still there because the synthesized monoCond has source := none. Cheap fix: attach the procedure's source location (if available) so the synthetic postcondition traces back to the procedure declaration. proc.source (if that's the accessor) or proc.name.source.
    3. The per-procedure one-line-per-test noise concern is unaddressed. In a 20-class Python program there will be 40+ monotonicity lines. Consider a category := .Internal / summary.mkInternal marker that is filtered from the default display.

Proof coverage — suggested theorems for a HeapParameterizationCorrect.lean follow-up. The monotonicity axiom is only sound because of two facts about heapTransformExpr:

-- Informal: updateField preserves the reference counter.
theorem updateField_preserves_nextReference
    (h : Heap) (ref : Reference) (field : String) (v : Value) :
    Heap.nextReference (updateField h ref field v) = Heap.nextReference h := by
  sorry  -- unfold updateField; by cases on h; rfl or by definition

-- Informal: increment increases the reference counter by 1.
theorem increment_succ_nextReference (h : Heap) :
    Heap.nextReference (increment h) = Heap.nextReference h + 1 := by
  sorry  -- by definition of increment

-- Informal: heapTransformExpr only produces heap states obtained from
-- the input heap by a finite sequence of updateField/increment calls.
-- Hence nextReference is monotone across any heapTransformExpr step.
theorem heapTransformExpr_nextReference_monotone
    (ctx heapIn model expr heapOut) :
    heapTransformExpr heapIn model expr = .ok (expr', heapOut) →
    Heap.nextReference heapOut ≥ Heap.nextReference heapIn := by
  sorry  -- induction on expr; cases on StmtExpr constructors; 
         -- each case either preserves (updateField) or increases (increment)

With those three lemmas the axiomatic postcondition on Opaque / Abstract becomes a derived fact, not a trust assumption. At minimum the first two should be straightforward rfl-ish proofs.

Additional test coverage I'd like to see before merge (independent of CI fix):

  1. Negative test for the "does-not-hold" → "could-not-be-proved" regression. If solver timeout is the root cause, add a directive -- KnownIssue: modifies-clause-unknown-result or a @[timeout ...] attribute on the affected procs that pins the regression. Otherwise this silently becomes the expected behaviour.

  2. A test where monotonicity is violated by a fabricated Opaque body — i.e., an impl that decreases nextReference. This should fail verification with the new postcondition. Validates that the postcondition is actually being enforced on Opaque bodies with an impl, not just axiomatically assumed.

  3. An Abstract-body test. The T1 test exercises Opaque; there's no test pinning that the postcondition is injected on Abstract bodies too (and it has to be, per the match branch in heapTransformProcedure).

  4. Regression test for the original issue #1098. The Python-level test_heap_reference_monotone.py covers the user-visible bug, but a minimal Laurel-level test (a := new C; b := new C; assert a != b without any heap-writing procedure in between, purely inside the caller) would isolate the monotonicity postcondition from any incidental logic.

Refactoring suggestions:

  • The monoCond construction duplicates the heap-name-identifier idiom already used at line 273 in the same file (mkMd (.Var (.Local heapVar))). Consider a tiny helper heapRef (name : Identifier) : AstNode StmtExpr := mkMd (.Var (.Local name)) local to this file, used at all the call sites. Would also have made the .Identifier.Var (.Local) migration a one-place change.

  • The axiomatic-safety comment pattern duplicated across .Opaque and .Abstract arms is a sign that both arms are applying the same transformation. Consider extracting prependMonoCond : List (AstNode Condition) → List (AstNode Condition) (or the metadata-preserving equivalent) and calling it in both branches. One source of truth for the monotonicity injection.

Comment thread Strata/Languages/Laurel/HeapParameterization.lean Outdated
Comment thread Strata/Languages/Laurel/HeapParameterization.lean
thanhnguyen-aws and others added 3 commits May 6, 2026 11:37
Co-authored-by: Michael Tautschnig <mt@debian.org>
Co-authored-by: Michael Tautschnig <mt@debian.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants