Chapter 6.3 — Mutable References

Aliasing as DuckPath in cubical, vs ownership transfer in Pulse.

Python Variables Are References

In Python, every variable is a reference — a name bound to an object on the heap. Assignment (y = x) copies the reference, not the object. This is the fundamental source of aliasing in Python:

class Box:
    def __init__(self, value):
        self.value = value

a = Box(10)
b = a            # b is an ALIAS for a — same object
b.value = 99
print(a.value)   # 99 — a was mutated through b!
Two ways to handle this in Deppy. The cubical heap (Chapter 6.1) makes the alias explicit: w.alias('b', 'a') records that a and b point at the same cell. Pulse-style separation logic makes aliasing impossible by default: any specification using ** implies non-aliasing. Both are sound — pick the one that fits the idiom you're verifying.

In the classical separation-logic view: if two variables a and b are separated (pts_to(a, _) ** pts_to(b, _)), they cannot be aliases. If they might alias, we must model this explicitly. In the cubical view: aliases are DuckPaths; mutation through either of two aliases bumps the epoch on the shared cell, so any proof about either name is correctly invalidated.

Cubical view, in code

from deppy.proofs.psdl_heap import World

w = World()
cid = w.alloc('Box')
w.bind('a', cid); w.alias('b', 'a')
print(sorted(w.aliases_of('a')))   # ['a', 'b']

affected = w.mutate('b', line=3)
print(sorted(affected))               # ['a', 'b']  ← both invalidated
print(w.epoch)                       # 1

Aliasing vs. Non-Aliasing

Non-Aliasing (★)

# x ≠ y guaranteed
@requires(
    pts_to(x, 'a') **
    pts_to(y, 'b')
)
def safe(x, y):
    x.value = 0
    # y.value is still b

Possible Aliasing (∧)

# x might equal y!
@requires(
    pts_to(x, 'a') and
    pts_to(y, 'b')
)
def unsafe(x, y):
    x.value = 0
    # y.value might be 0!
Deppy requires you to use ** (separating conjunction) in specifications. If you want to express that two references might alias, you must explicitly model the two cases using disjunction.

Ownership Tracking

Deppy tracks which references each function owns. Ownership is passed through function calls: when you call a function, you transfer ownership of the references mentioned in its precondition. When it returns, you get back ownership of the references in its postcondition.

@requires(pts_to(x, 'v'))
@ensures(lambda _: pts_to(x, 'v * 2'))
def double_ref(x: Ref[int]):
    x.value *= 2

# Caller perspective:
# pre:  pts_to(r, 5) ** pts_to(s, 7)
double_ref(r)   # transfers ownership of r, keeps s
# mid:  pts_to(r, 10) ** pts_to(s, 7)   ← r doubled, s unchanged
double_ref(s)   # transfers ownership of s, keeps r
# post: pts_to(r, 10) ** pts_to(s, 14)  ← both doubled independently

Swap with Detailed Proof

The swap function exchanges the values of two references. Here we annotate each intermediate state:

@requires(pts_to(x, 'a') ** pts_to(y, 'b'))
@ensures(lambda _: pts_to(x, 'b') ** pts_to(y, 'a'))
def swap(x: Ref[int], y: Ref[int]):
    # State: pts_to(x, a) ** pts_to(y, b)
    tmp = x.value
    # State: pts_to(x, a) ** pts_to(y, b) ∧ tmp = a
    x.value = y.value
    # State: pts_to(x, b) ** pts_to(y, b) ∧ tmp = a
    y.value = tmp
    # State: pts_to(x, b) ** pts_to(y, a)  ✓
Verified

The key insight is that ** guarantees x ≠ y, so writing to x does not affect the value at y. Without separation logic, we would need to add an explicit x is not y precondition — which is exactly what ** gives us for free.

Linked Data Structures

Heap-allocated data structures like linked lists are where separation logic truly shines. Consider a simple linked list node:

from __future__ import annotations
from typing import Optional

class Node:
    def __init__(self, val: int, nxt: Optional[Node] = None):
        self.val = val
        self.nxt = nxt

A linked list is a chain of nodes on the heap. We define a recursive separation-logic predicate that describes the entire list:

from deppy.separation import SLProp, attr_to, emp

def is_list(head: Optional[Node], vals: list[int]) -> SLProp:
    """head points to a linked list containing vals."""
    if head is None:
        return emp if vals == [] else False
    if not vals:
        return False
    return (
        attr_to(head, 'val', vals[0]) **
        attr_to(head, 'nxt', head.nxt) **
        is_list(head.nxt, vals[1:])
    )

Each node occupies a disjoint region of the heap (enforced by **), which prevents cycles by construction.

Example: Prepend to a Linked List

@requires(is_list(head, 'xs'))
@ensures(lambda result: is_list(result, '[v] + xs'))
def prepend(head: Optional[Node], v: int) -> Node:
    """Prepend v to the front of the list."""
    return Node(v, head)
Verified

Example: Length of a Linked List

@requires(is_list(head, 'xs'))
@ensures(lambda result: is_list(head, 'xs') and result == len('xs'))
def list_length(head: Optional[Node]) -> int:
    """Return the length without modifying the list."""
    count = 0
    current = head
    while current is not None:
        count += 1
        current = current.nxt
    return count
Verified
Notice the postcondition restores is_list(head, 'xs') — the list is returned to the caller intact. This is a read-only traversal: the function borrows the list and gives it back unchanged.

Existential Quantification

Often we don't care about the exact value stored at a reference — we just need to know something is there. Deppy uses exists for this:

from deppy.separation import exists

# "x points to some integer, we don't care what"
prop = exists('v', pts_to(x, 'v'))

@requires(exists('v', pts_to(x, 'v')))
@ensures(lambda _: pts_to(x, 0))
def reset(x: Ref[int]):
    """Set reference to zero, regardless of current value."""
    x.value = 0

The existential hides the initial value — the function doesn't need to know it. This is useful for initialisation functions and cleanup code.

Ghost Variables

Ghost variables exist only in specifications — they have no runtime representation. Deppy uses them to track logical properties that aren't stored explicitly in the program:

from deppy.ghost import ghost_var, ghost_update

class Counter:
    def __init__(self):
        self.count = 0
        self._total_ops = ghost_var(0)  # specification-only

    @requires(
        attr_to(self, 'count', 'n') **
        ghost_pts_to(self._total_ops, 't')
    )
    @ensures(lambda _:
        attr_to(self, 'count', 'n + 1') **
        ghost_pts_to(self._total_ops, 't + 1')
    )
    def increment(self):
        self.count += 1
        ghost_update(self._total_ops, lambda t: t + 1)
Ghost variables are erased at runtime — they incur zero overhead. They exist solely to help the verifier track invariants that involve history or counting.

Fractional Permissions

Full ownership (pts_to(x, v)) grants both read and write access. But what if multiple functions need to read the same reference concurrently? Deppy supports fractional permissions: a permission p where 0 < p ≤ 1.0.

PermissionAccessMeaning
p = 1.0 Read + Write Full ownership — exclusive access
0 < p < 1.0 Read only Shared read access — no writes permitted
p = 0 None No access (not useful — never used)
from deppy.separation import pts_to_frac

# Full permission — can read and write
full = pts_to_frac(x, 'v', perm=1.0)

# Half permission — can only read
half = pts_to_frac(x, 'v', perm=0.5)

# Two halves combine to full permission:
# pts_to_frac(x, v, 0.5) ** pts_to_frac(x, v, 0.5)
#   ≡ pts_to_frac(x, v, 1.0)
#   ≡ pts_to(x, v)

Splitting and Joining Permissions

# Split: one full permission → two half permissions
@requires(pts_to(x, 'v'))
@ensures(lambda _:
    pts_to_frac(x, 'v', perm=0.5) **
    pts_to_frac(x, 'v', perm=0.5))
def share(x: Ref[int]):
    """Split ownership for concurrent readers."""
    pass  # ghost operation — no runtime effect

# Join: two half permissions → one full permission
@requires(
    pts_to_frac(x, 'v', perm=0.5) **
    pts_to_frac(x, 'v', perm=0.5))
@ensures(lambda _: pts_to(x, 'v'))
def recombine(x: Ref[int]):
    """Recombine shared permissions for exclusive access."""
    pass  # ghost operation

Multiple Readers, Single Writer

Fractional permissions naturally encode the readers–writer pattern: any number of readers can hold fractional permissions simultaneously, but a writer needs the full permission (which means all readers must give up their shares first).

# Multiple readers — each gets a fraction
@requires(pts_to_frac(data, 'v', perm=0.25))
@ensures(lambda result:
    pts_to_frac(data, 'v', perm=0.25) and result == 'v')
def read_data(data: Ref[str]) -> str:
    """Read-only access with fractional permission."""
    return data.value

# Writer — needs full permission
@requires(pts_to(data, 'old'))  # perm=1.0 implied
@ensures(lambda _: pts_to(data, 'new_val'))
def write_data(data: Ref[str], new_val: str):
    """Write requires exclusive ownership."""
    data.value = new_val
Fractional permissions are a purely logical concept — they don't correspond to runtime locks or reference counts. They're a tool for the verifier to track how access is distributed across call sites.

Comparison with Pulse References

Pulse (F*)

fn swap (x y: ref int)
  requires
    pts_to x 'a' **
    pts_to y 'b'
  ensures
    pts_to x 'b' **
    pts_to y 'a'
{
  let vx = !x;
  let vy = !y;
  x := vy;
  y := vx;
}

Deppy (Python)

@requires(
    pts_to(x, 'a') **
    pts_to(y, 'b'))
@ensures(lambda _:
    pts_to(x, 'b') **
    pts_to(y, 'a'))
def swap(x, y):
    vx = x.value
    vy = y.value
    x.value = vy
    y.value = vx

The specifications are nearly identical — separation logic is the same regardless of the host language. The main differences are syntactic:

Example: Binary Tree Ownership

class TreeNode:
    def __init__(self, val, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

def is_bst(node: Optional[TreeNode],
         lo: int, hi: int) -> SLProp:
    """node is a BST with all values in (lo, hi)."""
    if node is None:
        return emp
    return (
        attr_to(node, 'val', node.val) **
        attr_to(node, 'left', node.left) **
        attr_to(node, 'right', node.right) **
        pure(lo < node.val < hi) **
        is_bst(node.left, lo, node.val) **
        is_bst(node.right, node.val, hi)
    )

@requires(is_bst(root, 'lo', 'hi'))
@ensures(lambda result:
    is_bst(root, 'lo', 'hi') and
    result == '(target in tree_values(root))')
def search(root: Optional[TreeNode], target: int) -> bool:
    """Search in BST — verified to not modify the tree."""
    if root is None:
        return False
    if target == root.val:
        return True
    elif target < root.val:
        return search(root.left, target)
    else:
        return search(root.right, target)
Verified — read-only traversal preserves BST invariant
Exercise 6.2.1: Write a function move(src, dst) that moves the value from src to dst and resets src to zero. Give the complete separation-logic specification.
Exercise 6.2.2: Using the is_list predicate, write and verify a function sum_list(head) that returns the sum of all values in a linked list without modifying the list.
Exercise 6.2.3: Split a full permission into four quarter-permissions. Show that all four can read simultaneously, then recombine them in pairs (two quarters → half, two halves → full). Write the specification chain.
Exercise 6.2.4: Define a ghost variable _max_seen that tracks the maximum value ever written to a reference. Write a specification for update_if_larger(x, v) that only updates x if v > x.value, and prove that _max_seen is monotonically non-decreasing.
Exercise 6.2.5 (Challenge): Write and verify a function reverse_list(head) that reverses a singly-linked list in-place. You will need to use a loop invariant expressed in separation logic. Hint: at each step of the loop, split the list into a "reversed prefix" and an "unreversed suffix."