Як я писав інтерпретатор Lisp на Rust разом із Claude Code

Як я побудував risp — Lisp на Rust без жодних залежностей, з деревообхідним інтерпретатором, байткодовою віртуальною машиною та Cranelift JIT — у парі з Claude Code, і яка дисципліна диференційного тестування тримає системний код, написаний ШІ, чесним.

Джерело: github.com/kissishka/risp

risp — це невеликий інтерпретатор Lisp, написаний на Rust: жодних залежностей у типовій збірці, три рушії виконання та опціональний JIT, який випереджає CPython 3.14 у 10–23 рази. Я побудував його в парі з Claude Code. Проте говорити я хочу не про швидкість. Цей допис про робочий процес і про ту дисципліну, яка зробила системний проєкт, прискорений агентом, чимось, чому я справді можу довіряти.

Статут, а не беклог

Перш ніж писати будь-який код, я зафіксував інваріанти. Документація крейта формулює найважливіший із них у першому ж реченні, і читається він як обмеження, а не як похвальба:

//! # risp
//!
//! A small, std-only Lisp interpreter in Rust.
//!
//! The pipeline is: `text -> lexer -> parser -> Value -> eval -> Value`.
//!
//! A single [`Value`] enum serves as BOTH the parsed AST and the runtime value
//! — homoiconicity made literal. ... A hand-written iterative teardown reclaims
//! arbitrarily long or deep `Rc` graphs ... without overflowing the Rust stack.
//! [`eval`] is an explicit-stack machine ...

Єдина залежність, яку risp коли-небудь бере — Cranelift, для JIT — ізольована за прапорцем функціональності, тож типова збірка не підтягує нічого:

/// Optional Cranelift-backed native JIT for the integer subset. Compiled only
/// under `--features jit`; the default build is std-only with zero dependencies.
#[cfg(feature = "jit")]
pub mod jit;

Оце #[cfg(feature = "jit")] і є примусом: жоден код у цьому модулі не компілюється, доки ви явно його не увімкнете, тож типова cargo build взагалі не тягне жодного зовнішнього крейта. Коли набирає агент, обмеження на кшталт цього — це кермо. Вони перетворюють задачу «напиши Lisp» на задачу з твердими краями, які має поважати кожне подальше рішення.

Одне значення, три рушії

За своє життя risp обзавівся трьома рушіями виконання — деревообхідним інтерпретатором (tree-walker), байткодовою VM та Cranelift JIT — але всі вони тримаються на одному крихітному диспетчері CLI і всі користуються спільним обчислювачем, середовищем і стандартною бібліотекою:

let result: Result<(), RispError> = match args.as_slice() {
    // No arguments: interactive REPL.
    [] => repl::run(),
    // `-e EXPR`: evaluate one expression.
    [flag, expr] if flag == "-e" => runner::run_str(expr),
    // `--vm PATH`: run a file on the bytecode VM fast path.
    [flag, path] if flag == "--vm" => risp::vm::run_file(path),
    // `--jit PATH`: VM + Cranelift JIT (requires `--features jit`).
    #[cfg(feature = "jit")]
    [flag, path] if flag == "--jit" => risp::jit::run_file(path),
    // A single path argument: run the file on the tree-walker.
    [path] if !path.starts_with('-') => runner::run_file(path),
    _ => { /* usage error */ }
};

Типовий шлях — це деревообхідний інтерпретатор. VM і JIT — це прискорювачі, накладені поверх нього, і деревообхідний інтерпретатор ніколи не видаляється, коли з’являються швидші рушії. Він лишається ввімкненим як еталонна реалізація, і це, як виявляється, є самим фундаментом стратегії тестування, описаної нижче.

Контракт «жодних паніки»

REPL, що падає на одруківці, марний, тож risp має рівно один тип помилки, а ядро ніколи не панікує на введенні користувача:

/// The one typed error enum threaded through every interpreter stage.
#[derive(Debug, Clone)]
pub enum RispError {
    // --- reader stage ---
    UnexpectedEof,          // the REPL uses this to ask for more input
    UnexpectedClose,
    UnterminatedString,
    InvalidNumber(String),
    BadDottedList,
    // --- eval stage ---
    UnboundSymbol(String),
    NotCallable(String),
    Arity { name: String, expected: String, got: usize },
    TypeError { op: &'static str, expected: &'static str, got: String },
    BadSpecialForm { form: &'static str, msg: String },
    DivisionByZero,
    Custom(String),         // from the `(error "msg")` builtin
}

Варіанти згруповані за стадією, яка їх породжує, тож походження помилки зчитується з одного погляду. UnexpectedEof виконує справжню роботу: це сигнал, який повідомляє REPL продовжувати буферизувати багаторядкову форму, замість того щоб повідомляти про помилку. На шляху обчислювача ніде немає Box<dyn Error>; RispResult = Result<Value, RispError> протягнутий через кожну публічну функцію. Паніки зарезервовані для справжніх порушень внутрішніх інваріантів інтерпретатора, а ніколи для чогось, що може спричинити програма.

Тримати ШІ чесним за допомогою диференційних тестів

Це найважливіша частина. Генерувати системний код за допомогою агента швидко. Достатньо швидко, щоб вузьким місцем ставала довіра, а не набирання тексту. Відповідь risp — це один помічник, який з’являється, майже ідентично, і в tests/vm.rs, і в tests/jit.rs:

/// Assert the VM and the tree-walker agree (and return that shared result).
fn agree(src: &str) -> String {
    let interp = Interpreter::new();
    let tree = interp.run_source(src).unwrap().to_string();
    let got = vm_eval(src);
    assert_eq!(tree, got, "VM disagrees with tree-walker for {src:?}");
    got
}

Це теза, втілена у виконуваному коді: прогнати ту саму програму на двох рушіях, серіалізувати обидва результати, перевірити побайтову рівність. Деревообхідний інтерпретатор виступає оракулом, і кожна оптимізація безперервно тестується проти нього. Версія для JIT іде ще далі й нормалізує обидві сторони до Result<String, String>, тож вона тестує паритет помилок, а не лише паритет успіхів.

Чому це так важливо саме для коду, написаного ШІ? Бо швидкі шляхи — це рівно те місце, де правдоподібна на вигляд зміна тихо ламає якийсь кутовий випадок. Два реальних приклади, які зловив agree():

#[test]
fn redefined_operators_are_not_inlined() {
    // Rebinding a core operator must take effect on the VM too.
    assert_eq!(agree("(def + (lambda (a b) 999)) (+ 1 2)"), "999");
    assert_eq!(agree("(+ 1 2)"), "3");   // not redefined -> still inlined, still correct
}

Байткодовий компілятор, який вбудовує + у опкод Add, якщо не подбати про це, продовжуватиме його вбудовувати навіть після того, як програма перевизначить + як функцію. Це тонка помилка з неправильною відповіддю, і agree() змушує VM видати 999 або завалити збірку. Другий випадок — це регресія, яка раніше переривала весь процес:

#[test]
fn deep_non_tail_recursion_deopts_instead_of_overflowing() {
    // (Regression: before the depth guard this exited 134 / "stack overflow".)
    assert_eq!(
        agree("(def sum (lambda (n) (if (< n 1) 0 (+ n (sum (- n 1)))))) (sum 1000000)"),
        "500000500000"
    );
}

JIT компілює цілочисельну рекурсію у машинний код на обмеженому C-стеку; не-хвостова рекурсія глибиною в мільйон переповнила б його й видала б SIGABRT. Виправлення (пастка деоптимізації, що відкочується до VM зі стеком на купі) — це саме той вид агресивної, копіткої системної зміни, яку ви б нервували довіряти агентові. Тест знешкоджує це: він проганяє ту саму програму через деревообхідний інтерпретатор, який рекурсує на купі й повертає правильну відповідь, а потім перевіряє, що JIT збігається з ним точно. Падіння не дає жодного значення, тож це автоматичний провал.

По всьому набору це ~60 еталонних тестів деревообхідного інтерпретатора в integration.rs, плюс 14 тестів VM і 12 тестів JIT, що всі проходять через agree(). Блоки unsafe у VM і генерація машинного коду в JIT — це не ставка проти коректності. Другий, простіший інтерпретатор, який ніколи не зрушив з місця, перевіряє кожен із них.

Фасад Interpreter

Усе це ховається за одним вбудовуваним типом, зі стандартною бібліотекою, запеченою на етапі компіляції й завантаженою тим самим шляхом, що й код користувача:

impl Interpreter {
    pub fn new() -> Self {
        let global = env::new_global();
        builtins::install(&global);
        let interp = Interpreter { global };
        interp.load_prelude();   // include_str!("prelude.lisp"), evaluated
        interp
    }
}

map, filter і fold не є нативними — вони живуть у prelude рівня risp, вбудованому в бінарник через include_str! і обчисленому під час старту, що тримає нативну поверхню мінімальною, а рекурсію на власному стеку обчислювача в купі.

Як цей цикл відчувався насправді

Розподіл праці лишався сталим увесь час: я тримав інваріанти й визначення «готово», Claude Code забезпечував пропускну здатність, а диференційні тести виступали арбітром. Складні ідеї — ітеративний обчислювач, який ніколи не переповнює стек, макроси, що розгортаються в нативні цикли, байткодова VM, що випереджає CPython, JIT поверх неї — були побудовані й перевірені значно швидше, ніж я впорався б сам, і жодного разу в реліз не пішла зміна, яку я не міг би пояснити. Це і є аргумент на користь такого способу будувати: агент — це кінські сили, статут і тести-оракул — це гальма, і потрібні обидва.

Часті запитання

Що таке risp?
risp — це невеликий інтерпретатор Lisp, написаний на Rust, без жодних залежностей у типовій збірці. Він має три рушії виконання (деревообхідний інтерпретатор, байткодову віртуальну машину та опціональний Cranelift JIT), які користуються спільним обчислювачем, середовищем і стандартною бібліотекою. Його було побудовано в парі з Claude Code.
Як підтримувати коректність інтерпретатора, написаного ШІ?
risp проганяє кожну програму через два рушії, простий еталонний деревообхідний інтерпретатор і оптимізовану VM чи JIT, і перевіряє побайтову ідентичність результатів за допомогою помічника диференційного тестування під назвою agree(). Деревообхідний інтерпретатор виступає оракулом, тож будь-яка оптимізація, що з ним розходиться, валить збірку ще до того, як потрапить у реліз.
Чи має risp якісь залежності?
Ні. Типова збірка cargo не підтягує жодного зовнішнього крейта. Єдина залежність, Cranelift для JIT, ізольована за прапорцем --features jit, тож інтерпретатор лише зі стандартною бібліотекою ніколи не тягне сторонній код, доки ви явно цього не оберете.

← Усі дописи