Skip to main content

⊢ Symbolic coprocessors for LLM generation — ground model outputs in exact computation

Project description

⊢ turnstyle

Ground LLM generation in real computation. The model writes prose; turnstyles guarantee the facts.

A turnstyle intercepts generation at the token level, running an oracle (anything from a + b to arbitrary Python in a WASM sandbox) and steering the model toward the correct answer. Every intervention is audited.

from turnstyle import SandboxTurnstyle

t = SandboxTurnstyle(model, tokenizer, device)

# The model writes the explanation. The sandbox computes the answer.
text, proof = t.generate("""What does this return?
```python
primes = [n for n in range(2, 100) if all(n % i != 0 for i in range(2, int(n**0.5)+1))]
len(primes)
```""")
# proof.answer == 25

The code runs in a WASM sandbox — no network, no filesystem, no syscalls. The model can't hallucinate a number that the sandbox actually computed.

Turnstyles

Every turnstyle follows the same pattern: parse() runs an oracle, make_processor() wires the answer into logit biasing, generate() lets the model write freely while the coprocessor enforces correctness.

Turnstyle Oracle Example
SandboxTurnstyle Arbitrary Python in WASM "What does `sum(range(101))` return?"
ArithmeticTurnstyle +, -, *, / "What is 445 + 152?"
DateTurnstyle Date arithmetic "How many days between Jan 1 and Mar 20?"
UnitTurnstyle Physical unit conversion "How many km is 26.2 miles?"
CurrencyTurnstyle Currency conversion "How much is 100 USD in EUR?"
PercentageTurnstyle Percentages, tips, discounts "What is 15% of 230?"
CountingTurnstyle Letters, vowels, words "How many r's in 'strawberry'?"
BaseConversionTurnstyle Binary, hex, octal "What is 255 in binary?"

The specialized turnstyles (arithmetic, dates, etc.) are fast pattern-matched oracles. SandboxTurnstyle is the general case — if you can write Python for it, you can ground generation in it.

How it works

A turnstyle is a bridge between two systems: a neural network that generates language and a symbolic oracle that computes facts. The bridge is HuggingFace's LogitsProcessor interface — a hook that runs after the model produces logits for each token but before the token is sampled.

The pipeline

flowchart LR
    prompt --> parse["parse()"]
    parse -->|success| oracle["oracle result"]
    oracle --> make["make_processor()"]
    make --> gen["generate()"]
    make --> lp["LogitsProcessor"]
    lp --> gen
    parse -->|failure| free["model generates freely<br/>(no biasing, no crash)"]

The oracle extracts a computable problem from the prompt. ArithmeticTurnstyle uses regex to find 445 + 152597. SandboxTurnstyle extracts Python code and runs it in a WASM sandbox. If parsing fails, the model generates freely — no intervention, no crash.

make_processor() creates a LogitsProcessor preloaded with the answer digits [5, 9, 7] and a digit-to-token mapping for the model's tokenizer. The processor is passed to generate(), which lets the model write freely while the coprocessor enforces correctness.

The state machine

During generation, the LogitsProcessor walks through four states:

flowchart LR
    WAITING -->|trigger word| TRIGGERED
    TRIGGERED -->|first digit| INJECTING
    INJECTING -->|non-digit| DONE
State What happens Transition
WAITING Watches for trigger words (is, =, equals) → TRIGGERED on trigger
TRIGGERED Waits for the first digit token → INJECTING on digit
INJECTING Adds +15.0 to the correct digit's logit each step → DONE on non-digit
DONE Counts any extra digits (model rambling) terminal

This trigger-word state machine is the digit coprocessor (ArithmeticLogitsProcessor). The general-purpose SequenceLogitsProcessor takes immediate=True — it skips WAITING/TRIGGERED and biases from the first generated token, no trigger word needed (that's what the hello-world below uses).

Logit biasing

The coprocessor doesn't force tokens — it biases them. At each digit position, it adds a fixed offset (default 15.0) to the logit of the correct digit:

Model's logits for digit position 0:
  "6" → 10.2  (model's top choice — wrong)
  "5" →  9.1  (correct answer)
  "4" →  3.7
  ...

After coprocessor bias (+15.0 to "5"):
  "5" → 24.1  ← now top choice
  "6" → 10.2
  "4" →  3.7

If the model was already going to emit the correct digit, the bias is a no-op. If the model was wrong, the bias flips the ranking. Either way, the correction is recorded in the audit trail.

Audit trail

Every digit gets a DigitAudit recording what the model wanted vs. what the oracle computed:

text, proof = t.generate("What is 445 + 152?")

print(proof.inline()) # ⊢ 445+152=5̲97 ∎
print(proof.detail())
# ⊢ 445+152=597  1/3 corrected  Δ=0.05
#   d0: [6→5]  logit_gap=+1.0
#   trigger@step 14/19  state=DONE

Annotation marks:

  • underline — digit corrected by coprocessor
  • circumflex — digit the model never emitted
  • turnstile — "this was derived"
  • QED — "proof complete"

Use proof.inline(plain=True) for annotation-free output.

Build your own

A turnstyle is any subclass of Turnstyle that implements two methods: parse() runs your oracle, make_processor() wires the answer into logit biasing. If parse() returns None, the model generates freely — no biasing, no crash.

Hello world — the smallest solver. SequenceLogitsProcessor steers the model toward any answer string (letters, a word, an option):

import re
from turnstyle.core import Turnstyle, SequenceLogitsProcessor

class ReverseTurnstyle(Turnstyle):
    """The model often flubs letter order; the oracle doesn't."""

    def parse(self, prompt: str):
        m = re.search(r"reverse(?:\s+the\s+word)?:?\s+(\w+)", prompt, re.I)
        return m.group(1)[::-1] if m else None       # None -> the model generates freely

    def make_processor(self, parsed, max_new_tokens: int):
        answer_ids = self.tokenizer.encode(parsed, add_special_tokens=False)
        return SequenceLogitsProcessor(
            self.tokenizer, answer_ids,
            expression="reverse", answer_str=parsed,
            bias_strength=self.bias_strength,
            max_new_tokens=max_new_tokens, immediate=True,
        )
t = ReverseTurnstyle(model, tokenizer, device)
text, _ = t.generate("Reverse the word: turnstyle")   # -> "elytsnrut"

parse() is your oracle — compute the answer however you like (regex, a library call, a simulation). make_processor() steers the model to emit it.

For numeric answers, ArithmeticLogitsProcessor biases digit-by-digit and produces the audited 5̲97 trail:

from turnstyle.core import Turnstyle
from turnstyle.arithmetic import ArithmeticLogitsProcessor

class FibonacciTurnstyle(Turnstyle):
    """Ground Fibonacci answers in real computation."""

    def parse(self, prompt: str):
        """Extract a computable problem, or return None to skip biasing."""
        import re
        m = re.search(r'(\d+)(?:th|st|nd|rd)?\s+fibonacci', prompt, re.I)
        if not m:
            return None
        n = int(m.group(1))
        a, b = 0, 1
        for _ in range(n):
            a, b = b, a + b
        return n, a  # (index, answer)

    def make_processor(self, parsed, max_new_tokens: int):
        """Wire the oracle's answer into a LogitsProcessor."""
        n, answer = parsed
        return ArithmeticLogitsProcessor(
            self.tokenizer,
            answer_digits=[int(d) for d in str(answer)],
            expression=f"fib({n})",
            answer_value=answer,
            bias_strength=self.bias_strength,
            max_new_tokens=max_new_tokens,
        )

That's it. parse() is your oracle — compute the answer however you want. make_processor() wires it into digit biasing. If parse() returns None, the model generates freely.

t = FibonacciTurnstyle(model, tokenizer, device)
text, proof = t.generate("What is the 10th Fibonacci number?")
# proof.answer == 55

Routing many solvers: DispatchTurnstyle

DispatchTurnstyle routes a prompt to the right solver automatically through a typed Task dispatch. Deterministic solvers (arithmetic, dates, web-of-lies, navigation, logical deduction) ground the answer exactly; multiple-choice tasks route through a per-option probe; anything it can't solve falls back to plain generation.

from turnstyle import DispatchTurnstyle

dt = DispatchTurnstyle(model, tokenizer, device)
dt.generate("What is 3 * (4 + 5)?")[0]           # "27"     — deterministic, grounded
dt.generate("Complete the brackets: ( ( [")[0]   # "] ) )"

# Multiple choice: fit a per-option probe once (autoprobe), then route through it
dt.fit_choice(snarks_examples)
dt.generate(snarks_prompt)[0]                     # "(B)"    — probe pick, grounded

Each prompt is parsed into a typed variant (Task = Arithmetic | MultipleChoice | … | FreeForm), solved, and grounded back into the model's output. Step-by-step walkthrough, one stage per cell:

Open In Colab · or experiments/dispatch_walkthrough.ipynb locally.

Probe routing

DispatchTurnstyle (above) routes automatically via typed dispatch. The lower-level RoutingTurnstyle routes among a custom set of turnstyles using a linear probe on model hidden states — for novel phrasings that regex can't catch.

Regex-first, probe-fallback: existing parse patterns are tried first. The probe only activates when no regex matches.

from turnstyle import RoutingTurnstyle, TurnstyleProbe

probe = TurnstyleProbe.load("probe_weights.pt")
router = RoutingTurnstyle(
    turnstyles=[ArithmeticTurnstyle(model, tokenizer, device),
                DateTurnstyle(model, tokenizer, device)],
    probe=probe,
    layer_index=23,  # model-specific
)
text, proof = router.generate("Sum of four hundred forty-five and one fifty-two")

Train a probe by collecting hidden states at your target layer for labeled prompts, then fitting a logistic regression. Save as a .pt file with TurnstyleProbe.save().

SandboxTurnstyle

Extracts Python from prompts via fenced code blocks, inline backticks (`expr`), "what does X return" patterns, or directives (Evaluate: expr). Bare arithmetic falls through to ArithmeticTurnstyle.

from turnstyle import SandboxTurnstyle

t = SandboxTurnstyle(model, tokenizer, device)

text, proof = t.generate("What does `sum(range(101))` return?")
text, proof = t.generate("Evaluate: sum(int(d) for d in str(2**100))")

See docs/sandbox.md for the full reference — code extraction patterns, backends, error behavior, and V1 limitations.

Install

pip install turnstyle

Requires torch and transformers. Works with any HuggingFace causal LM.

For sandbox support (runs Python in a WASM sandbox):

pip install turnstyle[sandbox]

This installs wasmtime and auto-downloads CPython WASM on first use. Falls back to Deno + Pyodide if wasmtime is unavailable.

For probe routing and DispatchTurnstyle.fit_choice (the autoprobe sweep needs scikit-learn):

pip install turnstyle[sweep]

References

Turnstyle is an implementation of neurosymbolic programming — combining neural generation with symbolic computation through constrained decoding.

  • Chaudhuri, S., Ellis, K., Polozov, O., Singh, R., Solar-Lezama, A., & Yue, Y. (2021). Neurosymbolic Programming. Foundations and Trends in Programming Languages, 7(3), 158–243.
  • Neuro-symbolic AI — Wikipedia overview of the broader field.

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

turnstyle-0.4.0.tar.gz (2.1 MB view details)

Uploaded Source

Built Distribution

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

turnstyle-0.4.0-py3-none-any.whl (157.3 kB view details)

Uploaded Python 3

File details

Details for the file turnstyle-0.4.0.tar.gz.

File metadata

  • Download URL: turnstyle-0.4.0.tar.gz
  • Upload date:
  • Size: 2.1 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for turnstyle-0.4.0.tar.gz
Algorithm Hash digest
SHA256 2947bae1f5ff8dc92b8556994e39c2f0c7517867cc49dc423d3491f4f2d8a91d
MD5 11bf375d12749c506d74fe8d4475cb7e
BLAKE2b-256 f210fda763983979cb9c9e46af1615330dd86c748f8e93d98c0222af9d38f5b5

See more details on using hashes here.

Provenance

The following attestation bundles were made for turnstyle-0.4.0.tar.gz:

Publisher: publish.yml on jdonaldson/turnstyle

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file turnstyle-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: turnstyle-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 157.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for turnstyle-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 47e1e42a390bc83121780d906e05281e6b8da20c60ad59b6698a18d68c74c58d
MD5 a7f2466767f770d419518caea97985c8
BLAKE2b-256 e4380049c87e52059ab566e1b4517f1171a7470cbdfc9e145baefe9862ef53ab

See more details on using hashes here.

Provenance

The following attestation bundles were made for turnstyle-0.4.0-py3-none-any.whl:

Publisher: publish.yml on jdonaldson/turnstyle

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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