Getting Started with Deppy

Deppy verifies existing Python code — you do not need to rewrite anything or learn a new language. You keep writing ordinary Python with standard type hints, add lightweight specification decorators, and Deppy checks that the code actually satisfies those specifications using Z3, cubical homotopy type theory, and optional sidecar proofs.

This chapter walks you through installation, your first verified function, and how to read the verifier's output.

Python version. Deppy requires Python 3.10 or later. It relies on match statements, ParamSpec, and several newer typing features. We recommend Python 3.12+ for the best experience.

Installation

Install Deppy from GitHub:

# Install directly from GitHub
pip install git+https://github.com/thehalleyyoung/deppy.git

# — or — clone and install in editable mode (for development)
git clone https://github.com/thehalleyyoung/deppy.git
cd deppy
pip install -e .

# Optional but strongly recommended — enables Z3-backed proofs
pip install z3-solver

Without z3-solver Deppy can still run in LLM-judged mode, but you will not get the strongest (Z3_VERIFIED) trust level.

Confirm the installation:

python3 -c "from deppy import guarantee, verify; print('ready')"
# ready

Your First Verified Function

Create a file called mymath.py with the following contents. This is plain Python — the only addition is the @guarantee decorator that states what the function promises to its callers.

from __future__ import annotations
from deppy import guarantee


@guarantee("result >= a and result >= b and (result == a or result == b)")
def max_of(a: int, b: int) -> int:
    """Return the larger of two integers."""
    if a >= b:
        return a
    else:
        return b

Notice that the function body is completely ordinary Python. There are no proof terms, no monadic wrappers, and no special base classes. Deppy reads the AST, extracts proof obligations from the @guarantee specification, and discharges them automatically.

Running the Verifier

Deppy ships with two ways to invoke the verifier on a Python module: a .deppy sidecar file (best for verifying libraries you don't control) or the decorator form (best when the module carries its own @verify annotations). Both feed the same kernel pipeline.

# Verify via a sidecar (mymath.deppy beside mymath.py)
python -m deppy.pipeline.run_verify mymath.deppy --out mymath.lean

# Or verify a module that carries decorators inline
python -m deppy.pipeline.run_verify --from-module mymath --out mymath.lean

You should see output like this (truncated):

  Foundations: N / N Z3-attempted / verified
  ProofTerm subclasses (C1–C7 audit batch):
    constructed / kernel-accepted:         N / N
  ``verify`` blocks (.deppy → kernel.verify):
    accepted by ProofKernel.verify:        N
  ────────────────────────────────────────────────
  deppy.lean.lean_runner round-trip:       OK
  ────────────────────────────────────────────────
  ✓ CERTIFIED — top-level certify_or_die: PASS
  ════════════════════════════════════════════════
  Certificate at: mymath.lean
  JSON sidecar:   mymath.json
Working examples. The repository ships sympy_geometry.deppy (Euclidean geometry) and sympy_ntheory.deppy (number theory) plus examples/decorated_geometry.py in the decorator form. All three certify end-to-end on every commit.

Two Styles of Specification: @guarantee vs @ensures

Deppy provides two complementary ways to state postconditions, designed around the principle of gradual formality:

@guarantee — Natural Language

from deppy import guarantee

@guarantee("result is non-negative")
def square(n: int) -> int:
    return n * n
  • Takes a plain English string
  • Deppy parses it into Z3 assertions when possible, falls back to the LLM oracle otherwise
  • Yields Z3_VERIFIED when the string maps to a decidable formula, or LLM_CHECKED when it cannot
  • Best for: quick annotations, documentation-grade specs, early-stage verification

@ensures — Formal Predicate

from deppy import ensures

@ensures(lambda n, result: result >= 0)
def square(n: int) -> int:
    return n * n
  • Takes a Python callable (args..., result) → bool
  • Deppy encodes it directly into Z3 — no NL parsing, no oracle
  • Always yields Z3_VERIFIED or a concrete counterexample
  • Best for: machine-checkable contracts, CI gates, production-grade proofs

Similarly, preconditions have two forms:

@requires("xs is non-empty")  # NL string
@requires(lambda xs: len(xs) > 0)  # formal
The gradual formality principle. Start with @guarantee strings to capture intent quickly. As your project matures, tighten critical specs to @ensures lambdas for machine-checkable, Z3-proven guarantees. Both can coexist on the same function:
@guarantee("returns the sorted, deduplicated version of xs")
@ensures(lambda xs, r: len(r) <= len(xs))
@ensures(lambda xs, r: all(r[i] < r[i+1] for i in range(len(r)-1)))
def unique_sorted(xs: list[int]) -> list[int]:
    return sorted(set(xs))
Here the two @ensures predicates are Z3-proven; the @guarantee string acts as human-readable documentation and may trigger an additional oracle check for the "deduplication" semantics that Z3 cannot express directly.

Understanding the Output

Each function annotated with @guarantee produces a verification block in the output. Here is what each piece means:

ElementMeaning
Proof obligations Individual clauses extracted from the guarantee string. Deppy splits conjunctions (and) into separate obligations so you can see exactly which parts pass or fail.
Trust level How the obligation was discharged. From strongest to weakest:
🟢 LEAN_KERNEL_VERIFIED — machine-checked by Lean's kernel
🟡 Z3_VERIFIED — proved by the Z3 SMT solver
🟠 LLM_CHECKED — an LLM oracle judged it correct (confidence ≥ threshold)
🔴 UNTRUSTED — no verification yet
Status ✅ VERIFIED if every obligation is discharged at the configured minimum trust level; ❌ FAILED otherwise.
Time Wall-clock time for that function's verification.

A Slightly Harder Example

Let's verify an absolute-value function. The guarantee now involves conditionals that Deppy must reason about across both branches.

from __future__ import annotations
from deppy import guarantee


@guarantee("result >= 0 and (result == x or result == -x)")
def abs_val(x: int) -> int:
    """Return the absolute value of x."""
    if x >= 0:
        return x
    else:
        return -x

Running python -m deppy.pipeline.run_verify on this file produces:

abs_val(x: int) -> int
  Proof obligations:
    1. result >= 0                       ✅  Z3_VERIFIED
    2. result == x or result == -x      ✅  Z3_VERIFIED

  Status: ✅ VERIFIED   Trust: Z3_VERIFIED   Time: 0.02s

Z3 handles both branches by case-splitting on the condition x >= 0. In each branch it checks the guarantee independently and merges the results.

When Verification Fails

What happens if the code is wrong? Suppose we make a mistake in our max_of implementation:

from __future__ import annotations
from deppy import guarantee


@guarantee("result >= a and result >= b and (result == a or result == b)")
def max_of(a: int, b: int) -> int:
    """Oops — always returns the first argument."""
    return a  # BUG: ignores b entirely

The verifier will catch this immediately:

max_of(a: int, b: int) -> int
  Proof obligations:
    1. result >= a            ✅  Z3_VERIFIED
    2. result >= b            ❌  FAILED
    3. result == a or result == b  ✅  Z3_VERIFIED

  Status: ❌ FAILED

  Counterexample (from Z3):
    a = 0, b = 1
    result = 0
    Violated: result >= b  (0 >= 1 is False)

──────────────────────────────────────────────────
 0 functions verified · 1 failed · 2/3 obligations discharged
──────────────────────────────────────────────────

The counterexample is concrete: when a = 0 and b = 1, the function returns 0, which violates result >= b. This is one of the great strengths of SMT-backed verification — when something is wrong, you get a witness, not just a vague error.

Mixing verified and unverified code. A @guarantee only tells you that the decorated function satisfies its postcondition assuming it is called with values that satisfy Python's type annotations. If unverified code passes bad data (e.g., a string where an int is expected), the guarantee says nothing about the outcome. Keep verification boundaries clean: verify entire modules, not isolated functions surrounded by untyped code.

IDE Integration

Deppy works with any editor that runs Python: the only IDE contract is "show the file, run a process when you save." A typical workflow is to keep a split terminal open running python -m deppy.pipeline.run_verify my_module.deppy --out my_module.lean on every save.

The --out Lean certificate is a plain text file you can diff in source control; the JSON sidecar emitted next to it records every proof obligation, trust level, and discharge method. Together they form a reproducible audit log of what was verified.

Project Configuration

For multi-file projects, place a deppy.toml at your project root. Here is a minimal example:

# deppy.toml
[deppy]
python   = "3.12"
min_trust = "Z3_VERIFIED"     # reject LLM-only judgements
z3_timeout = 30            # seconds per obligation

[deppy.paths]
include = ["src/**/*.py"]
exclude = ["tests/**"]

A deppy.toml file is documentation today — python -m deppy.pipeline.run_verify always takes either a .deppy path or a --from-module DOTTED.PATH argument; multi-file project orchestration is typically handled with a wrapper shell script or a Makefile that enumerates the .deppy files explicitly.

What Deppy Checks (and What It Doesn't)

At this stage Deppy is checking functional correctness — does the return value satisfy the postcondition for every possible input that matches the type annotations? It does not yet check:

Those topics build on the foundation laid here. For now, focus on pure functions with clear input/output contracts.

A Complete Module

Let's put several verified functions together into a small utility module to see how Deppy handles a realistic file.

from __future__ import annotations
from deppy import guarantee, assume


@guarantee("result >= 0")
def square(n: int) -> int:
    return n * n


@guarantee("result >= lo and result <= hi")
def clamp(x: int, lo: int, hi: int) -> int:
    assume("lo <= hi")
    if x < lo:
        return lo
    elif x > hi:
        return hi
    else:
        return x


@guarantee("result == a + b")
def add(a: int, b: int) -> int:
    return a + b


@guarantee("(result == True and n > 0) or (result == False and n <= 0)")
def is_positive(n: int) -> bool:
    return n > 0

Running the safety pipeline directly on utils.py (which understands @guarantee/@requires/@ensures decorators):

$ python -m deppy check utils.py
──────────────────────────────────────────────────
 Deppy v0.1 — verifying utils.py
──────────────────────────────────────────────────

square(n: int) -> int
  1. result >= 0   ✅  Z3_VERIFIED
  Status: ✅ VERIFIED   Trust: Z3_VERIFIED   Time: 0.01s

clamp(x: int, lo: int, hi: int) -> int
  Precondition: lo <= hi
  1. result >= lo   ✅  Z3_VERIFIED
  2. result <= hi   ✅  Z3_VERIFIED
  Status: ✅ VERIFIED   Trust: Z3_VERIFIED   Time: 0.02s

add(a: int, b: int) -> int
  1. result == a + b   ✅  Z3_VERIFIED
  Status: ✅ VERIFIED   Trust: Z3_VERIFIED   Time: 0.01s

is_positive(n: int) -> bool
  1. (result == True and n > 0) or
     (result == False and n <= 0)   ✅  Z3_VERIFIED
  Status: ✅ VERIFIED   Trust: Z3_VERIFIED   Time: 0.01s

──────────────────────────────────────────────────
 4 functions verified · 0 failed · 5 obligations discharged
──────────────────────────────────────────────────

Note how clamp uses assume("lo <= hi") to declare a precondition. Deppy adds this as an axiom when proving the postcondition, and separately generates a proof obligation at every call site of clamp to ensure the precondition actually holds.

Exercise

Exercise 1 — clamp with guarantee.

Write a function clamp(x: int, lo: int, hi: int) -> int that returns lo if x < lo, hi if x > hi, and x otherwise. Add a @guarantee that the result is between lo and hi (inclusive), and an assume("lo <= hi") precondition.

Verify your solution with python -m deppy.pipeline.run_verify. Then deliberately introduce a bug (e.g., swap the lo and hi returns) and observe the counterexample that Z3 produces.

Hint

The guarantee string should be "result >= lo and result <= hi". Don't forget from __future__ import annotations.

Next Steps

You now know how to install Deppy, annotate functions with @guarantee and assume, run the verifier, and read its output. In the next chapter we will explore refinement types — a more expressive way to describe the values a function accepts and returns.