Skip to content

Trees

A schema that refers to itself is a language of trees: a comment and its replies, a folder and its contents, a category and its subcategories. A dataclass whose field is a list of itself, a pydantic model or TypedDict that names itself, and a JSON Schema whose $ref points back into its own document all adapt, and so does L[json], whose grammar is recursive by definition. Members are finite and acyclic: a value that contains itself is outside the language, and the explanation names the place the cycle closes.

The examples on this page are the shop's comment threads, from examples/shop/threads.py:

from dataclasses import dataclass, field


@dataclass
class Comment:
    author: str
    body: str
    replies: list[Comment] = field(default_factory=list)


def thread_size(comment: Comment) -> int:
    """How many comments the thread holds, this one included."""
    return 1 + sum(thread_size(r) for r in comment.replies)


def replies_below(comment: Comment) -> int:
    """How many replies sit under this comment, at any depth."""
    return len(comment.replies) + sum(replies_below(r) for r in comment.replies)


def thread_depth(comment: Comment) -> int:
    """How many levels the thread has, this comment's included."""
    return 1 + max((thread_depth(r) for r in comment.replies), default=0)

Depth, nodes and width

Three refinements bound a tree inside the brackets. On a record tree they count records: depth is the number of records along the deepest path (a comment with no replies has depth 1), nodes is the number of records, and width is the most records any one record holds directly. On L[json], where every container is a value, they count containers and values instead ([[[]]] has depth 3). Each reads <=, <, >=, > or an interval, and they combine:

for comment in L[shop.threads.Comment, depth <= 10], ...
for comment in L[shop.threads.Comment, nodes in [1, 200], width <= 20], ...
for doc in L[json, depth <= 6, width <= 50], ...

A bound the schema states itself (maxItems on a list, pydantic's max_length) is the language's own. Where nothing bounds a tree the language stays unbounded, and random members are drawn within stated sampling bounds (depth 8, 256 nodes, width 16), which the record names as sampling choices rather than facts about the language. For claims about nesting, depth(v), nodes(v), width(v) and leaves(v) from mathema_language.tree take any nested dict, list, tuple or record, bound with let, and measure it the way the refinements do: records on a tree of records, containers and values on plain dicts and lists.

What the probe visits

The hazards sit on the structure axes: the empty tree, the deepest and the widest member the bounds allow (a refinement's own bound among them, and one past it as the value outside the language), one long spine, and for an unbounded language a spine past the interpreter's recursion limit, since a recursive function over an unbounded language fails there however right its arithmetic is. Where a library's validator recurses in Python and stops early (the jsonschema validator does, pydantic does not) the language finds that validator's limit and states it. Random draws climb a depth ladder, 1, 2, 4 and so on up to the bound, instead of clustering shallow, and a failing tree is shrunk inside the language by hoisting a subtree into its parent's place, dropping a reply, then simplifying the fields of each record, so the witness is the smallest tree that still fails. A witness too deep to print is summarised by its type and depth.

f = thread_size
for comment in L[shop.threads.Comment], f(comment) >= 1
    falsified   comment = <Comment tree 1050 records deep>: raised RecursionError

A reply chain a thousand deep exhausts Python's recursion, and the probe builds one.

Proof by structural induction

A claim about a recursive function over a tree language can be proven, not only sampled, when every function it applies is a structural fold: one return of arithmetic over the record's numeric fields, len of its list of children, and sum, max or min of a fold over the children (with a default for max and min). The derive route proves the claim for a record with no children, then for a record with k >= 1 children assuming it of each child, and a numeric field enters at its lower bound.

f = thread_size
for comment in L[shop.threads.Comment, depth <= 20], f(comment) >= 1
    proven

f = replies_below
for comment in L[shop.threads.Comment, depth <= 20], f(comment) == thread_size(comment) - 1
    proven

Both are proven on the route derive:induction, and the first record's sketch reads: by structural induction over replies, the base case (no replies) and the step (k >= 1 replies, the claim assumed of each) both hold, and depth <= 20 keeps the recursion under the interpreter's limit.

The functions are recursive Python, two stack frames per level (the call and the generator over the replies), so the proof stands only where the depth bound keeps the recursion under the interpreter's limit, depth <= 448 at Python's default limit of 1,000. Over an unbounded language the mathematics is settled and the implementation is not, the record says so, and the probe decides, which is how the unbounded claim above is falsified by a real thread.

The claims it proves: a fold against a constant (thread_size(comment) >= 1), two folds related affinely by == (replies_below(comment) == thread_size(comment) - 1), and two folds related by an ordering where both aggregate with sum. Anything else is declined with the reason and sampled, never disproven by the induction, since an induction that does not go through says nothing against the claim:

f = thread_size
for comment in L[shop.threads.Comment, depth <= 20], f(comment) >= 2
    falsified   comment = Comment(author='', body='', replies=[]): 1 vs 2

for comment in L[shop.threads.Comment, depth <= 20], f(comment) >= thread_depth(comment)
    holds

The first is falsified by a single comment, and the record says why the induction declined: the base case (no replies) does not prove, -1 >= 0. The second is true and sampled, since a sum and a maximum are not related by this route.

Out of reach for now: mutual recursion between two functions, recursion through anything but the list of children, functions that carry an accumulator, and claims that need a stronger statement than themselves to go through.