Skip to content

Commit 36bc81f

Browse files
authored
Merge pull request #36 from aallan/c6b-callee-preconditions
Add callee precondition verification (C6b) — closes #19
2 parents a188ed5 + 7794fa3 commit 36bc81f

12 files changed

Lines changed: 471 additions & 26 deletions

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ Each stage is a module with a single public API function (`parse_file`, `transfo
108108
### Testing
109109

110110
```bash
111-
pytest tests/ -v # Run all tests (540 tests)
111+
pytest tests/ -v # Run all tests (553 tests)
112112
mypy vera/ # Type-check the compiler
113113
python scripts/check_examples.py # All 14 examples must pass
114114
```
@@ -119,7 +119,7 @@ Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)`
119119

120120
- All 14 examples in `examples/` must pass `vera check` and `vera verify`
121121
- `mypy vera/` must be clean
122-
- `pytest tests/ -v` must pass (currently 540 tests)
122+
- `pytest tests/ -v` must pass (currently 553 tests)
123123
- Version must be in sync across `vera/__init__.py`, `pyproject.toml`, and `CHANGELOG.md`
124124

125125
### Contributing

CHANGELOG.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

77
## [Unreleased]
88

9+
## [0.0.11] - 2026-02-24
10+
11+
### Added
12+
- **Callee precondition verification** (C6b — closes #19): modular call-site contract checking
13+
- When function `f` calls function `g`, the verifier now checks that `g`'s `requires()` clauses hold at the call site given `f`'s assumptions
14+
- Callee postconditions (`ensures()`) are assumed at the call site, enabling symbolic reasoning about return values
15+
- Fresh Z3 variables created per call, with postconditions asserted — supports chained calls, let bindings, and recursive calls
16+
- Recursive functions (e.g., `factorial`) now verify `ensures()` at Tier 1 instead of falling to Tier 3
17+
- `CallViolation` dataclass in `smt.py` records call-site violations with callee name, precondition, and counterexample
18+
- `_report_call_violation()` in verifier produces LLM-oriented diagnostics with fix suggestions
19+
- `param_type_exprs` field added to `FunctionInfo` for callee parameter slot resolution
20+
- **Verifier tests**: 13 new tests — satisfied/violated/forwarded preconditions, assumed postconditions, recursive calls, trivial preconditions, let bindings, where-block calls, generic call fallback, multiple preconditions, sequential calls, error message quality (553 total, up from 540)
21+
22+
### Changed
23+
- Tier 3 warning rationale updated: "recursive calls" replaced with "generic calls" (recursive calls now handled via modular verification)
24+
- `SmtContext` constructor accepts optional `fn_lookup` callback for callee contract resolution
25+
- Caller precondition assumptions now asserted into the Z3 solver before body translation
26+
927
## [0.0.10] - 2026-02-24
1028

1129
### Added
@@ -208,7 +226,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
208226
- Grammar: handler body simplified to avoid LALR reduce/reduce conflict
209227
- `pyproject.toml`: corrected build backend, package discovery, PEP 639 compliance
210228

211-
[Unreleased]: https://github.com/aallan/vera/compare/v0.0.10...HEAD
229+
[Unreleased]: https://github.com/aallan/vera/compare/v0.0.11...HEAD
230+
[0.0.11]: https://github.com/aallan/vera/compare/v0.0.10...v0.0.11
212231
[0.0.10]: https://github.com/aallan/vera/compare/v0.0.9...v0.0.10
213232
[0.0.9]: https://github.com/aallan/vera/compare/v0.0.8...v0.0.9
214233
[0.0.8]: https://github.com/aallan/vera/compare/v0.0.7...v0.0.8

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ vera parse file.vera # Print the parse tree
1717
vera ast file.vera # Print the typed AST
1818
vera ast --json file.vera # Print the AST as JSON
1919

20-
pytest tests/ -v # Run the test suite (540 tests)
20+
pytest tests/ -v # Run the test suite (553 tests)
2121
mypy vera/ # Type-check the compiler itself
2222

2323
python scripts/check_examples.py # Verify all 14 examples parse + check + verify

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ The code generator compiles 6 of 14 examples. C6 extends WASM compilation to all
172172
| Sub-phase | Scope | Closes | Unlocks |
173173
|-----------|-------|--------|---------|
174174
| ~~C6a~~ | ~~Float64 — `f64` literals, arithmetic, comparisons~~ | ~~#25~~ | ~~Done (v0.0.10)~~ |
175-
| C6b | Callee preconditions — verify `requires()` at call sites | #19 | |
175+
| ~~C6b~~ | ~~Callee preconditions — verify `requires()` at call sites~~ | ~~#19~~ | ~~Done (v0.0.11)~~ |
176176
| C6c | Match exhaustiveness — verify all constructors covered | #18 ||
177177
| C6d | State\<T\> operations — get/put as host imports || increment.vera |
178178

@@ -465,7 +465,7 @@ vera/
465465
│ ├── errors.py # LLM-oriented diagnostics
466466
│ └── cli.py # Command-line interface
467467
├── examples/ # 14 example Vera programs
468-
├── tests/ # Test suite (540 tests)
468+
├── tests/ # Test suite (553 tests)
469469
├── scripts/ # CI and validation scripts
470470
│ ├── check_examples.py # Verify all .vera examples
471471
│ ├── check_spec_examples.py # Verify spec code blocks parse

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "vera"
3-
version = "0.0.10"
3+
version = "0.0.11"
44
description = "Vera: a programming language designed for LLMs, with full contracts, algebraic effects, and typed slot references"
55
readme = "README.md"
66
license = "MIT"

tests/test_verifier.py

Lines changed: 243 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,7 @@ def test_match_body_is_tier3(self) -> None:
453453
assert result.summary.tier1_verified >= 2
454454

455455
def test_recursive_call_is_tier3(self) -> None:
456-
"""Recursive function bodies can't be translated to Z3."""
456+
"""Recursive functions still have Tier 3 for decreases."""
457457
result = _verify("""
458458
fn factorial(@Nat -> @Nat)
459459
requires(true)
@@ -465,7 +465,7 @@ def test_recursive_call_is_tier3(self) -> None:
465465
else { @Nat.0 * factorial(@Nat.0 - 1) }
466466
}
467467
""")
468-
# ensures(@Nat.result >= 1) — body has recursive call → Tier 3
468+
# ensures(@Nat.result >= 1) — now Tier 1 via modular verification
469469
# decreases — always Tier 3 for now
470470
assert result.summary.tier3_runtime >= 1
471471

@@ -576,7 +576,8 @@ def test_mixed_tiers(self) -> None:
576576
}
577577
""")
578578
# requires(true) → Tier 1 trivial
579-
# ensures with recursive body → Tier 3
579+
# ensures — now Tier 1 via modular verification (recursive call
580+
# returns fresh Nat var with assumed postcondition)
580581
# decreases → Tier 3
581582
assert result.summary.total >= 3
582583
assert result.summary.tier1_verified >= 1
@@ -652,3 +653,242 @@ def test_boolean_logic_in_contract(self) -> None:
652653
effects(pure)
653654
{ @Int.0 }
654655
""")
656+
657+
658+
# =====================================================================
659+
# Call-site precondition verification (C6b)
660+
# =====================================================================
661+
662+
class TestCallSiteVerification:
663+
"""Modular verification: callee preconditions checked at call sites."""
664+
665+
def test_call_satisfied_precondition(self) -> None:
666+
"""Calling with a literal that satisfies requires(@Int.0 != 0)."""
667+
_verify_ok("""
668+
fn non_zero(@Int -> @Int)
669+
requires(@Int.0 != 0)
670+
ensures(true)
671+
effects(pure)
672+
{ @Int.0 }
673+
674+
fn caller(@Unit -> @Int)
675+
requires(true)
676+
ensures(true)
677+
effects(pure)
678+
{ non_zero(1) }
679+
""")
680+
681+
def test_call_violated_precondition(self) -> None:
682+
"""Calling with literal 0 violates requires(@Int.0 != 0)."""
683+
_verify_err("""
684+
fn non_zero(@Int -> @Int)
685+
requires(@Int.0 != 0)
686+
ensures(true)
687+
effects(pure)
688+
{ @Int.0 }
689+
690+
fn bad_caller(@Unit -> @Int)
691+
requires(true)
692+
ensures(true)
693+
effects(pure)
694+
{ non_zero(0) }
695+
""", "precondition")
696+
697+
def test_call_precondition_forwarded(self) -> None:
698+
"""Caller's precondition implies callee's — passes."""
699+
_verify_ok("""
700+
fn non_zero(@Int -> @Int)
701+
requires(@Int.0 != 0)
702+
ensures(true)
703+
effects(pure)
704+
{ @Int.0 }
705+
706+
fn safe_caller(@Int -> @Int)
707+
requires(@Int.0 != 0)
708+
ensures(true)
709+
effects(pure)
710+
{ non_zero(@Int.0) }
711+
""")
712+
713+
def test_call_postcondition_assumed(self) -> None:
714+
"""Caller's ensures relies on callee's postcondition."""
715+
_verify_ok("""
716+
fn succ(@Int -> @Int)
717+
requires(true)
718+
ensures(@Int.result == @Int.0 + 1)
719+
effects(pure)
720+
{ @Int.0 + 1 }
721+
722+
fn add_two(@Int -> @Int)
723+
requires(true)
724+
ensures(@Int.result == @Int.0 + 2)
725+
effects(pure)
726+
{ succ(succ(@Int.0)) }
727+
""")
728+
729+
def test_recursive_call_uses_postcondition(self) -> None:
730+
"""Recursive factorial: ensures(@Nat.result >= 1) now Tier 1.
731+
732+
The postcondition is assumed at the recursive call site,
733+
and base case returns 1, so result >= 1 is provable.
734+
"""
735+
result = _verify("""
736+
fn factorial(@Nat -> @Nat)
737+
requires(true)
738+
ensures(@Nat.result >= 1)
739+
decreases(@Nat.0)
740+
effects(pure)
741+
{
742+
if @Nat.0 == 0 then { 1 }
743+
else { @Nat.0 * factorial(@Nat.0 - 1) }
744+
}
745+
""")
746+
errors = [d for d in result.diagnostics if d.severity == "error"]
747+
assert errors == [], f"Expected no errors, got: {[e.description for e in errors]}"
748+
# ensures now Tier 1 (modular verification), decreases still Tier 3
749+
assert result.summary.tier1_verified >= 2
750+
751+
def test_call_trivial_precondition(self) -> None:
752+
"""Callee with requires(true) — always satisfied."""
753+
_verify_ok("""
754+
fn id(@Int -> @Int)
755+
requires(true)
756+
ensures(@Int.result == @Int.0)
757+
effects(pure)
758+
{ @Int.0 }
759+
760+
fn caller(@Int -> @Int)
761+
requires(true)
762+
ensures(@Int.result == @Int.0)
763+
effects(pure)
764+
{ id(@Int.0) }
765+
""")
766+
767+
def test_call_in_let_binding(self) -> None:
768+
"""Call result used via let binding, passed to second call."""
769+
_verify_ok("""
770+
fn succ(@Int -> @Int)
771+
requires(true)
772+
ensures(@Int.result == @Int.0 + 1)
773+
effects(pure)
774+
{ @Int.0 + 1 }
775+
776+
fn add_two_let(@Int -> @Int)
777+
requires(true)
778+
ensures(@Int.result == @Int.0 + 2)
779+
effects(pure)
780+
{
781+
let @Int = succ(@Int.0);
782+
succ(@Int.0)
783+
}
784+
""")
785+
786+
def test_where_block_call(self) -> None:
787+
"""Call to a where-block helper function."""
788+
_verify_ok("""
789+
fn outer(@Int -> @Int)
790+
requires(true)
791+
ensures(@Int.result == @Int.0 + 1)
792+
effects(pure)
793+
{ helper(@Int.0) }
794+
where {
795+
fn helper(@Int -> @Int)
796+
requires(true)
797+
ensures(@Int.result == @Int.0 + 1)
798+
effects(pure)
799+
{ @Int.0 + 1 }
800+
}
801+
""")
802+
803+
def test_generic_call_falls_to_tier3(self) -> None:
804+
"""Calls to generic functions bail to Tier 3."""
805+
result = _verify("""
806+
forall<T>
807+
fn id(@T -> @T)
808+
requires(true)
809+
ensures(@T.result == @T.0)
810+
effects(pure)
811+
{ @T.0 }
812+
813+
fn caller(@Int -> @Int)
814+
requires(true)
815+
ensures(true)
816+
effects(pure)
817+
{ id(@Int.0) }
818+
""")
819+
# id's contracts → Tier 3 (generic)
820+
# caller's body has generic call → body_expr is None
821+
# Since caller's ensures is trivial, it doesn't matter
822+
assert result.summary.tier3_runtime >= 1
823+
824+
def test_multiple_preconditions_all_checked(self) -> None:
825+
"""Two requires on callee, second one violated."""
826+
_verify_err("""
827+
fn guarded(@Int -> @Int)
828+
requires(@Int.0 > 0)
829+
requires(@Int.0 < 100)
830+
ensures(true)
831+
effects(pure)
832+
{ @Int.0 }
833+
834+
fn bad_caller(@Int -> @Int)
835+
requires(@Int.0 > 0)
836+
ensures(true)
837+
effects(pure)
838+
{ guarded(@Int.0) }
839+
""", "precondition")
840+
841+
def test_precondition_via_caller_requires(self) -> None:
842+
"""Caller's requires forwards two constraints to satisfy callee."""
843+
_verify_ok("""
844+
fn guarded(@Int -> @Int)
845+
requires(@Int.0 > 0)
846+
requires(@Int.0 < 100)
847+
ensures(true)
848+
effects(pure)
849+
{ @Int.0 }
850+
851+
fn good_caller(@Int -> @Int)
852+
requires(@Int.0 > 0)
853+
requires(@Int.0 < 100)
854+
ensures(true)
855+
effects(pure)
856+
{ guarded(@Int.0) }
857+
""")
858+
859+
def test_multiple_calls_in_sequence(self) -> None:
860+
"""Two calls in sequence, each gets a fresh return variable."""
861+
_verify_ok("""
862+
fn inc(@Int -> @Int)
863+
requires(true)
864+
ensures(@Int.result == @Int.0 + 1)
865+
effects(pure)
866+
{ @Int.0 + 1 }
867+
868+
fn add_two_seq(@Int -> @Int)
869+
requires(true)
870+
ensures(@Int.result == @Int.0 + 2)
871+
effects(pure)
872+
{
873+
let @Int = inc(@Int.0);
874+
inc(@Int.0)
875+
}
876+
""")
877+
878+
def test_violation_error_mentions_callee_name(self) -> None:
879+
"""Error message includes the callee function name."""
880+
errors = _verify_err("""
881+
fn non_zero(@Int -> @Int)
882+
requires(@Int.0 != 0)
883+
ensures(true)
884+
effects(pure)
885+
{ @Int.0 }
886+
887+
fn bad(@Int -> @Int)
888+
requires(true)
889+
ensures(true)
890+
effects(pure)
891+
{ non_zero(0) }
892+
""", "precondition")
893+
# Check that the error mentions the callee name
894+
assert any("non_zero" in e.description for e in errors)

vera/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ Every diagnostic includes a description (what went wrong), rationale (which lang
451451

452452
## Test Suite
453453

454-
**540 tests** across 8 files, plus 4 validation scripts and CI infrastructure.
454+
**553 tests** across 8 files, plus 4 validation scripts and CI infrastructure.
455455

456456
### Test files
457457

@@ -460,7 +460,7 @@ Every diagnostic includes a description (what went wrong), rationale (which lang
460460
| `test_parser.py` | 95 | 791 | Grammar rules, operator precedence, parse errors |
461461
| `test_ast.py` | 84 | 896 | AST transformation, node structure, serialisation |
462462
| `test_checker.py` | 91 | 950 | Type synthesis, slot resolution, effects, contracts |
463-
| `test_verifier.py` | 55 | 654 | Z3 verification, counterexamples, tier classification, Int→Nat enforcement |
463+
| `test_verifier.py` | 68 | 897 | Z3 verification, counterexamples, tier classification, Int→Nat enforcement, call-site preconditions |
464464
| `test_codegen.py` | 130 | 1,397 | WASM compilation, arithmetic, Float64, control flow, strings, IO, contracts, example round-trips |
465465
| `test_cli.py` | 67 | 832 | CLI commands (check, verify, compile, run), subprocess integration, runtime traps, arg validation |
466466
| `test_readme.py` | 2 | 68 | README code sample parsing |

vera/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Vera: a programming language designed for LLMs."""
22

3-
__version__ = "0.0.10"
3+
__version__ = "0.0.11"

vera/environment.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class FunctionInfo:
4242
effect: EffectRowType
4343
span: object | None = None # ast.Span
4444
contracts: tuple[object, ...] = () # ast.Contract nodes (for C4)
45+
param_type_exprs: tuple[object, ...] = () # ast.TypeExpr nodes (for C6b)
4546

4647

4748
@dataclass

vera/registration.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def register_fn(
4646
effect=eff,
4747
span=decl.span,
4848
contracts=decl.contracts,
49+
param_type_exprs=decl.params,
4950
)
5051

5152
if decl.where_fns:

0 commit comments

Comments
 (0)