Apsi
Browse the docs · Apsi 2.4.4

Docs 2.4.4 · 11 min read

From Python to Apsi (apsi convert)

apsi convert translates Python files — or whole projects — into Apsi with Python's exact semantics: the converted program prints the same text, raises the same errors, trains to the same losses and writes the same files as the Python program, byte for byte. Large real programs convert in seconds and run immediately.

apsi convert script.py                 # writes script.apsi (and .apsi files for the modules it imports), checks them
apsi convert script.py --run           # convert, check and run
apsi convert src/ -o apsi_src/         # a whole project, keeping the folder layout
apsi convert script.py --stdout        # print the Apsi code
apsi convert script.py --idiomatic     # map numpy/math/torch onto Apsi's own built-ins (small numeric scripts)

In VS Code, Cursor and the other VS Code-family editors: right-click a .py file (or a folder) → Apsi: Convert Python File to Apsi.

Typical output:

✓ build_graph_cache.py → build_graph_cache.apsi  (0 notes)
✓ GLA.py → GLA.apsi  (0 notes)
    run with --allow-python=all (it uses subprocess)
✓ gla_brain_dual.py → gla_brain_dual.apsi  (0 notes)

converted 5 files, 0 notes

Modules that sit next to the program (import GLA, from gla_brain_dual import X, packages with __init__.py, from . import x) are converted with it and imported as Apsi files; everything else is loaded from Python.

How exact is exact?

The converted file starts with use pycompat. That line gives the file Python's object model and built-ins while the text stays Apsi (fn, let/var, none/true/false, import "x.apsi"). Concretely:

Python Apsi (with use pycompat) behaves
class Net(nn.Module): with __init__, super().__init__(), forward, @property, @staticmethod, @classmethod, __slots__, dunder methods, metaclasses, class decorators, __init_subclass__ class Net(nn.Module): with fn __init__(self, …): … a real class: inherits from any library class, attribute hooks (__setattr__, __getattr__) run, pickling works
@dataclass(frozen=True, order=True) with x: int = 0, field(default_factory=list) the same decorator and x: int = 0 fields Python's dataclass (asdict, fields, replace, frozen errors …)
with open(p) as f, lock: / async with with open(p) as f, lock: __enter__/__exit__, exceptions suppressed when __exit__ returns true
try / except (A, B) as e / else / finally, raise X from Y, own exception classes the same the real exception object is bound (e.args, e.__cause__, isinstance)
generators, x = yield v, yield from, generator expressions the same lazy, send() / throw() / return values
global, nonlocal, closures, default values evaluated once, *args (a tuple), keyword-only and positional-only parameters the same Python's scoping: one namespace per function, loop variables live after the loop
a, *rest = xs, for i, (a, b) in …, a = b = 0, x += [1] (in place) the same
{1, 2}, set/dict comprehensions, {**a, **b}, [*xs, y] the same
is, is not, a < b < c (b evaluated once), ` & ^ << >> ~, :=` the same
for … else, while … else, del, assert, pass the same
f-strings with !r, {x=}, nested specs {v:{w}.{p}f} f-strings, or format() calls when an expression contains quotes Python's format()
print, str, repr, len, range, map, zip, sorted, int, isinstance, super, … Python's own built-ins print([None, True]) prints [None, True]
import x.y as z, from x import y let z = pyimport("x.y"), let y = pyfrom("x", "y") submodules found as Python does
if __name__ == "__main__": the same __name__ is "__main__" only for the program you run; imported files get their module name
docstrings kept (they are docstrings again: help() and __doc__ work)
comments, blank lines, statement order kept
match / case with literal, capture, sequence ([x, *rest]), mapping ({"k": v, **rest}), class (Point(x=0, y=y)), or (1 | 2), as and guard patterns the same Python's matching rules, including __match_args__
except* and exception groups the same
except KINDS as e: where KINDS is a variable or call an isinstance test on the caught error the same handler runs
async def, await, async for, async with, async comprehensions the same runs on asyncio
[b.v for b.v in xs], for st.line, x in … (attribute and index targets) the same
type Pair = tuple[int, int], def first[T](xs: list[T]) -> T, class Box[T]: TypeAliasType, TypeVar and Generic[T] objects
self.__secret inside class Vault the same stored as _Vault__secret (Python's name mangling)
from math import *, from helper import * the module's public names (its __all__ when it has one)
a[1:2, ...], a[*idx], a[1,] the same
reading a name before it is bound the same UnboundLocalError / NameError, as in Python
names that are Apsi keywords (let, var, fn, struct, use, none, true, false, match, type …) written in backticks: `let`, `fn` the same name — getattr, keyword arguments and pickling see let, not a renamed copy

Converted programs run at the speed of the Python program — attribute access, calls and loops compile to the same Python bytecode — and keep every Apsi feature: use rt, use hp, @native functions, units, the checker. Measured on 2 cores (benchmarks/python/cmp.sh, best of five, interleaved):

program CPython 3.13 converted, apsi run
objects: trees, dataclasses, dicts, Counter 0.295 s 0.271 s (8 % faster)
strings: regex, split, JSON, sets 0.271 s 0.273 s
n-body, 200,000 steps (floats, lists) 0.928 s 0.941 s

Programs that create many objects are faster because Apsi freezes everything loaded before the program starts (the cycle collector never scans it again) and collects the youngest generation every 50,000 allocations instead of 2,000; reference counting is untouched, so objects are still freed the moment they are no longer used (APSI_GC=default restores Python's settings). Pure arithmetic in Python loops runs on the same bytecode interpreter as CPython, so it runs at the same speed; to go far beyond it, put @native on the hot function (machine code through LLVM, see native code).

Conversion itself: the 23,769-line program and three of its modules (29,022 lines) convert and check in 4.5 s.

Permissions — what a converted program may touch

Apsi's sandbox stays on. Without any flag a converted program can use:

  • every pure standard module (collections, itertools, functools, re, json, dataclasses, enum, typing, math, random, decimal, time, argparse, threading, queue, datetime, hashlib …)
  • sys, os, os.path, pathlib and numpy as permission-checked stand-ins: the same names and behaviour, but file access asks --allow-read/--allow-write, environment variables ask --allow-env, and the parts that would escape the sandbox (os.system, subprocess, sys.modules, numpy.ctypeslib, np.load(allow_pickle=True), array.tofile) are not there
  • open() checked against --allow-read / --allow-write

Other libraries need --allow-python (for example torch, scipy, pandas), and libraries that reach the operating system or the network (subprocess, socket, http, ctypes, multiprocessing, shutil …) need --allow-python=all; with =all every module is the real one and nothing is filtered. apsi convert prints the flag a file needs, and apsi run says so when one is missing:

apsi run train.apsi --allow-python                     # torch, numpy, scipy …
apsi run GLA.apsi --allow-python=all -- train --epochs 3   # arguments after -- go to the program (sys.argv)

Introspection that leads to interpreter internals (__globals__, __subclasses__, __builtins__, frame objects, object.__getattribute__, __dict__ of library classes, operator.attrgetter("__…"), eval/exec/__import__) is refused unless the program was started with --allow-python=all.

Checked with real programs

  • The Python standard library itself. tests/test_stdlib_convert.py copies 34 pure-Python standard modules next to a driver program — _pydecimal (6,357 lines of arbitrary-precision decimal arithmetic), _pydatetime, argparse, configparser, tomllib, json (a package with relative imports), pickle, dataclasses, enum, random, statistics, fractions, difflib, ipaddress, textwrap, pprint, calendar, shlex, graphlib, heapq, bisect, html.parser, urllib.parse, base64, fnmatch, string, copy, contextlib, operator, reprlib, numbers, _py_abc, colorsys, keyword — converts all of them with the program, runs it, and requires byte-identical output to CPython running the original files. About 38,000 lines of metaclasses, descriptors, generators, __slots__, name mangling, __class_getitem__, exception chaining and 50-digit arithmetic.
  • tests/pycompat/ — programs with classes, inheritance from library classes, dataclasses, enums, abstract base classes, context managers, generators with send, closures, threads, collections, heapq, functools, re, json, numpy and every operator. tests/test_python_compat.py converts each one and requires byte-identical output.
  • A 23,769-line AI training program with its four companion modules (graph building for 30 languages, web search, safety filter, a dual-brain model) converts in about 3 seconds and checks cleanly; its command-line tools, its multi-process graph-cache builder (results pickled across worker processes, resumable output) and its safety filter produce byte-identical files and output to Python.
  • A PyTorch-style training program (an nn.Module hierarchy that registers parameters through __setattr__, autograd, Adam with weight decay, cosine schedule, early stopping, checkpoint) trains to bit-identical losses.

Coverage

Every statement and expression of Python 3.13 converts: there is no "kept as a comment" list any more. Two things differ by design and are not part of a program's behaviour:

  • Tracebacks of an uncaught error name the .apsi file and line (train.apsi:212:1: TypeError: …) instead of the .py file; the error type and message are Python's. Programs that catch errors see the real exception objects.
  • The sandbox. Without --allow-python=all, introspection that leads to interpreter internals is refused (see above). With it, eval, exec, globals(), frames and every module behave exactly as in Python.

A converted module is reused while it is newer than its .py file and was written by the same Apsi version; a new Apsi version converts it again.

--idiomatic: PyTorch programs → native Apsi (no Python, no PyTorch)

A PyTorch training script becomes an Apsi program that runs without Python and without PyTorch. The converter targets use torch, Apsi's own implementation of the PyTorch API: the same layer names, constructor arguments, parameter names (fc1.weight stored as [out, in]), buffers, losses, optimizers and learning-rate schedulers, implemented natively on Apsi's automatic differentiation. Nothing is approximated and nothing is left for you to finish by hand.

import torch
import torch.nn as nn
import torch.nn.functional as F

class MLP(nn.Module):
    def __init__(self, d_in, hidden, d_out):
        super().__init__()
        self.fc1 = nn.Linear(d_in, hidden)
        self.bn = nn.BatchNorm1d(hidden)
        self.out = nn.Linear(hidden, d_out)
        self.drop = nn.Dropout(0.1)

    def forward(self, x):
        return self.out(self.drop(F.relu(self.bn(self.fc1(x)))))

X = torch.randn(256, 4)
y = (X[:, 0] > 0).long()
model = MLP(4, 32, 2)
opt = torch.optim.AdamW(model.parameters(), lr=1e-2)
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=200)
loss_fn = nn.CrossEntropyLoss(label_smoothing=0.1)
for epoch in range(200):
    opt.zero_grad()
    logits = model(X)
    loss = loss_fn(logits, y)
    loss.backward()
    torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    opt.step()
    sched.step()
model.eval()
print(loss.item())
torch.save(model.state_dict(), "mlp.safetensors")

apsi convert mlp.py --idiomatic writes:

# Converted from mlp.py by `apsi convert`.
use models as models
use torch as torch

var _aux3 = none
var _grads2 = none
var _loss_of1 = none
var loss = none

fn MLP(d_in, hidden, d_out):
    let fc1 = torch.Linear(d_in, hidden)
    let bn = torch.BatchNorm1d(hidden)
    let out = torch.Linear(hidden, d_out)
    let drop = torch.Dropout(0.1)
    fn init_(seed = 0) = record(fc1=fc1.init(seed=seed * 1009 + 1), bn=bn.init(seed=seed * 1009 + 2), out=out.init(seed=seed * 1009 + 3), drop=drop.init(seed=seed * 1009 + 4))
    fn forward_(p, x, train = false) = out(p.out, drop(p.drop, torch.relu(bn(p.bn, fc1(p.fc1, x, train=train), train=train)), train=train), train=train)
    return torch.Module(init_, forward_, "MLP", record(fc1=fc1, bn=bn, out=out, drop=drop))
let X = randn(256, 4)
let y = tensor(X[:, 0] > 0, dtype=i64)
let model = MLP(4, 32, 2)
var model_params = model.init(seed=0)
let opt = torch.AdamW(lr=0.01)
let sched = torch.CosineAnnealingLR(opt, T_max=200)
let loss_fn = torch.CrossEntropyLoss(label_smoothing=0.1)
for epoch in 0..200:
    fn _loss_of1(_p):
        let logits = model(_p, X)
        let loss = loss_fn(logits, y)
        return loss
    loss, _aux3, _grads2 = torch.value_and_grad_aux(_loss_of1)(model_params)
    _grads2 = torch.clip_grad_norm(_grads2, 1.0)
    model_params = opt.step(model_params, _grads2)
    sched.step()
model.eval()
print(loss)
models.save_safetensors("mlp.safetensors", torch.state_dict(model, model_params))

The saved file is a standard safetensors file with PyTorch's names (bn.running_mean, fc1.weight, …): model.load_state_dict(load_file("mlp.safetensors"), strict=True) in PyTorch accepts it unchanged, and the other direction works the same way.

What is converted exactly

PyTorch Apsi (use torch)
class Net(nn.Module) with __init__, forward and helper methods; sub-modules of your own classes; nn.Sequential, nn.ModuleList fn Net(...) returning torch.Module(init, forward); parameters are an explicit tree (p.fc1.weight)
nn.Linear, Bilinear, Conv1d, Conv2d (stride, padding including "same", dilation, groups), MaxPool1d/2d, AvgPool1d/2d (ceil_mode, count_include_pad), AdaptiveAvgPool1d/2d, AdaptiveMaxPool1d/2d, Embedding (padding_idx), Dropout, Flatten, Unflatten, Identity the same layers with the same arguments
BatchNorm1d/2d (running statistics, momentum, momentum=None, track_running_stats), LayerNorm, RMSNorm, GroupNorm identical training and evaluation behaviour, buffers included in state_dict
LSTM, GRU, RNN (num_layers, bidirectional, batch_first, dropout, initial states) returns the same (output, (h_n, c_n)) tuples
MultiheadAttention (kdim/vdim, key_padding_mask, boolean or float attn_mask, need_weights, average_attn_weights, is_causal), TransformerEncoder(Layer), TransformerDecoder(Layer), Transformer (norm_first, activation) same results, including attention weights
all activation modules and F. functions (gelu exact or tanh, silu, mish, softplus, glu, …), F.pad, F.normalize, F.one_hot, F.scaled_dot_product_attention, F.cosine_similarity same names under torch.
CrossEntropyLoss (weight, ignore_index, label_smoothing, probability targets, N-d inputs), NLLLoss, MSELoss, L1Loss, SmoothL1Loss, HuberLoss, BCELoss, BCEWithLogitsLoss (pos_weight), KLDivLoss; every reduction exact formulas
torch.optim.SGD (momentum, Nesterov), Adam (amsgrad), AdamW, Adamax, RMSprop, Adagrad, NAdam, RAdam identical update rules
lr_scheduler.StepLR, MultiStepLR, ExponentialLR, CosineAnnealingLR, CosineAnnealingWarmRestarts, LinearLR, ConstantLR, LambdaLR, MultiplicativeLR, PolynomialLR, OneCycleLR (with momentum cycling), CyclicLR, ReduceLROnPlateau, SequentialLR, ChainedScheduler identical learning-rate sequences; sched.step() is kept
opt.zero_grad(); loss.backward(); clip_grad_norm_ / clip_grad_value_; opt.step() one value_and_grad_aux call, clipping, opt.step(params, grads); values computed in the forward pass (logits, accuracy) remain available
model.train() / model.eval(), torch.no_grad(), .to(device), .item(), .detach() kept or removed as appropriate
DataLoader(TensorDataset(...)), DataLoader over your own Dataset class (__len__, __getitem__), batch_size, shuffle, drop_last, collate_fn torch.DataLoader with the same behaviour
torch.utils.checkpoint.checkpoint torch.checkpoint — recomputes activations in the backward pass to save memory
model.state_dict(), load_state_dict(...), torch.save / torch.load, safetensors.torch.save_file / load_file torch.state_dict, torch.load_state_dict; files ending in .safetensors stay safetensors, others become .apsim
tensor methods and functions: view, reshape, flatten, contiguous, size, float, long, sum(dim=), mean, argmax, max(dim=), transpose, permute, unsqueeze, squeeze, clamp, exp, matmul, torch.cat, torch.stack the matching Apsi functions

Verified exactness. The test suite (tests/test_convert_torch.py, tests/test_torch_compat.py) runs every layer, loss, optimizer and scheduler above next to real PyTorch with identical weights and compares outputs, gradients and learning-rate sequences. A complete training run — convolution, BatchNorm, LSTM, multi-head attention, AdamW, cosine annealing, gradient clipping and label smoothing — ends with the same weights as PyTorch to within 1e-9.

Random initialisation uses Apsi's generator, so a model trained from scratch starts from different random weights than in PyTorch. To reproduce a PyTorch run exactly, load its initial state_dict.

Memory

  • torch.checkpoint_layer(layer, params, x) or torch.checkpoint(fn, x) stores only the block inputs and recomputes the rest during the backward pass: about a third of the peak memory for about twice the compute.
  • Create parameters with dtype=f32 (or fit(…, precision="fp32")); gradients and Adam state then stay float32.
  • DataLoader builds one batch at a time, so datasets larger than memory can be streamed from a Dataset class.

After converting with --idiomatic, run apsi check file.apsi (done automatically) — and consider adding units to the physics parts: let g = 9.81 → let g = 9.81[m/s^2] turns every later formula into a checked one.

Found a mistake? Report it · Markdown source