Skip to main content

A constructive prior over small Python programs using AST-driven constrained decoding

Project description

PyPrior

A constructive prior over Python programs: sample them, condition on prompts, or use the prior as a constrained decoder for model-generated code.

A function with a for-loop and an if/else assembling itself one legal token at a time, then running

A single free-form sample, sampled from a random transformer network under PyPrior's constraints — a function with a for loop and an if/else (each branch returns), a comparison used as a value, and nested calls, assembled one legal token at a time and then run. Scope-safe, no dead code. Reproduce it (and the GIF) with python examples/api/demo_gif.py.

PyPrior samples programs from a restricted Python grammar by deciding, at every step, the exact set of tokens that are legal next — so every program it produces is syntactically valid, scope-correct (no undefined variables), and bounded in size by construction. Its token stream is a preorder serialization of the restricted Python AST.

The GIF above shows a nontrivial program with loops and branches. That program uses 47 PyPrior tokens; the same source costs 111 GPT-2 tokens. PyPrior's AST vocabulary is compact, and AST-token generation spends tokens on program structure instead of source-code spelling.

What existing constrained decoders miss

Existing constrained decoding libraries such as SynCode, XGrammar, and Outlines are valuable when a normal text-token LLM has already learned a Python prior from lots of human-written code and you want to keep its output inside a grammar or structured format. If the sampler or model has no Python prior, a syntax mask alone is not enough. PyPrior targets that different problem: a Python AST-token language with scope tracking, bounded completion, direct execution semantics, and useful unconditional sampling.

For example, uniform random sampling through SynCode's Python grammar mask with a GPT-2 tokenizer can produce syntactically valid Python, but GPT-2's token distribution is not a useful Python program prior. A prompt-conditioned sample can look like:

def f(x):
     KCatioushaIntroductioneeAddedRushymmWrittennyderescalALTHpecAccess713EyeensitiveenderedtriggerRahredditmenuappedicheixtpossiblybandJJwatersORD

That parses as Python, but it is just a bare expression using an undefined name. The constrained decoder did its job: it controlled syntax. It did not provide a program prior, scope safety, or execution semantics.

PyPrior samples directly in an AST vocabulary, so even uniform random sampling produces structured, scope-safe, runnable programs — every name is bound, there is no unreachable code, and every program is guaranteed to terminate (no while, no recursion — only bounded for/range loops and calls to already-defined functions, so every run halts):

def func():
    for a in range(9, 3 + 5):
        print(a)
    return 2 + 4

print(func())   # runs -> 6
Approach Good at Missing for PyPrior's use case
Text-token grammar masks Keeping an LLM's text output syntactically valid Scope tracking, AST-token output, small controlled vocabulary, bounded AST size/depth, direct execution model
PyPrior Random code augmentation and PyPrior-vocab constrained decoding Full Python coverage, arbitrary libraries/imports, unconstrained human-style code

This is the core distinction: text-level constrained decoders constrain a language model; PyPrior defines the language, the vocabulary, the decoder state, and the executable subset together.

Install

Install directly from the GitHub repo for now:

pip install "pyprior @ git+https://github.com/PatrickHua/pyprior.git"

The core is numpy-only. Optional extras: [demo] (torch, for the sample decoders), [vllm] (the vLLM V1 logits-processor integration), [dev] (pytest, hypothesis, pytest-benchmark).

Using PyPrior

The engine works the same at every step: ask what's legal, advance with any of those tokens, repeat. A random sampler, a weighted prior, and a neural model all plug into that one loop. There are two surfaces.

1. The engine

Drive the decode directly — pick any legal token however you like.

This is the primitive state-machine API that Prior wraps; examples/api/prior.py shows the higher-level model/prior loop built on top of it.

import random
import pyprior as pp

tok = pp.Tokenizer(value_range=(0, 9))
s = pp.init(vocab=tok, max_len=30)                  # a fresh free-form decode
                                                    # (pass ids=[...] to start from a prompt)
while not pp.done(s):
    legal = s.rows[0].legal()                       # the set of legal next token ids
    pp.advance(s, [random.choice(sorted(legal))])   # pick any one — here, uniformly
print(tok.decode(s.ids[0]))

legal (or pp.next_legal_tokens(s) for the additive [B, V] mask a model adds to its logits) is intersected from four sources: the grammar's FIRST set, the in-scope variables (definite assignment), the depth/length guards, and a min-cost-to-complete check that guarantees the program closes within max_len. It is never empty until the program is done.

2. The prior + a model

Prior blends a model's logits, a symbolic prior, and the legal mask — built once, then a session loop. The same prior.apply is what the vLLM processor calls, so behavior is identical whether you own the loop or vLLM does:

import pyprior as pp
from pyprior import PriorConfig
from pyprior.demo import RandomDecoder          # flat-logit stand-in model (the torch extra)

def sample(logits, temperature=1.0):            # [B, V] -> [B]
    import torch
    return torch.multinomial(torch.softmax(logits / temperature, -1), 1)[:, 0]

tok = pp.Tokenizer(value_range=(0, 9))
prior = pp.Prior(tok, PriorConfig(apply="entropy"), max_len=30)  # entropy | fixed | additive | off
model = RandomDecoder(tok.vocab_size)           # flat logits -> entropy ~max -> follows the prior

def function_prefix(arity):
    return (tok.id_of("BLK_1"), tok.id_of("FUNC_1"), tok.id_of(f"ARITY_{arity}"))

prior.start([function_prefix(1), function_prefix(2)])  # or start(n=B) for free-form
while not prior.done():
    logits = prior.apply(model(prior.input_ids)[:, -1])   # combine per PriorConfig.apply
    prior.advance(sample(logits))
prior.show(0)                                   # animate the first sample

Apply modes: entropy (param-free confidence gate — lean on the prior when the model is unsure, fade as it sharpens; default), fixed (convex blend, weight alpha), additive (prior as a bias), off (mask only).

Runnable walk-throughs in examples/api/: prior.py, torch_nn.py (a real torch.nn model), and vllm_adapter.py (serving). For batched production serving the engine ships a vLLM V1 logits processor (pyprior.integrations.vllm.PyPriorLogitsProcessor); the model-free walk-through is tests/integration/test_vllm_processor.py.

What it generates

A small, bounded, free-form subset of Python over integers — a module of top-level statements, where a def is one statement form:

x = 3                     # top-level statements: assign / if / for / def
def func(a, b):           # a def is one of them (params + locals are scoped)
    d = a * b + 1         # assignments to fresh / existing locals
    if d < 0:             # if / else
        return 0          # return is a statement (early returns OK), function-body only
    return d              # ...or fall through to None

The shape is configured entirely on the Tokenizer: value_range (integer range), max_block_stmts, max_expr_depth, max_control_depth, max_funcs, and max_vars. The grammar is otherwise maximal — bounded for loops (one- and two-arg range), non-recursive def calls, print(expr), and comparisons used as int values (b = a == 7int(a == 7)) are always available, and the language stays total (recursion is structurally impossible, loops are bounded). By default a return ends its block (no unreachable code) — pass no_dead_code=False to allow statements after a return. To restrict generation, subtract with exclude= rather than a flag: exclude={"CALL"} (no calls), exclude={"for"} (no loops), exclude={"PRINT"} (pure functions). To generate a single function, start from an explicit prefix such as BLK_1 FUNC_1 ARITY_2 and set max_block_stmts=1. init(..., exclude=...) forbids whole forms during generation (e.g. exclude={"binop"} or {"func"}).

Watch it decode

pp.show replays a token stream into the animation above — one legal token at a time, holes (<expr>, <stmt>, <block>) filling in:

import pyprior as pp

tok = pp.Tokenizer(value_range=(0, 9))
ids = pp.init(vocab=tok, max_len=30)               # ...sample a stream (see "Using PyPrior")
pp.show(tok, ids.ids[0])                            # animate in the terminal
pp.show(tok, ids.ids[0], save_as_gif=True, gif_path="decode.gif")   # ...or export a GIF

Status

Supports Integer types. List, strings, ... etc remains TBD.

License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pyprior-0.0.2.tar.gz (49.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pyprior-0.0.2-py3-none-any.whl (57.2 kB view details)

Uploaded Python 3

File details

Details for the file pyprior-0.0.2.tar.gz.

File metadata

  • Download URL: pyprior-0.0.2.tar.gz
  • Upload date:
  • Size: 49.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.10.13

File hashes

Hashes for pyprior-0.0.2.tar.gz
Algorithm Hash digest
SHA256 e72e4e56f0fdbae7f37b78816e9ee7fd9184bf443abc5f7e2a5bbde206a21221
MD5 988f8b814ccf4cc16914f378627b5633
BLAKE2b-256 912cd09a6cec9e12f802223e5910565686230d84f611ed827a42af6e520586b3

See more details on using hashes here.

File details

Details for the file pyprior-0.0.2-py3-none-any.whl.

File metadata

  • Download URL: pyprior-0.0.2-py3-none-any.whl
  • Upload date:
  • Size: 57.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.10.13

File hashes

Hashes for pyprior-0.0.2-py3-none-any.whl
Algorithm Hash digest
SHA256 7c7f34820a31de7b76629ccc90aa60b9ebd4fec0f5510fb3af52b7130a9537f3
MD5 1c56ac9aba32bcaf8f3d8a48a63c006e
BLAKE2b-256 6d710106fac546ad3cc2c431e9edc7ea2bc961da13542b314d654b2da63d4bbb

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page