Bollywood HungamaJanhvi Kapoor, Sreeleela and Siddharth Gupta team up for Umesh Bisht’s thriller, first schedule begins in NainitalInquirerTaal Volcano records 17 earthquakes in 24 hoursCNN TürkTaksitli alışverişlerde yeni dönem: Cep telefonlarında 12 taksit sınırı değiştiPunchPolice remove Rivers DPO after station renovation by suspected cultistsDaily MaverickKATLEHONG MASS SHOOTING: Girl, 11, who survived mass shooting still has bullet lodged near her spineRTP DesportoFrancisco Cabral na final de pares do Masters 1.000 de XangaiThe Jerusalem PostIDF blocks roads leading to Gaza border after Nahala settlers attempt to build outpost overnightEgypt IndependentTemperatures drop 3°C as rain reaches CairoMalay MailFahmi: No Malaysian palm oil companies involved in burning activities in IndonesiaColliderApple TV's New Crown Jewel Officially Continues Streaming DominationSouth China Morning PostChina’s AI boom, self-sufficiency push fuel earnings surge for local chip firmsSky TG24Sciopero scuola e blocco autotrasportatori: giornata di mobilitazione da Nord a Sud. LIVE
The Daily Newsstand · Free, Always
Friday, October 16, 2026

Можно ли написать язык программирования с ИИ? Опыт Nova: первый компилятор зашёл в тупик, второй собрал себя сам

Translate

Я пишу язык Nova с ИИ-агентами пять с половиной месяцев. Nova — это Go/Rust-подобный язык для бэкенда и серверных программ, где функция в типе объявляет прямым текстом, чем занимается сама. Например, работает с сетью, часами, файловой системой и т. п. В Nova это называется эффектами (идея: Koka, Eff, OCaml 5). Эффекты могут быть подменены в рантайме и направлять программу по другому пути исполнения. Поэтому Nova не требует движков для моков и систем тестирования, просто реализация подменяется на тестовую через эффекты, а сама программа остаётся неизменной. Я пробую довести систему эффектов до прикладного состояния. У языка, на текущий момент, два компилятора: Nova Oracle (на Rust) и Nova Carina (на самой Nova). Спецификация языка записана как журнал проектных решений, каждое с обоснованием: их больше 300. Ещё больше десятка документов описывают соглашения по разработке.

Начну с эпизода, после которого Oracle (почему такое название, скажу в статье), первый компилятор Nova, зашёл в тупик. Летом мы с агентами «чинили» определение типов выражений. Агенты со временем завели несколько параллельных реализаций одного и того же, и тип выражения вычислялся дважды: в проверке типов (дальше чекер) и ещё раз в генераторе кода, как запасная ветка (дальше запасной вывод). С 11 июля мы с агентами месяц сверяли эти два вывода в попытке свести всё к одной «двери», то есть к одной реализации: запасной планировал удалить, как только всё переедет в чекер и будет работать идентично. Совпадало почти всё, но запасного вывода меньше не становилось. Тесты проходили, отчёты были зелёными, а я принимал сверку за прогресс, и месяц на самом деле стоял на месте по этой теме. Одна из основных проблем в работе текущих агентов: контролируемо заставлять их выполнять наши задания.

Так можно ли разработать и написать новый язык с ИИ? Да, до альфы и самосборки уже доказано: второй компилятор, Nova Carina, всё же собрал себя сам из своих исходников! Для меня это значительное достижение. Что же касается стабильного и мощного компилятора на выходе, тут работа и выводы ещё впереди. Первую копию Nova из исходников на Nova собрал компилятор Oracle. Затем получившийся компилятор собрал себя сам из своих же исходников, вот и получилась самосборка.

Nova Oracle получился переходной альфой: он довольно неплохо реализует спецификацию языка Nova, он компилирует всю стандартную библиотеку, подключаемые модули, примеры и реальные приложения. Также на нем собирается Carina. Сейчас в Oracle я чиню только то, что мешает полному релизу Carina, т.к. сейчас она умеет делать проверку синтаксиса, типов и генерацию C. Oracle тоже на выходе получает исходники на языке C, которые компилируются (clang и т. п.) в исполняемую программу.

Дальше — как это было по шагам. В конце — семь приёмов проверки работы агентов, которые я из этого вывел, и как попробовать Nova самому.

Лента времени проекта Nova, май — 11 октября

Лента времени проекта Nova, май — 11 октября

Кто есть кто и как я работаю с агентами

  • Oracle и Carina. Oracle — переходная альфа, сборщик Carina и эталон (образец ответов, с которым сверяется Carina). Carina — второй компилятор, написанный заново на самой Nova: это не перевод кода Oracle с Rust, а новая архитектура. Я начал делать Carina 14 августа. 11 октября 2026 года вышли альфа-выпуски Oracle 0.1.0 и Carina 0.2.0.

  • Агенты и приёмка. Nova я пишу на личных аккаунтах: в нескольких параллельных сессиях работают агенты, одна из них, агент-интегратор, сводит работу остальных. Я определяю, что считать выполненным заданием, и выборочно читаю код. Правила приёмки собираю в публичную методологию (тексты под CC BY 4.0, скрипты под Apache-2.0 или MIT).

  • Масштаб и цена. С мая (пять месяцев) около 19 тысяч коммитов, больше 300 тысяч строк на Rust в Oracle (основная часть — компилятор), около 64 тысяч строк на Nova в исходниках Carina и около 16 тысяч в её тестовых фикстурах. Стандартная библиотека std на Nova примерно того же объёма, что и сама Carina: около 65 тысяч строк (из них около 17,5 тысячи тестов). Восемь опубликованных пакетов (HTTP, TLS, Polaris, сжатие, DuckDB, PostgreSQL, числа произвольной точности, SOCKS): ещё около 43 тысяч строк по последним тегам. Всего на Nova около 190 тысяч строк вместе с тестами. Около 160 тысяч рублей на подписки за всё время (порядка 30 тысяч в месяц); почти всё время основным агентом был Claude Code, в нескольких параллельных сессиях над непересекающимися задачами. Аккаунт Claude Code у меня один, и это сильно ограничивает скорость: с несколькими аккаунтами можно делать гораздо больше или быстрее. Но проект я делаю на энтузиазме, поэтому работаю со своего одного аккаунта. Недавно подключил ещё Codex и Kimi Code, они тоже хорошо справляются. Своё время не считал.

Про имена. Carina — это киль корабля Арго, «Nova Carinae» — обозначение новых звёзд в созвездии Киля (почему выбрано именно это имя, записано в соглашении об именах). Имя Oracle придумали агенты, я его заметил в их переписках. Они назвали базовый компилятор Оракулом, потому что ходили к нему для сверки результатов Carina. Мне это показалось довольно интересным, и я решил оставить имя Oracle.

Как выглядит Nova и зачем она

Nova довольно похожа на «Go с эффектами»: то, что функция сама делает с внешним миром, видно в её типе. Часть эффектов заложена в stdlib (например, Net, Time), пользователь вправе добавить свои эффекты (например, Counter, Emit). Nova компилируется в C, а затем в нативный бинарник, без виртуальной машины; сборщик мусора есть. Назначение Nova: бэкенд, серверные программы; HTTP-фреймворк Polaris для неё уже написан (ниже).

Вот сигнатура публичной функции демонстрационного агрегатора, из aggregate.nv (строка 113; в { … } тело опущено):

export fn aggregate(sources []Source, budget Duration) Net Time Emit -> Report { … }: части сигнатуры — публичная функция, имя, параметры, эффекты Net Time Emit, результат Report, тело опущено

export fn aggregate(sources []Source, budget Duration) Net Time Emit -> Report { … }: части сигнатуры — публичная функция, имя, параметры, эффекты Net Time Emit, результат Report, тело опущено

Текст для копирования
export fn aggregate(sources []Source, budget Duration) Net Time Emit -> Report { … }

export делает функцию публичной, []Source обозначает массив, Net Time Emit между параметрами и стрелкой задаёт эффекты (Emit — собственный эффект агрегатора: события прогресса для живого интерфейса, например «запрос начался», «завершился», «отменён»). Публичная функция обязана объявить эффекты, к которым обращается сама: если она обращается к часам, а Time в типе нет, это ошибка компиляции.

Как в Nova устроены конкурентность, эффекты и ресурсы, показано в конце, в разделе «Как попробовать Nova».

68 тысяч строк в одном файле: как возник запасной вывод типов

Oracle рос от задания к заданию: агент пишет план, код и тесты, я проверяю результат. Архитектура компилятора, написанная агентами, получилась — ну, скажем, не получилась (такое бывает и без ИИ; скажу наперёд: заставить агентов писать по чётко выверенной архитектуре — та ещё задачка), и к выпуску генератор C-кода (часть, которая переводит программу в C) стал одним файлом порядка 70 тысяч строк. В Nova я заложил мощную поддержку дженериков и на расчёт мономорфизации типов уходит очень много кода.

Пришлось выяснять, почему компилятор ошибается в похожих местах. Тип определял чекер и записывал его в таблицу. Генератор C читал таблицу, а если записи не было, определял тип ещё раз, своей логикой. Это и есть запасной вывод.

Генератор C читает типы из таблицы чекера или выводит их сам

Генератор C читает типы из таблицы чекера или выводит их сам

Каждая такая правка по отдельности выглядела разумно. Запасной вывод разросся до нескольких тысяч строк в двух функциях, и на него опирались сотни мест. Когда оба вывода расходились, компилятор мог молча вызвать метод не того типа: программа собиралась, ошибки не было, а работала неправильно.

Так появился план «Одна правда»: дополнить таблицу чекера и удалить запасной вывод из генератора.

Я принимал отчёты о прогрессе, а запасной вывод только рос

Чекер дополняли новой логикой (расширяли таблицу типов). Она сначала работает в тени: считает то же самое, сверяет свой ответ с запасным выводом и выбрасывает его, а реально работает по-прежнему запасной вывод. Когда ответы совпадают везде, запасной вывод удаляют. Так мы с агентами работали около месяца: план создан 11 июля, а Carina я начал 14 августа (≈34 дня).

Месяц я принимал совпадение ответов за движение к цели, а это была только проверка, что новый код ничего не ломает.

На лёгких случаях новая логика справлялась, а на сложных, с обобщёнными типами, отвечала «не знаю». Это не считалось расхождением, поэтому сверка показывала совпадение почти везде, а сложные случаи по-прежнему решал запасной вывод. Счётчика удалённого кода не было.

Потом мы с агентами это признали и вернулись к прямой работе: дополнять таблицу и удалять ветви запасного вывода по одной. Появился счётчик: каждый этап обязан уменьшать запасной вывод. Он мерил одну из двух функций, infer_call_ret_c. Она и правда уменьшилась, но вторая, infer_expr_c_type, за то же время выросла (первый замер в таблице от 12 июля):

12 июля

выпуск

infer_call_ret_c, строк

≈2590

≈2380

infer_expr_c_type, строк

≈2340

≈2820

вместе

≈4,9 тысячи

≈5,2 тысячи

вызовов infer_expr_c_type в emit_c.rs

229

242

Сумма почти не изменилась: ≈4,9 → ≈5,2 тысячи строк, в реальности увеличилась (считал по git: коммит b7a45bf от 12 июля и ревизия выпуска eee6eae; строки между фигурными скобками функций).

Одна функция уменьшилась, другая выросла, сумма не уменьшилась

Одна функция уменьшилась, другая выросла, сумма не уменьшилась

Счётчик смотрел только на одну из двух функций, поэтому прогресса не показывал. Переделку мы с агентами так и не довели, поэтому тихие ошибки этого класса в Oracle возможны, и это одна из причин, почему он альфа. Тесты показывали, не изменилось ли поведение, но сколько запасного вывода удалось убрать, сказать не могли.

До релиза оставалось 36 дефектов-блокеров. Потом 62

Блокер: известный дефект, из-за которого я не выпускал Oracle 0.1.0, пока он не исправлен. Я вёл их по плану выхода на релиз. Замеры по неделям до паузы 9 сентября (число блокеров считает скрипт проекта по реестру дефектов):

67 → 36 → 44 → 62.

Реестр дефектов и блокеры по неделям. Всего записей: 687, 715, 824, 1020; закрыто: 259, 300, 359, 441; открыто: 428, 415, 465, 579; блокеров: 67, 36, 44, 62

Реестр дефектов и блокеры по неделям. Всего записей: 687, 715, 824, 1020; закрыто: 259, 300, 359, 441; открыто: 428, 415, 465, 579; блокеров: 67, 36, 44, 62

За первую неделю блокеров стало почти вдвое меньше: это настоящие исправления, и в ту неделю закрыли больше, чем нашли. Затем дефекты стали находить быстрее, чем закрывать. Например, 8–9 сентября нашлось, что компилятор молча терял часть настроек проекта в некоторых ситуациях. Такой класс ошибок ловится очень сложно. Найденные блокеры в основном были старые, но мы с агентами стали их находить пачками только сейчас, десятки блокеров могли быть закрыты в день, а ещё большее количество прибавлялось среди найденных, и расстояние до релиза в таких условиях, конечно, только увеличивалось.

За три недели до паузы в реестр добавилось 333 записи, а закрыто 182. В реестре дефектов все найденные дефекты любой важности, закрытые тоже; к октябрю там набралось 1789 записей. Блокеры — лишь небольшая часть реестра, поэтому числа 62 и 1789 сравнивать нельзя.

Вторую попытку я начал с правил, которые проверяет машина

Решение о Carina я принял в проектной сессии 13 августа (план 274, добавлен 13 августа, коммит 0d1505c), работа началась 14 августа. Как я сказал ранее, дефекты находились быстрее, чем исправлялись, и причина была в дизайне компилятора Oracle. Поэтому я сразу поставил цель: новый компилятор должен был делаться по продуманной архитектуре, быть стабильным и соответствовать спецификации языка. Вторая цель: быть максимально быстрым, о чем я уточнил запись 11 сентября. Уровня Go, вероятно, не достичь: несравнимы масштабы разработки, также дженерики усложняют компилятор и увеличивают время компиляции значительно (запись реестра №640: семь строк исходника дают 285 КБ C и 444 тела функций из-за полной мономорфизации). Это цель, а не обещание. Починить всё значило бы переписать всё, то есть сделать новый компилятор на Rust? По моим тогдашним словам, воронка не сходилась, положительной динамики не было. Запасной вывод к тому времени уже несколько раз латался точечно (план 196.7 закрыт 15 июля неполно; запись №696 от 16 августа). Всё это вместе привело меня к мысли перестать латать то, что нельзя залатать полностью: 16 августа в плане 196 я запретил дальнейшие точечные заплатки. Я решил написать компилятор заново, сразу на Nova. Это серьёзный вызов и заодно возможность допилить Nova сразу на большом проекте, где можно проверить ее жизнестойкость.

Первый месяц работы над Carina я отвёл на то, чтобы понять перспективность идеи: в плане 274 день 30 назван точкой переоценки. Выпуск Oracle я ещё почти месяц пытался стабилизировать параллельно с разработкой Carina. 9 сентября я поставил его выпуск на паузу, 15 сентября решил, что релизиться будет сразу Carina. Перед выпуском я пересмотрел это: Oracle вышел альфой, без обещания стабильности, потому что только он пока собирает Carina.

Архитектуру с первого дня мы с агентами разбирали по образцам других компиляторов: в плане они названы донорами. От rustc взяли вывод типов внутри тела функции и обход экземпляров дженериков, от Go сопоставление типов при вызове дженерика и генерацию экземпляров (плюс ранний компилятор Go на C как образец некоторых решений), от Roslyn дерево разбора без потерь, от Zig и Carbon индексы узлов дерева. Записывать тип выражения разрешено только чекеру, остальные части читают его ответ. CI (автоматические проверки на сервере при каждой сборке) ищет вызовы функций записи и вывода типа за пределами чекера, по именам функций вывода типа. Допустимые зависимости между модулями записаны в файле правил, его тоже проверяет CI. В Oracle их ловил я глазами и, конечно, неуспешно. Для себя я пока вывел так: правило, которое просто записано, в большом проекте агенты рано или поздно нарушат. Держится только то, что машина проверяет при каждой сборке: архитектура, регламенты, соглашения. Как этого достичь — тема для отдельного обсуждения.

Конвейер Carina: lex, parse·tree, names, check, lower, mono, emit_c с донорами; таблицу типов пишет только check, остальные читают; CI на каждой сборке проверяет запись типов вне check и зависимости модулей по файлу правил

Конвейер Carina: lex, parse·tree, names, check, lower, mono, emit_c с донорами; таблицу типов пишет только check, остальные читают; CI на каждой сборке проверяет запись типов вне check и зависимости модулей по файлу правил

Подробнее: документ архитектуры Carina с таблицей слоёв и доноров.

Тест проходил, потому что проверял только целые

С 30 августа в проекте работают охотники за дефектами: это отдельные агенты, которым назначают модуль и класс дефектов. Охотник ищет пробами, ничего не чинит и сдаёт отчёт, а находки заносятся в реестр. К релизу Oracle охотники нашли 202 записи реестра (определение агента).

5 сентября охотник проверял генератор C у Carina (отчёт) и среди прочего отметил, что генератор молча превращает в целое всё, что не строка (самовольное решение агента), а тест при этом зелёный, т.к. он проверял только целые. Это очень частая механика работы агентов: найти дыру в подходе и протаскивать через неё всё, что надо и не надо.

Проверка типов пускала в строковую интерполяцию "${expr}" только строки и целые, хотя я изначально настойчиво просил агентов делать без упрощений, т.к. натыкался на такое уже миллион раз, а исправлять упрощения потом — отдельная головная боль, учитывая их количество в большом проекте и неполную фиксацию (в этом агенты тоже могут халтурить). Но агенты нашли, что тесты проверяли только целые типы и начали лепить целые везде, даже там где типы целыми не являлись. По спецификации подставлять можно и дробные, и логические значения, и Option, и любой тип, превращаемый в строку. Это латентное расхождение со спецификацией: агент сузил реализацию до удобного решения. Прочитав отчёт, я спросил то, что стоило спросить сразу:

а такое сужение вообще допустимо по спецификации и нашим договорённостям?

Три слоя по-разному понимают интерполяцию; тест проверял только целые

Три слоя по-разному понимают интерполяцию; тест проверял только целые

После этого я закрепил правило: делать строго по спецификации. Если что-то пока не сделано, компилятор должен прямо сказать «пока не поддерживается», а пробел должен быть записан в реестр или план. Молча сделать проще, чем требует спецификация, нельзя: такая задача не считается выполненной, тем более что тесты этого не поймают.

Эталон тоже ошибается: 25 обходов багов Oracle в исходниках Carina (середина сентября)

При написании нового компилятора удобно подавать ему ту же программу, что и для Oracle, и сравнивать результат. Так нашлись новые дефекты Oracle. Перенести дефекты в новый компилятор значило бы перенести ошибки, поэтому в спорных случаях решала спецификация языка Nova.

Баг Oracle иногда приходится обходить в исходниках самой Carina, иначе инструмент, которым её собирают, не даёт её собрать. Такой учёт обходов в плане назван «налогом оракула». К 16 сентября в исходниках Carina стояло 25 обходов шести багов Oracle в 18 файлах (пометки LEGACY-#N со ссылкой на запись реестра). Обход делался другой конструкцией языка, которая компилировалась правильно. К выпуску Carina все шесть багов исправили в самом Oracle, и обходы убрали: последние сняты 30 сентября.

Такие же пометки стоят ещё у двух записей, но это не баги Oracle: №700 — долг самой Carina (на ошибке в программе она пока может упасть внутренней ошибкой вместо понятного сообщения), №708 — недоделка в рантайме языка: по спецификации обработчик сбоя должен получать запись об ошибке с полями, но это ещё не реализовано, поэтому Carina пока разбирает текст сообщения об ошибке. Они остались и в выпуске.

Обходы багов Oracle в исходниках Carina: 16 сентября 25 обходов шести багов в 18 файлах, к выпуску 0; пометки №700 и №708 не про Oracle и остались

Обходы багов Oracle в исходниках Carina: 16 сентября 25 обходов шести багов в 18 файлах, к выпуску 0; пометки №700 и №708 не про Oracle и остались

«CI зелёный, кроме известного красного»: за красным шагом ничего не запускалось

20 сентября агент-интегратор стал разбирать, что именно исполняется на CI, и обнаружил, что модульные тесты компилятора не запускаются. Перед ними падал шаг проверки соглашений проекта, и всё после него пропускалось. Этот красный шаг и я, и агенты считали известной проблемой и не трогали, а снаружи пропущенные тесты выглядели так же, как пройденные. Не запускались они минимум с 16 сентября. Я рассчитывал на защиту, которой, как оказалось, не было. Почему спохватились только 20 сентября? Аккаунт Claude Code у меня один, и его лимита хватает только на главные задачи: я тратил его на разработку Carina и выпуск. На отдельного агента, который занялся бы самим CI, лимита уже не оставалось, как и на многие другие вопросы в разработке Nova.

Красный шаг соглашений, все следующие шаги пропущены

Красный шаг соглашений, все следующие шаги пропущены

20 сентября красный шаг перестал падать, и CI наконец запустил модульные тесты Carina (прогон): они прошли, 27 файлов из 27.

Шаг соглашений прошёл, сборка прошла, модульные тесты: 27 из 27

Шаг соглашений прошёл, сборка прошла, модульные тесты: 27 из 27

В те же дни Carina начала проверять собственные исходники командой check (проверка без генерации кода) по модулю целиком, а не по файлу. Число диагностик упало:

1216 → 332 (к 20 сентября).

Разница в 884 это не исправленные баги. Раньше каждый файл проверялся отдельно и не знал, что объявлено в соседних файлах, и большая часть сообщений была ложной тревогой из-за этого. Когда проверку стали запускать на весь модуль, такие сообщения пропали сами.

Диагностики check: 1216, 332

Диагностики check: 1216, 332

Ещё одна проверка вела себя по-разному в зависимости от окружения: 3 октября всплыло, что на CRLF один файл переставал разбираться, а другой, с неподдерживаемой конструкцией, принимался: результат проверки зависел от переводов строк. Исправили в тот же день, в лексере.

Carina собрала себя дважды, и результаты совпали байт в байт

Carina 0.2.0 на Windows x86_64 собрала себя дважды: и C-код, и исполняемые файлы совпали байт в байт (при детерминированной линковке, C-код компилировался в бинарник отдельным скриптом, т.к. в Carina пока не реализована команда build). Это самосборка в базовом виде. Самосборку зафиксировали вечером 10 октября 2026: наблюдавшийся мной прогон начался около 19:35 UTC, побайтное совпадение записано в журнале приёмки. Отдельный CI на коммите 99756a8 в тот же вечер завершился успешно, но побайтное совпадение он сам по себе не доказывает. От начала работы 14 августа прошло меньше двух месяцев.

После истории с пропущенными тестами мне нужен был ответ на вопрос: как узнать, что не врёт сама проверка? Сравнение проверили в обратную сторону:

один изменённый байт в сгенерированном C или в исполняемом файле должен давать «не совпадает» с номером байта.

В копии файла изменили один байт, и сравнение показало «не совпадает» ровно на этом байте. После восстановления байта оно снова показало «совпадает».

Как устроена цепочка. A — Carina, собранная Oracle. A переводит исходники Carina в код на C, clang собирает из него исполняемый файл B. Затем B делает то же с теми же исходниками и получает B′.

Oracle                        → A
A (исходники Carina) → C-код₁ → clang → B
B (исходники Carina) → C-код₂ → clang → B′
проверка: C-код₁ = C-код₂ и B = B′, байт в байт

A — первое поколение (собрано Oracle), B — второе, B′ — третье. A ≠ B допустимо: должны совпасть C-код₁ с C-кодом₂ и B с B′. (В журнале приёмки, где записаны проверки самосборки, приложенной к релизу Carina 0.2.0, третья копия B′ называется C.)

Oracle собирает Carina A, Carina A собирает Carina B, Carina B собирает Carina B′ из того же исходника на Nova; C-код и файлы B и B′ совпадают

Oracle собирает Carina A, Carina A собирает Carina B, Carina B собирает Carina B′ из того же исходника на Nova; C-код и файлы B и B′ совпадают

Детерминированная линковка нужна потому, что линкер по умолчанию вписывает в исполняемый файл время сборки и имя файла: иначе файлы нельзя сравнивать. Поэтому она включена, а B и B′ линкуются под одним именем в разных каталогах.

Сам C-код у Carina, собранной Oracle, и у Carina, собранной самой Carina, получается один и тот же из одних исходников (кроме общего снимка Oracle).

Корректность языка совпадение копий не доказывает. Есть три оговорки:

  1. Carina строилась не на пустом месте. Спецификация, стандартная библиотека и рантайм уже были, а Oracle служил и сборщиком, и эталоном. Исходники Carina написаны на понятном ей подмножестве; из примеров проекта (118 файлов) она на 10 октября принимает 66, и в 61 из них поведение совпало с собранным Oracle (по логу CI на коммите Carina); ещё 43 она пока не умеет принять. Одинаковая ошибка может остаться в обеих копиях: для семантики нужны свои тесты.

  2. Около 12% C — готовый снимок Oracle. Это ≈1,5 из 13,25 МБ: служебные таблицы и используемые части стандартной библиотеки, заранее сгенерированные Oracle. Совпадение B и B′ относится к коду программы плюс к этому общему снимку. Компилировать стандартную библиотеку самой Carina ещё предстоит.

  3. Первую копию собрала внутренняя сборка Oracle, а не опубликованный архив. Журнал приёмки (релиз Carina 0.2.0, там же сохранённый B) записал только хеш этой сборки. Поэтому цепочку отдельно повторили с опубликованным архивом Oracle 0.1.0: A собрана его релизным nova.exe, B и B′ (в расписке второе называется C) совпали побайтно (3 874 304 байта), как и C-файлы (13 250 302 байта). Это замещающая расписка релиза; числа в ней другие, чем в первоначальной приёмке, потому что это отдельный прогон. Первоначальная приёмка по-прежнему шла на внутренней сборке.

Carina пока умеет только check и emit (проверка и генерация C), Linux-бинарника в 0.2.0 нет, а сборка всех пакетов и примеров входит в критерии выпуска 0.3. Исходники Carina открыты. Цель 0.3 — чтобы Carina сама собирала и запускала все примеры и пакеты, а проверки и CI проходили на одном коммите. Если Carina расходится с Oracle, сверяемся со спецификацией Nova.

Семь попыток агента и порог приёмки «30 из 30»

Отступление в май: то же «почти работает» случилось раньше. 31 мая и 1 июня агент семь раз подряд пытался исправить гонку в рантайме с планировщиком файберов (лёгких потоков, как горутины в Go; около 10 часов работы агента в шести сессиях; debugging-races.md и план 83.11, §12.13–12.26; в таблице попытки пронумерованы мной по порядку). Каждая давала «почти работает». Воспроизводилось так: в блоке с токеном отмены (объект, которым останавливают группу заданий) запущенное внутри задание отменяет этот же токен, и программа иногда падает. Сколько прогонов стресс-теста из 30 проходило успешно:

№

Что пробовали

Успешных прогонов из 30

1

поменять порядок двух записей в токене отмены: сначала причина, потом пометка «отменено»

4

2

закрепить токен отмены в памяти, чтобы сборщик мусора его не убрал

1

3

обойти проблему прямо в генерируемом коде, для отмены без причины

25 (но причина отмены возвращалась неверно: поведение сломано)

4

четыре способа по-другому хранить причину отмены (строку) в памяти

7, 25, 5, 5

5

отключать сборщик мусора на время отмены

19 (+4 теста упали по таймауту в полном наборе)

6

регистрировать у сборщика мусора стек каждого файбера

21 (+726 тестов по таймауту)

7

сообщать сборщику мусора о стеке работающего файбера

13

Порог приёмки жёсткий: стресс-тест обязан пройти 30 из 30, а все семь попыток его не прошли и были откатаны. Восьмой попытки не понадобилось, помогла диагностика. На Windows внутри процесса поставили перехватчик, который при падении по памяти печатает стек вызовов, и причина стала видна с первого запуска: очередь на стеке уже освобождена (use-after-free). Исправление (коммит bf1b45a) заняло около 30 минут после 10 часов безуспешных попыток, и стресс-тест прошёл 30 из 30. Это единственный известный мне случай, где порог приёмки сыграл, и он из рантайма, не из компилятора, так что вывод опирается на один случай.

Семь попыток исправить гонку: успешные прогоны стресс-теста из 30, порог 30 из 30

Семь попыток исправить гонку: успешные прогоны стресс-теста из 30, порог 30 из 30

Так можно ли?

До альфы и самосборки — да. За пять месяцев (цена и время указаны выше) получились альфа языка со стандартной библиотекой, на которой собираются небольшие программы (пакеты есть отдельно и в приёмку выпуска не входили; у Oracle известны тихие ошибки генерации), и второй компилятор на самой Nova, прошедший самосборку. До стабильного компилятора — пока не знаю: первая попытка упёрлась в архитектуру, вторая, Carina, дошла до альфы и самосборки, но путь к стабильной версии у неё ещё впереди.

Ошибались обе стороны. Агент подменял задание или выдавал «почти работает» за готовое. Я принимал зелёные отчёты за прогресс. Из этих эпизодов я вывел семь приёмов:

  1. Мерить удаление запасного вывода, а не отчёты о прогрессе (месяц сверки: по отчётам прогресс шёл, а запасной вывод вырос с ≈4,9 до ≈5,2 тысячи строк).

  2. Считать расстояние до цели, а не число закрытых дефектов (блокеры: 67 → 36 → 44 → 62, находили быстрее, чем закрывали).

  3. Сверять реализацию со спецификацией, даже когда тест зелёный (интерполяция «интерполяция в строке ${expr}»: генератор молча делал целое из всего, что не строка).

  4. Проверять, что на CI тесты вообще исполняются («CI зелёный, кроме известного красного»: модульные тесты не запускались минимум с 16 сентября из-за красного шага перед ними).

  5. Проверять саму проверку (самосборка: испорченный байт должен давать «не совпадает» с номером байта).

  6. Ставить жёсткий порог приёмки, который не пускает «почти работает» (семь попыток исправить гонку в рантайме, каждая «почти работает»; порог опирается на один случай из рантайма).

  7. При красном пороге сначала диагностика, новые попытки потом (после семи попыток причину показала диагностика: около 30 минут против около 10 часов).

Часть этого случилась бы и с людьми. С агентами, по-моему, добавляется своё: кода становится больше, чем я успеваю прочитать, и отчёты зеленеют даже без прогресса. Поломки мои проверки ловили, а подмену задания замечал только человек или другой агент. Поэтому к вопросу «прошли ли тесты?» я добавил два: что именно они проверили и какое изменение заставит их упасть. Ответ на них должен быть повторяемой проверкой: счётчик удалённого кода (он считает обе функции и число вызовов) и намеренная порча файла, которую сравнение обязано поймать.

Если вы ведёте с агентами большой проект: случалось ли, что задание агента выполнено для галочки, тесты зелёные, а нужного результата нет? Какую проверку вы добавили после этого?

## Как попробовать Nova

Здесь: как запустить Nova самому, на что обратить внимание, короткий тур по языку и ссылки.

Оба компилятора переводят Nova в C, а бинарник собирает C-компилятор. Carina пока только проверяет и генерирует C, поэтому пробовать язык стоит на Oracle: архивы для Windows и Linux x86_64, CLI nova с check, build, test, линтером и языковым сервером.

Собрать первую программу

Есть стандартная библиотека и отдельные пакеты в организации nv-lang (HTTP, TLS, Polaris, сжатие, DuckDB, PostgreSQL и другие). В архивы Oracle пакеты не входят и приёмку выпуска не проходили. Полный демонстрационный агрегатор работает на Polaris, веб-фреймворке для Nova.

Скачайте nova-oracle-0.1.0-windows-x86_64.zip со страницы Oracle и сверьте с SHA256SUMS того же выпуска. Нужны MSVC Build Tools (SDK и линкер; они требуются и для clang). Если установлен clang (winget install LLVM.LLVM), nova выберет его первым. Распакуйте архив и в каталоге с nova.exe откройте PowerShell:

. .\setup-env.ps1
nova --version

Точка в начале команды (dot-sourcing) оставляет переменные окружения в текущей сессии. В отдельной папке создайте nova.toml ([package], name = "hello", version = "0.1.0") и hello.nv; имя модуля должно совпадать с именем пакета:

Код Nova: module hello; fn main() { println("Hello, Nova!") }

Код Nova: module hello; fn main() { println("Hello, Nova!") }

Текст для копирования
module hello

fn main() {
    println("Hello, Nova!")
}

В той же сессии, из папки проекта, выполните nova build hello.nv и .\hello.exe. Ожидаемый вывод: Hello, Nova!.

Эти шаги на Windows автоматически проверены в CI на собранном zip (прогон 38131440529: распаковка, nova build hello.nv, запуск hello.exe печатает «Hello, Nova!»). Это раннер GitHub, а не чистая пользовательская машина. Все примеры кода из статьи я проверил на этом же архиве. Для Linux есть архив nova-oracle-0.1.0-linux-x86_64.tar.gz, установка описана в README-INSTALL.md.

Как я пишу на Nova с агентом: открываю клонированный репозиторий и говорю «прочитай проект», после этого агент уже умеет писать на Nova. Это на моих агентах, и качество у разных агентов разное, но даже бесплатный агент OpenCode с моделью Big Pickle пишет на Nova: ядро BigInt в пакете nova-bignum (целые произвольной точности: знак и массив 32-битных слов, сложение, вычитание, сравнение; 302 строки модуля и около 200 строк тестов) он написал в первом коммите пакета 29 июля, и тесты прошли. Из семи бесплатных моделей, которые я пробовал на задачах Nova, пригодными оказались две; результаты отбора записаны в runbook.

Что стоит знать до первого эксперимента

  • Оба выпуска альфа: язык и API могут меняться. Бинарники Carina есть только для Windows x86_64.

  • Oracle может тихо сгенерировать неверный код (см. про запасной вывод): проверяйте поведение тестами.

  • Известен отказ: строковый литерал, переданный в []u8-параметр перегруженной функции, не собирается (запись №1594 реестра); обход: явный .bytes(). Отдельная запись №1850: модуль std/src/os не собирается под MSVC (ошибка C2099).

  • Гонки данных проверяются не полностью (см. «Ресурсы, гонки данных, дженерики» ниже).

  • Контракты requires/ensures проверяются во время выполнения. Статическое доказательство включается флагом --verify, но в готовых архивах оно справляется только с простыми случаями (очевидные тавтологии, вычисление констант). Полноценный решатель Z3 в Oracle есть, но подключается при сборке из исходников (cargo build --release --features z3-backend) и в архивы не входит.

  • Контракты проверяются и в релизной сборке: на опубликованном архиве программа с нарушенным requires в режиме release тоже завершается с ошибкой (код 101). По спецификации (D421) вырезаются только контракты с пометкой #debug.

В продакшен это нести рано, а для небольшого эксперимента, с проверкой поведения тестами, уже хватает.

Немного о языке

Здесь только то, что нужно для примеров статьи; синтаксис целиком описан в спецификации на сайте.

Конкурентность: запросы с дедлайном

Конкурентность структурная: параллельные задания запускаются внутри блока, и блок не завершается, пока все они не закончили работу или не были отменены, так что в фоне после выхода из блока ничего не остаётся. Конструкции async/await отсутствуют, т.к. это красит всё дерево вызовов сразу в Async. Вместо этого Nova построена на модели M:N: как в Go, есть файберы (лёгкие потоки, как горутины), M штук, а планировщик раскладывает их по N системным потокам и при необходимости переносит работу между потоками. Ввод-вывод у каждого потока идёт через свой цикл событий libuv. Функции при этом не делятся на синхронные и асинхронные.

Четыре строки из examples/mini_aggregator.nv, строки 49–52:

Код Nova: fn fan_out(latencies []int, deadline Monotonic) Time -> []str => parallel for ms in latencies { probe(ms, deadline) }

Код Nova: fn fan_out(latencies []int, deadline Monotonic) Time -> []str => parallel for ms in latencies { probe(ms, deadline) }

Текст для копирования
fn fan_out(latencies []int, deadline Monotonic) Time -> []str =>
    parallel for ms in latencies {
        probe(ms, deadline)
    }

parallel for запускает итерации одновременно в отдельных файберах и собирает результаты, здесь в []str; отмена по дедлайну встроена в сам язык. Типы пишутся после имён, как в Go; ->: тип результата, =>: тело одним выражением; другая форма тела: блок { … }, и значение блока равно его последнему выражению (как a + b + c ниже). Time между ) и -> — эффект (часы), как Net Time Emit в сигнатуре aggregate в начале статьи; Monotonic в скобках — обычный тип: монотонные часы, deadline — момент отмены. Future и await не нужны: вместо async используется модель M:N.

Опоздавшие запросы отменяются по дедлайну (структурная конкурентность, как в Kotlin и Trio). Сама отмена в probe, из того же файла, строки 30–37 (выше в функции объявлены timed_out, атомарный флаг, и канал tx/rx):

Код Nova: обработчик with Fail[TimeoutError] и supervised(deadline: deadline) со вложенным spawn

Код Nova: обработчик with Fail[TimeoutError] и supervised(deadline: deadline) со вложенным spawn

Текст для копирования
with Fail[TimeoutError] = |_e| { timed_out.store(true) } { // ловим ошибку через эффект
    supervised(deadline: deadline) { // включаем супервизор с дедлайном (это опционально),
                                     // супервизор ждёт, пока все файберы внутри не завершатся
                                     // либо, как в данном случае, не отменятся по дедлайну
        spawn { // запускаем файбер
            latency_ms.to_millis().sleep() // в данном случае просто засыпаем на определенное количество мс
            ro _ = tx.try_send(true) // результат записываем в канал
        }
    }
}

supervised(deadline: …) ограничивает блок по времени: когда дедлайн истёк, дочерние задания отменяются, а with Fail[TimeoutError] = |_e| { … } { … } ловит это как ошибку и взводит флаг (with X = |e| { … }: краткая форма обработчика для Fail: в |_e| приходит ошибка, в фигурных скобках реакция, а следом блок, на который обработчик действует). ro объявляет неизменяемую переменную; ro _ = … значит, что результат вызова сознательно не используется.

Веб-интерфейс демонстрационного агрегатора: кадр записи, блок параллельного опроса

Веб-интерфейс демонстрационного агрегатора: кадр записи, блок параллельного опроса

Это пример конкурентной работы с HTTP-запросами. Что видно: зелёная полоса — запрос успел, красная — ошибка, серая — отменён по дедлайну. Москва, Петербург, Новосибирск и Лондон успели, Нью-Йорк — ошибка, Токио отменён по дедлайну 2 с. Демонстрация детерминированная (mock_net, не живая сеть). Сетка маленьких квадратов под полосами — остальные запросы той же пачки сверх шести городов, по квадрату на запрос; в кадр она попадает обрезанной. Посмотреть в движении (гифка, 4,75 МБ).

Запись 2 августа 2026, сейчас другая версия. Сигнатура в шапке отличается от репозитория на этой ревизии (aggregate в начале статьи); сценарий погодный, не mini_aggregator.nv выше.

Поменять реализацию, не меняя функцию

type Counter effect { … } объявляет операции эффекта (effect здесь — вид типа, как consume ниже). effect Counter { … } внутри with задаёт реализацию (обработчик, handler). После with идут два блока в фигурных скобках: реализация и область, где она действует. Пример из examples/tour/effects_tour.nv (без модуля и комментариев) должен напечатать total = 6. ro объявляет неизменяемую переменную, mut — изменяемую:

Три части: объявление эффекта Counter, реализация-обработчик, область with; результат total = 6

Три части: объявление эффекта Counter, реализация-обработчик, область with; результат total = 6

Текст для копирования
type Counter effect {
    fn next() -> int
}

fn count_three() Counter -> int {
    ro a = Counter.next()
    ro b = Counter.next()
    ro c = Counter.next()
    a + b + c
}

fn main() {
    mut state = 0
    with Counter = effect Counter {
        next() -> int {
            state += 1
            return state
        }
    } {
        ro total = count_three()
        println("total = ${total}")
    }
}

Обработчик выдаёт 1, 2 и 3, а count_three знает только про Counter.next(). В тесте агрегатора сеть подменяется одной строкой (README, строка 174): with Net = mock_net(), Emit = null_emit() {.

Ресурсы, гонки данных, дженерики

consume задаёт тип, значение которого компилятор отслеживает как ресурс (близко к владению в Rust). Такое значение потребляется ровно один раз. Если у типа есть @cleanup, то значение, которое не потребили явно, потребляется автоматически при выходе из области видимости. Без @cleanup забытое значение даёт ошибку компиляции.

Пример по мотивам examples/tour/consume_tour.nv: Resource — тип-ресурс, make создаёт его значение, а @cleanup — метод типа. Метод объявляется как fn Тип @имя(…): @ перед именем означает неявного получателя (то, что в других языках self), поля получателя доступны как @поле, а слово после типа (consume, у изменяющих методов mut) говорит, как метод обращается со значением. consume r = привязывает значение, которое надо потребить. Значение r никто не потребляет явно, поэтому при выходе из внутреннего блока вызывается @cleanup:

Код Nova: ресурс Resource с методом @cleanup, функция make и блок, в котором значение не потребляется явно

Код Nova: ресурс Resource с методом @cleanup, функция make и блок, в котором значение не потребляется явно

Текст для копирования
type Resource consume { id int }

fn Resource consume @cleanup(_outcome ScopeOutcome) {
    println("cleanup: ресурс закрыт автоматически")
}

fn make(id int) -> Resource => { id }

fn main() {
    {
        consume r = make(1)
        println("работаем с ресурсом")
    }
    println("вышли из области")
}

Программа печатает (проверено на опубликованном архиве Oracle 0.1.0 для Windows; компилятор при этом предупреждает о вызове println внутри @cleanup без пометки #cancel_safe, для примера это не мешает):

работаем с ресурсом
cleanup: ресурс закрыт автоматически
вышли из области

Гонки данных: Nova проверяет небезопасное разделение изменяемых данных между файберами, но не гарантирует отсутствия всех гонок: косвенные вызовы замыканий остаются непроверенными. В обеих реализациях есть тихие ошибки.

Дженерики — основа языка и стандартной библиотеки: на них построены типы Vec[T], Option[T] и Result[T, E] из std Nova. Запись с параметрами типа в квадратных скобках обычная, новизны здесь нет, показываю только её (из examples/tour/types.nv, строки 19–21):

Код Nova: fn first[T](xs []T) -> Option[T] с проверкой длины

Код Nova: fn first[T](xs []T) -> Option[T] с проверкой длины

Текст для копирования
fn first[T](xs []T) -> Option[T] {
    if xs.len() == 0 { None } else { Some(xs[0]) }
}

Сумм-тип: значение одного из нескольких вариантов, и у каждого варианта могут быть свои данные. Option и Result в Nova именно такие: Some/None, Ok/Err. Пример по мотивам examples/tour/types.nv (строки 8–16; main мой): Shape — фигура, круг или прямоугольник, match разбирает варианты. Если пропустить вариант, компилятор откажет (E_MATCH_NON_EXHAUSTIVE):

Код Nova: сумм-тип Shape с вариантами Circle и Rectangle и match по нему

Код Nova: сумм-тип Shape с вариантами Circle и Rectangle и match по нему

Текст для копирования
type Shape enum
    | Circle(f64)
    | Rectangle(f64, f64)

fn area(s Shape) -> f64 => match s {
    Circle(r)       => 3.14 * r * r
    Rectangle(w, h) => w * h
}

fn main() {
    ro c = Circle(2.0)
    ro r = Rectangle(3.0, 4.0)
    println("area(circle)=${area(c)} area(rect)=${area(r)}")
}

Печатает (проверено на опубликованном архиве Oracle 0.1.0 для Windows): area(circle)=12.56 area(rect)=12.

Пример программы на Nova: claude-limits

Небольшая, но настоящая программа на Nova: claude-limits, страница с лимитами аккаунтов Claude Code, Kimi Code и Codex и прогнозом расхода к сбросу окна (периода до обнуления лимита). Использует недокументированные API; сборка для Windows отдельная.

Три карточки claude-limits над выдуманными аккаунтами: calm, pace, locked

Три карточки claude-limits над выдуманными аккаунтами: calm, pace, locked

На картинке только лимиты Claude. Интерфейс английский («at reset» — «к сбросу»); имена и числа выдуманы. Три карточки: calm (спокойный), pace (прогноз «130% к сбросу», лимит закончится через 1 ч 10 мин), locked (лимит исчерпан). Штриховка — прогноз расхода до сброса, тонкая линия под полосой — доля прошедшего окна, окно — период до обнуления лимита.

Куда идти дальше

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.