Runtime — режимы запуска, panic, prelude, статическое состояние
Решения этой группы определяют, как программа Nova исполняется: поддерживаемые режимы компиляции, что считается panic’ом и как он обрабатывается, что предоставляет prelude и почему в языке нет static-состояния.
| # | Решение |
|---|---|
| D7 | Один язык — три режима компиляции |
| D13 | Panic vs эффекты: что НЕ является эффектом |
| D26 | Базовая stdlib и prelude |
| D41 | Static-функции есть, static-состояния нет |
| D70 | ⚠️ REPLACED → D73 (migration map only) |
| D73 | From / Into protocol-пара с авто-выводом |
| D74 | Математические операции на числовых типах — instance-методы |
| D77 | TryFrom / TryInto — расширение D73 для fallible-конверсий |
| D76 | Mem эффект — runtime introspection для leak/growth тестов |
| D81 | assert(cond) vs debug_assert(cond) — build-mode семантика |
| D141 | Примитивы доступа к памяти — byte_at / bulk slice-операции |
| D177 | str Nova-body dispatch — Plan 54 Ф.2 extension |
| D178 | str API cleanup и расширения — Plan 91 Ф.2.6 |
| D178 amend V2 | str @try_parse_int + ParseIntError sum type — Plan 91 Ф.2 |
| D179 | StringBuilder — pure Nova consume type — Plan 91 Ф.2.6 |
D7. Один язык — три режима компиляции
Что
Один и тот же исходник Nova поддерживает три режима исполнения: AOT (бинарь, как Go), JIT (как .NET) и интерпретатор (как Python). Скрипт за 1 строку и сервер на 100k строк — это разные режимы запуска одного языка, а не разные языки.
Правило
nova run script.nv # интерпретатор / JIT (быстрый старт)
nova build app.nv # AOT-бинарь, как `go build`
nova jit-server # долгоиграющий процесс с JIT-компиляцией
Один и тот же script.nv без модификации работает во всех трёх
режимах. Эффекты, типы, контракты, handler’ы — везде ведут себя
одинаково.
Почему
- Скрипт vs сервер — это режимы запуска. Не разные языки. Программисту не нужно «переписывать» под другой режим.
- Прецедент Julia — тот же подход (JIT по умолчанию + AOT через
PackageCompiler.jl) работает на масштабе data-science. - AI-first — LLM может генерировать код и запускать через интерпретатор для быстрой проверки, а тот же код собирать в бинарь для production.
- Эффекты ортогональны runtime’у — handler’ы перехватываются и в JIT, и в AOT, и в интерпретаторе одинаково.
Что отвергнуто
- Только AOT (Rust/Go-стиль) — медленный feedback loop, плохо для скриптов и REPL.
- Только интерпретатор (Python) — производительность недостаточна для backend.
- Транспиляция в чужой язык (TypeScript → JS) — теряется возможность контроля runtime, привязка к чужой экосистеме.
Связь
- 01-philosophy.md → D9 — «три режима компиляции в строго типизированном языке» — одна из двух потенциальных уникальных заявок Nova.
- 01-philosophy.md → D10 — три режима следуют из «всё — эффект»: handler’ы абстрагируют runtime.
Открытые вопросы
- Конкретные технологии: LLVM для AOT? Cranelift для JIT? Tree-walking для интерпретатора? — выбор реализации.
- Совместимость артефактов между режимами — пока считаем, что один исходник, разные бинарные форматы.
D13. Panic vs эффекты: что НЕ является эффектом
AMEND (Plan 140.3, 2026-06-13) — assert тегает
error_kind = NOVA_THROW_PANIC.assert/debug_assertfailure = panic (D13), но рантаймnova_assert_locтегал fail-frame толькоerror_msg, оставляяerror_kind = NOVA_THROW_USER→ пойманныйconsume/supervised assert классифицировался как recoverable Failure, не Panic. Теперьnova_assert_loc(какnv_panic, D188) ставитerror_kind = NOVA_THROW_PANIC→ классификация совпадает с panic и с контрактами (D24 amend).[M-140-contract-panic-unwind].
AMEND (Plan 140.1, 2026-06-12) — assert/debug_assert: location-first формат +
file:line.assert/debug_assertfailure = panic (D13); Plan 140.1 уточняет диагностический текст этого panic’а. Прежнийassertion failed: <expr>(nova_assertвeffects.h) — без локации (не найти, где упало) и со словом «assertion» — RETRACT. Новый формат — общий с контрактами (D24, location-first):<file>:<line>: assert failed: <expr>Пример:
foo.nv:10: assert failed: a > 0. С пользовательским сообщением (assert(a > 0, "must be positive"), прецедент D84) — тот же принцип, выражение в скобках:<file>:<line>: assert failed: <msg> (<expr>)Пример:
foo.nv:10: assert failed: must be positive (a > 0).Правила: (1)
<file>:<line>:всегда первый, авто-проставляется codegen’ом из call-сайта (__FILE__/__LINE__) — пользователь его не включает в свой текст; (2)assert failed:сохраняется (словоassertion→assert); (3)debug_assertиспользует тот же текстassert failed— отличие только в debug-only-гейте (D81), не в сообщении; (4)<msg>опционален — без него формат без скобок. Реализация прокидываетfile/lineвnova_assert(Plan 140.1 Ф.2). Этим assert и контракты дают единый кликабельный location-first диагностический формат. См. также D81 (assert/debug_assert build-mode) и D24 → «Формат runtime-violation».
Что
Не каждое прерывание вычисления — эффект. Аппаратные/математические
сбои (деление на ноль, выход за границы массива, переполнение, OOM,
переполнение стека) не указываются в сигнатуре функции. Они
образуют общую категорию Panic — runtime-сбоев, перехватываемых
runtime’ом на границе fiber’а, не программистом в коде.
Правило
Граница
| Видимое (в сигнатуре) | Универсальное (не в сигнатуре) | |
|---|---|---|
| Что | эффекты, описывающие намерение | сбои, описывающие невозможность вычисления |
| Примеры | Net, Db, Time, Log, Fail[BusinessError] | деление на ноль, переполнение, выход за границы, OOM, переполнение стека |
| Где ловится | handler’ом в коде | runtime’ом на границе fiber’а |
| Как создаётся | throw | panic(msg) или сам runtime |
Перехват — на границе fiber’а runtime’ом
panic означает смерть текущего fiber’а, не процесса. Что это
значит для процесса в целом — зависит от runtime-окружения
(06-concurrency.md → D14):
- HTTP-handler — fiber на запрос. Panic = смерть fiber’а, runtime возвращает 500, остальные запросы продолжают.
- Worker очереди — fiber. Panic = задача упала, scheduler берёт следующую.
- Supervised group — supervisor видит «fiber завершился panic’ом», рестартует по своей стратегии.
- Синхронная программа без fiber-runtime (CLI-скрипт): fiber один
и совпадает с процессом, panic эффективно гасит процесс — но это
следствие топологии, не семантика panic’а. Если нужно гарантированно
убить процесс независимо от окружения — отдельная функция
exit.
fn handle_request(r Request) Db Log -> Response =>
process(r) // если panic — fiber умирает, runtime вернёт 500
// если throw — handler выше ловит обычно
fn server() Net Fail -> () {
supervised {
spawn handle_requests()
spawn periodic_cleanup()
}
// ⚠ ОБНОВЛЕНО (Plan 173 §3b, 2026-06-26): постфикс `strategy = …,
// max_restarts = …` РЕТРАКНУТ. Supervision = эффект-хендлеры (Plan 173.2):
// with Supervisor = effect Supervisor {
// on_child_fail(idx, err, attempt) => if attempt < 3 { Restart } else { Escalate }
// } { supervised { … } }
// Дефолт (нет супервизора) — Escalate (all-or-throw). MVP-исполнение =
// Escalate/Stop; Restart(single) — за гейтом изоляции (173.3). Primary при
// нескольких падениях — PANIC>USER>CANCEL (D414 §1, 06-concurrency.md).
}
Никакого try_panic/catch в коде. Программист не ловит
panic в обычной функции — это работа runtime’а на границе fiber’а.
Если программист хочет управляемую ошибку — пишет throw +
Fail[E], ловит обычным handler’ом.
Три уровня катастрофы
| Уровень | Конструкция | Что убивает | Перехват |
|---|---|---|---|
| Управляемая ошибка | throw err + Fail[E] | ничего, передаётся handler’у | handler’ом в коде (04-effects.md → D25) |
| Сбой fiber’а | panic(msg) | текущий fiber | runtime’ом на границе fiber’а; supervisor может рестартовать |
| Смерть процесса | exit(code, msg) | весь процесс | не перехватывается — процесс гасится с указанным exit code |
Никаких try_panic { ... } catch p { ... } или
panic_boundary { ... } recover (p) => { ... } в языке. exit
тем более не перехватывается — это финальная точка.
Когда какой использовать
throw err— контролируемая ошибка с информацией о причине. Всё, что вызывающий может осмысленно обработать. Дефолт.panic(msg)— поломан локальный инвариант, текущему вычислению дальше не жить, но процесс/сервер продолжают. Пример: «не должно случиться» в коде, который часть большого приложения.exit(code, msg)— поломан глобальный инвариант стартапа или операционной среды, продолжать процесс бессмысленно. Пример: битый конфиг при загрузке, нет доступа к критическим ресурсам, CLI завершает работу с конкретным exit code для скриптов.
// throw — обычная управляемая ошибка
fn parse(s str) Fail[ParseError] -> int =>
if !valid(s) { throw ParseError.BadFormat } else { ... }
// panic — поломан локальный инвариант
fn pop_nonempty(mut stack []int) -> int {
if stack.is_empty() { panic("pop_nonempty called on empty stack") }
stack.pop()
}
// exit — нечего продолжать
fn main() Io -> () {
ro cfg = load_config("/etc/app.toml")
?? exit(1, "config not found at /etc/app.toml")
run(cfg)
}
exit — детали
- Сигнатура:
fn exit(code int, msg str) -> never.code— exit code для процесса (по конвенции 0 = успех, ≥1 = ошибка).msgвыводится в stderr перед завершением; пустая строка — без сообщения. - Не вызывает defer’ы / handler’ы. Процесс гасится, стек не
разворачивается. Если нужен cleanup — программист пишет его до
exit. - В тестах runtime тестов перехватывает
exitи превращает в fail теста (иначе один тест убил бы всю прогонку). Это деталь test-runner’а, не часть языкового контракта. - Прецеденты: C
exit(code), Goos.Exit(code), Ruststd::process::exit(code), Pythonsys.exit(code)— везде отдельная функция от panic-аналога, везде не вызывает destructor’ы / defer’ы.
Опция: строгий режим #strict_total
Для критичного кода (медицина, финансы, авионика):
#strict_total
fn critical(...) -> Result =>
// деление на ноль здесь — compile error
// обязаны checked-операции: safe_div(a, b)?, arr.get(i)?
Превращает функцию в тотальную (всегда завершается). Цена — больше кода, но для 1% случаев это окупается.
Почему
Если бы Fail[DivByZero] был обязателен, он бы появился в каждой
второй сигнатуре (любая функция со средним арифметическим,
дисперсией, делением). К нему присоединились бы Fail[IntegerOverflow],
Fail[ArrayBounds]. Это синдром Java checked exceptions —
информативность сигнатуры исчезает, потому что эффекты везде.
Сознательный компромисс: строгая теория эффектов уступает читабельности в зоне аппаратных сбоев.
Что НЕ Panic, а обычный эффект
- Бизнес-ошибки парсинга, валидации, аутентификации →
Fail[E]. - Network failure, DB connection refused →
Fail[NetError],Fail[DbError]внутри эффектаNet/Db. - Любая ошибка, которую программа намерена обрабатывать, — это не Panic.
Принцип: «обработать никак нельзя, надо умереть» → Panic; «обработать можно и нужно» → Fail.
Что отвергнуто
Fail[DivByZero]для каждой функции — спам в сигнатурах.try_panic/catchв обычном коде — путает сFail, усложняет reasoning о потоке управления.- Panic как обычное Throwable (Java RuntimeException) — приводит
к ловле «всего» через
catch (Exception e), антипаттерн.
Связь
- 04-effects.md → D25 —
throwиFail[E]. - 06-concurrency.md → D14 — supervisor, fiber’ы.
- 01-philosophy.md → D10 — «всё — эффект» с оговоркой про runtime panics.
D26. Базовая stdlib и prelude
MAJOR AMEND (Plan 152.1 / D249-D250, 2026-06-13): разворот «школы B» на координатную модель линз + инвариант R-UTF8. Прежняя D26-формулировка («все public-операции
strcodepoint-indexed, O(n)») РАЗВЁРНУТА:
- Координаты
str— байтовые.s[i](int) запрещён (E_STR_NO_INT_INDEX); единственныйstr[..]— byte-range slices[a..b]. find/rfind/split — byte-offset.- Длина — через представление. Бэар
s.len()→E_STR_NO_LEN;byte_len()(O(1)) наstr; codepoint-длина —as_chars().count()(O(n)).- Линзы:
as_bytes() -> ro []u8(reinterpretation, O(1));as_chars() -> CharsIter(decoding lens, O(n) поток). Элементный доступ — через них, не плоскимиchar_at/byte_at(ретайрнуты).- Инвариант R-UTF8 (NEW): значение
strвсегда валидный UTF-8. Конструкторы валидируют (from_byteschecked → Result;from_bytes_lossy→ U+FFFD) либо несут явный контракт вызывающего (from_bytes_unchecked*). Делаетas_chars()-decode тотальным; отличает Nova от Go (гдеstringбывает невалиден). Лучше Go.- Список «базовых str-методов»:
byte_len/as_bytes/as_chars/iter/to_bytes/to_chars/slice[a..b]/get(Range)/find/rfind/split/trim/case/replace/pad/concat/ parse + identity (eq/hash/clone/compare). НЕlen/char_len/char_at/byte_at/get(int).См. D249/D250, Q-string-indexing/Q-string-len (open-questions.md).
Что
Базовые типы (Option[T], Result[T, E], Error, never,
Ordering) и их конструкторы (Some, None, Ok, Err) живут в
prelude — автоматически в скоупе любого модуля, без import.
Список prelude явно зафиксирован в одном месте, не «магия».
Bootstrap-расширение (Plan 35 sub-plan 35.A R27, 2026-05-12): большая часть prelude (
Option/Result/Some/None/Ok/Err/Error/never/println/panic) реализована hardcoded в type-checker’е и codegen’е. Параллельноcompiler-codegen::importsauto-импортируетstd/prelude.nvесли файл существует — это opt-in mechanism для расширения prelude из пользовательского кода (или для миграции hardcoded items в file-based form). Bootstrap MVP:std/prelude.nvсодержит placeholderPRELUDE_VERSION = 1.Plan 62 (закрыт 2026-05-18,
PRELUDE_VERSION = 3): большая часть prelude мигрирована в file-based декларацииstd/prelude/*.nv:
std/prelude/core.nv—Option/Result/Some/None/Ok/Err/Error/Ordering. Bottom-типnever— строчный встроенный примитив (Plan 76), в prelude не объявляется (какint/bool).std/prelude/runtime.nv—panic/exit/assert/debug_assert(printlnmigrated в Plan 62.B.bis —PRELUDE_VERSION = 7, 2026-05-18).std/prelude/errors.nv—RuntimeError(6 variants) +ReadBufferError(RuntimeNoneErrordeferred — bootstrap parser не поддерживает empty-body sum syntax).std/prelude/collections.nv—Iter[T]formal protocol declaration.std/prelude/protocols.nv—From/Into/Hash/Equal/Compare/Display(6 formal protocols;TryFrom/TryIntodeferred — Plan 56 Ф.2.7 effect-row enforcement).std/prelude/effects.nv—Fail[E]formal effect declaration.Plan 62.D bis-1 (закрыт 2026-05-18,
PRELUDE_VERSION = 4):Range/RangeIterre-export через prelude facade изstd.collections.range. Раньше эта строка триггерила 4 latent codegen bugs (закрыты в bis-1).Plan 62.F.bis (закрыт 2026-05-18,
PRELUDE_VERSION = 5):
- Edition versioning (D366):
[package].edition = "2026.05"вnova.toml→ resolver auto-импортируетstd/prelude/e2026_05.nvвместо rolling facade. Mirror Rust’sedition = "2021". См. D366.- Structured W_PRELUDE_SHADOW lint (D125): user-declaration shadowing prelude-imported имени → structured lint warning через
lints::lint_prelude_shadow. Suppress:module X allow_prelude_shadowclause. См. D125.Time/Memformal effect declarations добавлены вstd/prelude/effects.nv(codegen dispatch неизменен через pre-registeredeffect_schemas).Plan 62.D.bis (закрыт 2026-05-18,
PRELUDE_VERSION = 6): StringBuilder/WriteBuffer/ReadBuffer formally declared черезexternal type(D126) вstd/prelude/collections.nv. Закрывает последний known-by-name hole в D26 visible prelude. Methods остаются вstd/runtime/<name>.nvчерезexternal fn(D82) — связь по receiver-type name. См. D126.Plan 62.B.bis (закрыт 2026-05-18,
PRELUDE_VERSION = 7):printlnformally declared вstd/prelude/runtime.nvчерез D69 variadic +[]any(canonical D26 signaturefn print(...items []any) Io -> ()). Plan 67 hotfix (silent-wrong-output bug вinfer_print_helperдляprintln(str.from(int))паттерна) absorbed как Ф.0 — refactor через unifiedinfer_expr_c_typedispatch. Codegen special-case (emit_c.rs:11270) fires ДО variadic routing (Ф.1 reorder) — preserves per-arg type info, synthesized[]anyarray никогда не строится; per-argnova_print_<type>dispatch сохраняется черезinfer_print_helper→ unified inference. Builtins HashSet shrink:"print","println"removed (Ф.5). Cross-file resolve через R26+R27 находит declarations. См. Plan 62.B.bis.Plan 62.A.bis (закрыт 2026-05-20): введён layered schema registry для sum-types в codegen (
SumSchemaRegistry—compiler-codegen/src/codegen/sum_schema_registry.rs). Registry работает в трёх слоях с убывающим приоритетом:DeclaredFromPrelude > DeclaredFromUser > HardcodedBaseline. Hardcoded entries (Option/Result/Error/RuntimeError) остаются в качестве ABI-compat fallback для runtime-хелперов вnova_rt/array.h. File-based декларации вstd/prelude/core.nv(черезexternal fn Option[T] @method) получают приоритет и маршрутизируют вызовы черезMethodRoutingregistry (HardcodedRuntimeFn / ExternalFn / DeclaredBody). Unblocked: 7 из 8 методов Option (is_some, is_none, unwrap, unwrap_or, unwrap_or_else, map, ok_or) + 4 из 9 методов Result (is_ok, is_err, ok, err) — задекларированы вstd/prelude/core.nv. Deferred в core: 5 Result-методов возвращающихT(unwrap_or и др.) — blocker: type-checker выводит genericT, codegen возвращаетnova_int,==после вызова ломается (Plan 62.B+).Option.or— trampoline вnova_rt/array.hотсутствует (Plan 62.B+). Phase 4 (удаление legacysum_schemas) deferred до Plan 59 sum-mono.Remaining deferred:
RuntimeNoneError(bootstrap parser empty-sum syntax),TryFrom/TryInto(Plan 62.E.bis — требует Plan 56 Ф.2.7 effect-row enforcement). Bottom-типnever— закрыт Plan 76 (строчный встроенный примитив, не требует prelude-декларации).Plan 99 (закрыт 2026-05-23): последние 6 closure-applying Option/Result-методов перенесены на Nova-body в
std/prelude/core.nv:Option.map[U],Option.unwrap_or_else,Option.ok_or[E],Result.map[U],Result.map_err[F],Result.unwrap_or_else. 15 / 17 Option/Result методов на Nova-body (7 Option + 8 Result), C-routed остаются толькоOption.unwrapиResult.unwrap(Plan 61 lineage — typedFail[E]effect). Декомпозирован на 4 sub-plan’а: Plan 99.1 (foundation — method-level generic в DeclaredBody: extractresolve_method_level_substhelper, mono_name с method-level suffix,register_novaopt_decl(U)lazy-emit,infer_method_level_return_for_sumдляinfer_expr_c_type); Plan 99.2 (contextual variant constructors — bareNoneиспользуетcurrent_fn_return_ty;Ok(v)/Err(e)берут (T,E) из rt; bareSome(v)использует ARG-type черезinfer_expr_c_type(arg)чтобы sub-expr контексты —s.char_at(i) == Some('/')вOption[int]-fn — не строилиNovaOpt_<rt's_X>для arg иного типа); Plan 99.3 (atomic per-method migration — 6 commits с regression-gate); Plan 99.4 (comprehensive tests + spec + close). Closure invoke черезNovaClosBase+ explicit cast — паритет RustFnOnce-mono. Param-naming: closure-параметрыdefault_fn/map_fn/err_fn(неf) — избегаем shadowing user-функций (см.contracts/trivial_congruence_positiveрегрессию). Полный nova test: 1141 PASS / 0 FAIL / 56 SKIP.Plan 95.bis (закрыт 2026-05-23): расширение Plan 95 — ещё 5 «чистых» Option/Result-методов перенесены на Nova-body в
std/prelude/core.nv:Option.unwrap_or,Option.or,Result.unwrap_or,Result.ok,Result.err. Удалены все соответствующие C-трамплины изnova_rt/array.h(включаяNOVA_ARRAY_IMPL-macro entryNova_Option_method_or_<T>+ explicit_nova_strспециализация,Nova_Result_method_unwrap_or_<n>,Nova_Result_method_ok_<n>+ back-compat#define-алиасы) и lazy-emit вregister_novaopt_decl/register_novares_decl. Также удалён inline emitResult.err()в codegen (Plan 59 Ф.7.5 D3 — теперь Nova-body эмитит boxed payload сам через mono’dregister_novaopt_declpath). ResultDeclaredBody-dispatch доработан: mono-имя всегда суффиксированный (Nova_Result_method_<m>_<n>), даже для legacyNova_Result*obj_ty, чтобы избежать C-redefinition. Граница не изменилась:unwrap(Fail-handler, Plan 61),unwrap_or_else/map/map_err/ok_or(closure-applying + method-level generic + Plan 98 inference) — остаются C-routed.
Plan 95 (закрыт 2026-05-23): builtin sum-типы
Option/Resultучаствуют в method-monomorphization через канал «method-only mono» — без регистрации вgeneric_type_templates(представлениеNovaOpt_<T>/NovaRes_<ok>_<err>*не трогается). Pre-existingMethodRouting::DeclaredBody(scaffold-only до Plan 95) теперь реально конструируется вinit_prelude_decls_from_itemsдля non-external методов наOption/Result, потребляется в перехватах вызоваNovaOpt_(#6 в emit_c.rs:14160) иis_result_like(#7).receiver_c_typeспец-кейситOption/Result→ value-тип черезcurrent_type_subst+ сохранённыеbuiltin_sum_type_params. Mono-имя совпадает с формой бывшего C-трамплина (Nova_Option_method_<m>_<T_sani>/Nova_Result_method_<m>_<n>) → call-site mangling не меняется. Перенесены на Nova-body:Option.is_some/is_none,Result.is_ok/is_err(=> match @ { ... }вstd/prelude/core.nv); C-трамплины удалены изnova_rt/array.h, lazy-emit вregister_novaopt_decl/register_novares_decl, и baseline-entries вinit_hardcoded_baseline. Граница:unwrap(Fail-dispatch),unwrap_or/unwrap_or_else/map/ok_or/map_err(closure-applying) — остаются C-routed. Закрыт маркер[M-option-methods-not-mono-able]. Plan 93 (узкий вариант «is_some-Nova-body») superseded by Plan 95 — целиком поглощён Ф.4. Plan 78 (prelude-codegen single-source) — узкий санкционированный пересмотр Ф.1 только для чистых тег-предикатов; реестр C-routing в силе.
Правило
AMEND (2026-07-07, [M-unwrap-twins-retraction]): метод-близнецы
@unwrap()/@unwrap_or(v)/@unwrap_or_else(f)наOption/Resultретрактированы — дублировали операторы!!/??(D85, D86). Ниже (v1.0 catalog) — историческая запись, актуальный список методов не включает эти три; вызовы мигрированы наx!!/x ?? v.
Что в prelude (v1.0)
Типы:
type Option[T] | Some(T) | None
type Result[T, E] | Ok(T) | Err(E)
type Ordering enum Less | Equal | Greater
// `never` — bottom-тип (uninhabited): строчный встроенный примитив,
// НЕ объявляется (как `int`/`bool`). См. «`never` — bottom-тип» ниже.
type any protocol { } // top-type через пустой protocol (D53)
Базовые методы Option[T]:
fn Option[T] @is_some() -> bool
fn Option[T] @is_none() -> bool
// @unwrap() / @unwrap_or(default) / @unwrap_or_else(f) — РЕТРАКТИРОВАНЫ,
// см. AMEND выше. Канон — операторы `!!` / `??` (D85/D86).
fn Option[T] @map[U](f fn(T) -> U) -> Option[U]
fn Option[T] @ok_or[E](err E) -> Result[T, E] // None → Err(err)
fn Option[T] @or(other Option[T]) -> Option[T]
Базовые методы Result[T, E]:
fn Result[T, E] @is_ok() -> bool
fn Result[T, E] @is_err() -> bool
fn Result[T, E] @ok() -> Option[T] // Ok(v) → Some(v); Err → None
fn Result[T, E] @err() -> Option[E] // Err(e) → Some(e); Ok → None
// @unwrap() / @unwrap_or(default) / @unwrap_or_else(f) — РЕТРАКТИРОВАНЫ,
// см. AMEND выше. Канон — операторы `!!` / `??` (D85/D86).
fn Result[T, E] @map[U](f fn(T) -> U) -> Result[U, E]
fn Result[T, E] @map_err[F](f fn(E) -> F) -> Result[T, F]
?? — основной идиоматический путь безопасного доступа к значению
с fallback. Прецеденты — Rust Option::unwrap_or, Swift ??
оператор, TypeScript ??.
ro n int = s.parse_int_opt() ?? 0 // на ошибке — 0 (Plan 91.18: parse_int → int Fail)
ro cfg = config ?? default_config() // lazy default (RHS матч-arm, не вычисляется на Some)
// Идиома: цепочка через and_then / ??:
ro port int = env.get("PORT").and_then(|s| s.parse_int_opt()) ?? 8080
!! — assertion-style: throw’ает Fail если None/Err. Идиома
для случаев когда программист гарантирует что значение есть
(prove’ил выше через if let / match). Caller-side либо ловит
через with Fail = ..., либо позволяет распространиться (паника
на границе fiber’а — D13).
Bootstrap status (2026-05-08)
| Метод | Codegen | Тесты |
|---|---|---|
Option.is_some / is_none | ✅ | ✅ |
Option.unwrap (Fail на None) | ✅ inline | ✅ runtime/unwrap_or.nv |
Option.unwrap_or(default) | ✅ runtime helper | ✅ |
Option.unwrap_or_else(f) | ✅ inline (closure call) | ✅ runtime/result_methods.nv |
Option.map(f) | ✅ inline | ✅ |
Option.ok_or(e) | ✅ inline | ✅ |
Option.or(other) | ✅ per-T trampoline Nova_Option_method_or_<T> | ✅ plan62/option_or_from_prelude.nv |
Result.is_ok / is_err | ✅ | ✅ |
Result.ok() → Option[T] | ✅ runtime helper | ✅ |
Result.err() → Option[E] | ✅ inline (boxed nova_str) | ✅ |
Result.unwrap (Fail на Err) | ✅ inline | ✅ |
Result.unwrap_or(default) | ✅ runtime helper | ✅ |
Result.unwrap_or_else(f) | ✅ inline (closure call) | ✅ |
Result.map(f) | ✅ inline | ✅ |
Result.map_err(f) | ✅ inline | ✅ |
Error.new(msg) | ✅ runtime helper | ✅ runtime/error_runtime_error.nv |
Error.msg (field) | ✅ direct field access | ✅ |
RuntimeError.DivByZero | ✅ unit-variant constructor | ✅ |
RuntimeError.Overflow | ✅ unit-variant constructor | ✅ |
RuntimeError.IndexOutOfBounds {i, n} | ✅ record-variant constructor | ✅ |
RuntimeError.TypeMismatch(s) | ✅ tuple-variant constructor | ✅ |
RuntimeError.AssertFailed(s) | ✅ tuple-variant constructor | ✅ |
RuntimeError.NoHandler(s) | ✅ tuple-variant constructor | ✅ |
Plan 62.B (2026-05-20):
Option.orреализован — per-T trampolineNova_Option_method_or_<T>. Все 17 Option/Result методов из §283-306 теперь задекларированы вstd/prelude/core.nvчерезexternal fn(раньше 5 Result-методов —unwrap/unwrap_or/unwrap_or_else/map/map_err— оставались hardcoded-only из-за generic-стаб блокера в type inference, см. plan-doc 62 §«Status update 2026-05-20»). Починен pre-existing багResult.mapдляbool/char-typed closure (хардкодNOVA_CLOS_CALL_iiint-layout → calling-convention mismatch).
Bootstrap-ограничения:
✅ ЗАКРЫТО (Plan 59 Ф.7.5 increment 2, 2026-05-21):Result[T, E]зашит на(nova_int Ok, nova_str Err). Generic monomorphization для произвольных T/E — отдельная задача (Q-result-monomorphization).Result[T, E]полностью мономорфизирован — per-(T,E) C-типNovaRes_<ok>_<err>*(аналогNovaOpt_<T>), реальные типы в Ok/Err payload’е. Legacy единыйNova_Resultустранён.- Lambda-параметры с не-
intтипом (напримерfn(e str) -> str => ...дляmap_err) требуют явной аннотации через closure-full (fn(...)). Closure-light (|x|) полагается на context-inference; если method-sig недостаточен — переключайся на closure-full. Codegen в bootstrap не делает inference closure-параметра по сигнатуре method’а (Q-closure-param-inference). - Zero-arg closure для
unwrap_or_else—|| expr(closure-light) илиfn() -> T => expr(closure-full). Парсер различает||-closure-start от||-binary OR по позиции. Errorимеет полеmsg. По D26 spec’у должно бытьreadonly msg, но bootstrap не enforce’ит readonly — поле модифицируется как обычное (bootstrap-grade compromise).RuntimeErrorварианты создаются и matchаются user-кодом, но встроенные операции (a/bна 0,arr[i]out-of-bounds, unhandled effects) пока бросаютnova_strчерезNova_Fail_fail, не структурированныйNova_RuntimeError*. Конверсия throw-points в RuntimeError-payload — отдельная задача (требует расширения fail-frame mechanism сnova_strнаvoid*payload).
Прочие prelude-типы:
// Error — record для quick-and-dirty ошибок с сообщением (D65)
type Error {
ro msg str
}
fn Error.new(msg str) -> Error => { msg }
// RuntimeError — sum-тип встроенных runtime-сбоев (D65)
// Бросается встроенными операциями: a/b на 0, arr[i] на out-of-bounds, etc.
// StackOverflow и OutOfMemory не входят — они panic, не Fail (D13).
type RuntimeError
| DivByZero
| Overflow
| IndexOutOfBounds { index int, length int }
| TypeMismatch(str)
| AssertFailed(str)
| NoHandler(str)
// RuntimeNoneError — unit-тип, бросается через `expr!!` на Option (D85).
// Отдельный от RuntimeError — это категория «отсутствие значения», не
// аппаратный сбой.
type RuntimeNoneError
// Iterator protocol (D58)
type Iter[T] protocol {
mut next() -> Option[T]
}
// Range — литерал `a..b` / `a..=b` (D58)
type Range {
ro start int
ro end int
ro inclusive bool
}
type RangeIter {
end int
inclusive bool
mut cur int
}
// Built-in opaque accumulator/buffer типы (Plan 04, D82, D126).
// Formal declarations — std/prelude/collections.nv через `external type`
// (D126, Plan 62.D.bis, 2026-05-18). Methods — std/runtime/string_builder.nv,
// std/runtime/write_buffer.nv, std/runtime/read_buffer.nv через `external fn`
// (D82, Plan 13 Ф.8; раньше были в едином std/runtime/builtins.nv —
// REMOVED 2026-05-08). До 62.D.bis типы существовали как «known-by-name»
// (без formal Nova-side declaration) — теперь canonical source в prelude.
// `[]u8` — canonical byte-slice (Plan 69, byte→u8 migration).
external type StringBuilder // UTF-8 string accumulator, @into() -> str (infallible)
external type WriteBuffer // binary write buffer, @into() -> []u8
external type ReadBuffer // cursor-style binary reader, view над []u8
// Ошибка ReadBuffer — недостаточно байт для read-операции.
type ReadBufferError
| UnexpectedEnd { wanted int, available int }
Базовые числовые и строковые типы (int, i8-i64, u8-u64,
f32, f64, str, bool, char, ()) — встроены в язык,
не stdlib, но упомянуты для полноты.
Size-accessor методы для built-in []T и str (Plan 60 / D117):
fn []T @len() -> int // O(1), zero-cost lowering arr->len
fn []T @capacity() -> int // O(1), zero-cost lowering arr->cap
fn []T @is_empty() -> bool // O(1), len() == 0
fn str @len() -> int // O(1) — байты (Plan 108 D26 rev)
fn str @char_len() -> int // O(n) — codepoints (UTF-8 walk)
fn str @byte_len() -> int // O(1) — deprecated alias для @len()
fn str @is_empty() -> bool // O(1) — len() == 0
Field-access form (arr.len, s.byte_len, etc.) запрещён в
user-language — D117 enforce’ит method-only. Internal C-поля
arr->len / arr->cap сохраняются как implementation detail.
Built-in opaque-типы для аккумуляции (StringBuilder,
WriteBuffer, ReadBuffer) — расширяют примитивы D26. Type
declarations — в std/prelude/collections.nv через external type
(D126,
Plan 62.D.bis, 2026-05-18). Methods — в std/runtime/string_builder.nv,
std/runtime/write_buffer.nv, std/runtime/read_buffer.nv (auto-generated
через Plan 13 Ф.8) — external fn декларации (D82).
Программист не пишет type StringBuilder { ... } body — external type — это opaque marker, реализация в runtime (nova_rt/).
| Тип | Глагол | Финализация | Use-case |
|---|---|---|---|
StringBuilder | @append | @into() -> str infallible | string concat в hot loop |
WriteBuffer | @write_* | @into() -> []u8 | binary serialize |
ReadBuffer | @read_* / @try_read_* | view, no into | binary parse |
Эти три типа заменяют старый унифицированный Buffer (Q-buffer
закрыт REPLACED 2026-05-08). Причина split: text+binary mixed
ломает @into() -> str infallible-семантику. См. Plan 04.
@clone() — shallow по умолчанию (Plan 17 Ф.1)
Конвенция в Nova:
@clone() -> Self— shallow copy. Возвращает новый экземпляр с тем же набором полей; managed-references (другие record’ы, массивы, вложенные коллекции) после clone разделяются между оригиналом и копией. Для глубокой копии —@deep_clone()(не в prelude, определяется по необходимости вручную).
Что значит «shallow» для разных категорий:
- Примитивы (
int,f64,bool,char,u8) — value semantics, clone = тривиальная копия. str— immutable,s.clone()возвращает тот же ptr (равноценно присваиванию). Семантически независимая копия не нужна.- Record — копируются поля; managed-поля (вложенные record’ы, массивы) — по ссылке.
[]T— копируется внутренний(ptr, len, cap)-storage в свежий buffer (O(n) поверхностно), но элементыT— managed-references share’аются еслиTсам не примитив.- HashMap / Vec / Set / Queue (stdlib) — копируется внутренний storage, элементы и ключи — по ссылке.
StringBuilder,WriteBuffer—@clone()тут deep для внутреннего byte-buffer’а, потому что сам тип определён как mutable accumulator с уникальным storage’ом — shared buffer между clone’ами = data race по семантике D26. Это исключение из общего shallow-правила, обоснованное mutability-семантикой типа.
Когда писать @deep_clone() — когда нужно гарантировать, что
после clone никакая мутация одной копии не видна другой. Stdlib не
вводит общий @deep_clone()-protocol; программист реализует на
конкретном типе:
fn HashMap[K, V] @deep_clone() -> HashMap[str, []int] {
mut out = HashMap[str, []int].new()
for (k, v) in @ {
out.insert(k, v.clone()) // элементы клонируются shallow
}
out
}
Прецедент: Rust Clone shallow по умолчанию, deep — руками. Java
Object.clone() shallow, override для deep. Go — value semantics на
структурах + reference semantics на slice/map (=shallow на assign).
Bootstrap status (2026-05-08): только StringBuilder.@clone() и
WriteBuffer.@clone() зарегистрированы как built-in (deep, через
Nova_*_clone C-функции). Для record/коллекций программист пишет
clone вручную.
Подробно — Plan 17 Ф.1, Q-clone-semantics (closed).
StringBuilder.@into() -> str — infallible (UTF-8 invariant
поддерживается каждым @append, который принимает только str или
char). WriteBuffer.@into() -> []u8 — infallible (произвольные
байты валидны как []u8). ReadBuffer — view, @into()
не определён (явный throw блокирует D73 auto-derive).
ReadBuffer пара @read_* (Fail-form) / @try_read_* (Result-form)
— обе формы явно в runtime_registry.rs и в std/runtime/read_buffer.nv.
Каждая Fail-форма имеет независимую C-функцию Nova_ReadBuffer_method_read_X,
а Result-форма — Nova_ReadBuffer_method_try_read_X. Автоматический
синтез одной из другой отменён (Plan 13 Ф.9.5; ранее Plan 12 Ф.4.5
предлагал такое правило, но было отменено для соблюдения D82 single-source-
of-truth — всё что компилятор знает, должно быть в registry явно).
char — Unicode codepoint, НЕ UTF-8 byte sequence. char хранит
одно скалярное значение Unicode (диапазон 0..0x10FFFF, исключая
surrogate pairs 0xD800..0xDFFF). Размер в памяти — 4 байта (как Rust
char, Go rune, Swift Unicode.Scalar).
str хранит UTF-8 байты, char — codepoint. Конверсии:
char → strилиchar → []u8— UTF-8 encode (1-4 байта в зависимости от значения; см.Buffer.add_charв Q-buffer).str.chars() -> Iter[char]— UTF-8 decode по ходу итерации.
Это разделение типичное для современных языков (Rust, Swift). Go
использует rune = int32 по тому же принципу. C char это byte —
не аналог Nova char.
Bootstrap-status: char зарезервирован как тип, но синтаксис
char-литералов ('a') — ещё открытый вопрос (Q-char-literals).
В коде сейчас используется nova_int напрямую (передаём codepoint
как число) — это будет заменено на нормальный char при закрытии
Q-char-literals.
str — Unicode-string. Внутреннее представление — UTF-8 байты
(ptr, byte_len), но все public operations работают на уровне
codepoint’ов (Unicode scalar values). Содержимое — валидный UTF-8
по конвенции: литералы, конкатенация и str.from(...) гарантируют
валидность; FFI-код должен сам проверять при создании str из
чужого буфера.
MAJOR AMEND (Plan 139, ЗАКРЫТ 2026-06-11):
str— Nova value-type, не C-примитив.strтеперь — Nova value-record lang-item:type str value priv { ptr *u8, len int } // 16 байт, stack, copy-семантика
value→ stack-аллокация, 16 байт, copy-семантика (как было).priv→ поля видны только методам str.ptr *u8→ указатель на иммутабельный UTF-8 буфер (*T= ro-pointee, D246 — flagship use-case; immutability выражена типом указателя,*ro u8избыточен →E_REDUNDANT_POINTER_RO).len— длина в байтах (D26 §«str.len = bytes»; publiclen()— codepoints).ABI (risk-limiter, Ф.0): value-record лоуэрится в C-структуру layout-идентичную старому
nova_str:typedef struct {const uint8_t* ptr; int64_t len;} nova_str;(было{const char* ptr; size_t len;}). На x64const char* ≡ const uint8_t*иsize_t ≡ int64_t→ все ~354 рантайм-C-вхождения (net.c/effects/channels/sync/vtables/string_builder/ conv/fibers) продолжают работать черезnova_str-typedef-алиас БЕЗ правок.sizeof(str) == 16. Typedef определён вnova_rt.h:56+vtables.h:42.Методы — Nova-body (миграция Plan 139.2 Ф.0-Ф.3):
starts_with/ends_with/contains/find/rfind/char_at/char_len/trim/to_lower/to_upper/to_bytes/to_chars(139 Ф.1/Ф.2),as_bytes(139.2 Ф.0),split/from_bytes_unchecked/from_bytes_lossy/from_bytes_unchecked_steal(139.2 Ф.2),concat/compare(139.2 Ф.3) мигрированы из externalnova_str_XC-функций в Nova-тела вstd/runtime/string.nv(читают байты через@as_bytes()zero-copy view +@byte_at, аллоцируют через[]u8.with_capacity+push+str.from_bytes_unchecked). Семантика байт-в-байт идентична C-оригиналам. 9/10 str-методов — Nova-body. Остаётся C только@hash(SipHash-1-3 + crypto-seed, DoS-resistance —nova_str_hash). Operator-lowering ОСТАЁТСЯ на C (option (b), 139.2 Ф.3): операторы+/</<=/>/>=/==/!=надnova_strлоуэрятся ОТДЕЛЬНО, напрямую вnova_str_concat/nova_str_lt/…/nova_str_eq(perf hot-path: один alloc+ 2×memcpy / один memcmp vs Nova push-loop / byte-loop); ПРЯМЫЕ method-вызовыs.concat(t)/s.compare(t)идут в Nova-body. См. 02-types.md «Amend (Plan 139.2 Ф.3)»
[M-139.1-operator-lowered-methods](reframed).eq/hash/clone (Ф.3) — content, не pointer.
==/!=/</<=/>/>=лоуэрятся напрямую в BinOp codegen →nova_str_eq/nova_str_lt/… (memcmp/ byte-loop).emit_field_eqспец-кейситcty == "nova_str"→nova_str_eqПЕРЕД Plan 141 field-by-field — поэтому str-in-tuple/record/sum и str-keyedHashMap(hash черезnova_str_hashSipHash-1-3 по байтам) content-keyed автоматически.@clone= 16-байт handle-copy над общим иммутабельным буфером (*u8ro-pointee делает sharing безопасным). См. D228 content-eq override.Литералы интернируются (Ф.6): идентичные литералы делят один
static const uint8_t[]буфер + одинstatic const nova_str(per-CU rodata dedup; content-hash символы; семантически невидимо т.к. eq — content). См. §«Compile-time литералы интернируются» ниже.GC (Ф.5):
strvalue — 16-байт stack-значение{ptr,len};ptrуказывает в heap (RawMem/nova_alloc, GC-tracked) либо rodata (литералы/ interned — никогда не собираются). Conservative stack-scan покрывает value-record str на стеке (ptr-поле — обычный указатель в 16-байт значении).Открытые followups (никогда не silently dropped):
[M-139-f0-lang-item-decl]— полная Nova-декларация + privacy-enforcement (s.ptr→E_PRIV_FIELD); сегодня str — compiler-builtin, layout известен codegen’у, но privacy-gate + member-access@ptrfield-доступ требуют новой lang-item checker-инфры. Гейтит:[M-139-f1-trim-view](zero-copy trim-view),[M-139-f2-ptr-field-producers](as_bytes/split/from_bytes_*остаются тонкими C-примитивами — Cas_bytesуже zero-copy, контракт сохранён).[M-139-f0-rt-header-ptr-sign-casts]— 59 -Wpointer-sign warnings в рантайм-хедерах (source-compatible, подавлены-w).Q139-блоки (resolved / extracted):
Q139-gc-stack-scan→ RESOLVED: conservative stack-scan достаточен; str-value на стеке — 16-байт{ptr,len}, ptr-поле сканируется как обычный указатель; буферы static-rodata (литералы/interned, не собираются) либо RawMem/nova_alloc(GC-tracked).Q139-literal-buffer-lifetime→ RESOLVED: литералы — static rodata (program lifetime, никогда не GC’ятся); built-строки — heap, GC-tracked.Q139-utf8-cursor-primitive→ RESOLVED: один helpercp_to_char(codepoint→char) + byte-курсор по@as_bytes(); единственный decode-helper.Q139-str-eq-override→ RESOLVED: explicit special-case вemit_field_eq(cty=="nova_str"→nova_str_eq) + direct BinOp lowering; str никогда не доходит до Plan 141 field-by-field. См. D228.Q139-cstr-nul-termination→ RESOLVED (§3 выше, Ф.4): alloc-fallback черезnova_str_terminated_ptr.Q139-intern-scope→ RESOLVED (§interning ниже, Ф.6): per-CU.Q139-as-bytes-aliasing→ EXTRACTED в[M-139-f2-ptr-field-producers]: pure-Nova zero-copyas_bytesчерез@ptr-поле gated на[M-139-f0-lang-item-decl]; Cas_bytesуже zero-copy (ro Vecview над*u8ro-pointee — нет легального write-path), контракт сохранён.Спецификация value-record: D228. Указатель-поле: D216 §1. Таксономия value/reference: D52.
Длина и индексация (codepoint-indexed, школа Python/Swift):
s.len— длина в codepoint’ах, O(n) (требует обхода UTF-8). Это базовая «длина строки» с точки зрения программиста.s.byte_len()— длина в байтах, O(1). Для FFI и буферных операций.s[a..b](slice, bracket-form) — принимает codepoint-индексы, O(b) (нужен обход до byte-offset’ов). Boundary всегда корректные — невозможно попасть в середину multi-byte sequence. Panic при OOB (consistent сarr[a..b], D144). Также 5 форм Range:s[a..b]/s[a..=b]/s[a..]/s[..b]/s[..].s[i](codepoint indexing) —Option[char], O(i).Noneеслиi >= s.len. См. также Q-string-indexing.s.chars() -> Iter[char]— ленивый обход codepoint за codepoint.
Plan 96.1 (2026-05-23): метод
s.slice(a, b)удалён в пользу bracket-формыs[a..b](D9 «один очевидный путь»; convergence Rust/Go/ Swift/Python — bracket-only). Старая clamp-семантика метода (OOB → обрезка до длины) удалена; bracket-form всегда panic’ит на OOB — симметрично сarr[a..b](D144). Closes[P-str-slice-clamp-vs-panic].
⚠ AMEND Plan 152.1 (D249, в процессе 2026-06-13): школа B (всё codepoint- indexed) разворачивается на байт-координаты + линзы (D249,
docs/plans/152.1). УЖЕ сделано (Ф.2):@find/@rfindвозвращают БАЙТОВЫЙ offset (композируются с byte-slices[k..]за O(1); cp-offset был несоставим). В процессе:str[i](int) →E_STR_NO_INT_INDEX(Ф.1), бэарlen()→E_STR_NO_LEN+as_chars()линза (Ф.4). Раздел «Почему codepoint-indexing» ниже — исторический (school-B reasoning), отвергнут D249 в пользу прозрачности стоимости (O(n) под[i]/len— «ложь»). Пример сi == 6 (codepoints)устарел: теперьfindдаёт байт-offset.
Поиск, сравнение, конверсия:
fn str @find(needle str) -> Option[int] // БАЙТ-offset (Plan 152.1 Ф.2/D249)
fn str @rfind(needle str) -> Option[int] // последний БАЙТ-offset
fn str @contains(needle str) -> bool
fn str @starts_with(prefix str) -> bool
fn str @ends_with(suffix str) -> bool
fn str @split(sep str) -> Iter[str]
fn str @trim_ascii() -> str // Plan 91.18: bare trim = Unicode под import std.unicode
fn str @to_ascii_lower() -> str // Plan 91.18: bare to_lower = Unicode под import std.unicode
fn str @to_ascii_upper() -> str // Plan 91.18: bare to_upper = Unicode под import std.unicode
s.find(":") -> Option[int] возвращает байт-offset ”:”, который композируется
напрямую с byte-slice s[k..]:
ro s = "Привет:мир" // 19 bytes
ro i = s.find(":").unwrap_or(0) // i == 12 (БАЙТ-offset ":"; П,р,и,в,е,т = 12 bytes)
ro key = s[0..i] // "Привет"
ro val = s[i + 1..] // "мир" (open-end)
(ИСТОРИЧЕСКОЕ — отвергнуто D249) Почему codepoint-indexing (школа B) была выбрана:
- AI-friendly. LLM генерирует код где
s.lenинтуитивно «количество символов». Byte-уровень (Rust/Go) — источник bug’ов у новичков и AI:"Привет".len == 12нелогично. - Безопасность boundary. Невозможно попасть в середину UTF-8 sequence — все индексы codepoint-выровнены.
- Consistency.
find/s[a..b]/s[i]— все codepoint-уровень, не нужно мысленно переключаться между byte и codepoint. - Прецеденты: Python (codepoints), Swift (graphemes — ещё выше), Java (UTF-16 code units, близко к codepoint для BMP). Все современные языки кроме system-low-level (Rust, Go, C) выбирают codepoint-or-grapheme уровень.
Цена:
- O(n) для
s.len, O(b) дляs[a..b]— обходы UTF-8. Внутреннее byte-хранилище неизбежно: альтернатива (UTF-32 4-byte per char) утроит память для ASCII-heavy кода. - Hot-path работа с byte-уровнем — через explicit
s.bytes()→[]u8или черезBuffer(Q-buffer). - В Nova принципе AI-генерация важнее микро-perf для primitive ops; программист может явно перейти на byte-уровень там где надо.
FFI / byte-уровень доступен через:
fn str @byte_len() -> int // O(1) — для C-interop размеров
fn str @bytes() -> []u8 // copy (D73 []u8.from(s))
Конверсия в []u8 через D73:
[]u8.from(s str) -> []u8— infallible (всегда работает,strгарантированно валидный UTF-8). Копируетs.ptr..s.ptr+s.lenв свежий[]u8. D73 авто-синтезируетs.into()дляlet b []u8 = s.into().- Копирует, не view: Nova не имеет readonly-меток (D6 — managed
heap без borrow-checker), а
[]u8mutable — без копии mutate испортил бы immutabilitystr. Стоимость O(n) — приемлемо для границы str↔bytes; для in-place аккумуляции использоватьBuffer(Q-buffer). str.from(b []u8) Fail[Utf8Error] -> str— fallible-форма (D73 + Fail-effect). Валидирует UTF-8; на ошибке throw’ает. Auto-derived:b.into()тоже декларируетFail[Utf8Error]. Result-форма (str.try_from(b)→Result[str, Utf8Error]) доступна через D77 как convenience sugar.
Nul-termination (C-interop, RESOLVED 2026-06-03 — Plan 118.1 closes Q-cstring):
Nova str storage invariant — formal rules:
-
Full str (literal, runtime-allocated through concat/from_X/etc):
- Backing buffer is
len + 1bytes ptr[len] == '\0'ALWAYSptrcan be passed directly to C functions expectingconst char*- Implementation: все Nova allocator paths (
nova_str_concat,nova_str_to_upper/lower,string_builder.h,conv.h, literals в.rodata) allocatelen + 1+ explicitbuf[len] = '\0'.
- Backing buffer is
-
Substring view (created via
s[a..b]per D144):- Shares backing buffer of parent str
view.ptr[view.len]MAY OR MAY NOT be'\0':- TRUE iff view ends at parent’s
len(e.g.,s[5..]) - FALSE iff view ends mid-buffer (e.g.,
s[2..5])
- TRUE iff view ends at parent’s
- Parent’s
'\0'exists atparent.ptr[parent.len], beyond view’s window - Memory access at
view.ptr[view.len]ALWAYS safe (within parent buffer котороеparent.len + 1bytes, иview.len <= parent.len).
-
str @as_cstr()resolution (Q-cstring closed — Plan 118.1; alloc-fallback IMPLEMENTED — Plan 139 Ф.4 / Q139-cstr-nul-termination):- Runtime check:
ptr[len] == 0?- TRUE → zero-copy view (CStr wraps
ptrdirectly) - FALSE → allocate
len + 1, copy + NUL (fallback к@to_cstrsemantics)
- TRUE → zero-copy view (CStr wraps
- Plan 139 Ф.4: реализовано через C-примитив
nova_str_terminated_ptr(nova_rt.h) —s.ptr[s.len]peek (всегда safe per §2: parent bufferparent.len+1,view.len <= parent.len) + conditionalnova_alloc(GC-tracked) копия. Both@as_cstr()и@as_cstr_unchecked()маршрутятся через него: mid-buffer slice (s[a..b]ending mid-parent) теперь alloc+terminate’ится, так что C-side strlen НЕ over-read’ит за окно слайса. Раньше (V1) as_cstr был unconditional zero-copy → strlen на mid-buffer слайсе over-read’ил в parent.strvalue-record ABI ({const uint8_t* ptr; int64_t len;}, Plan 139 Ф.0) проходит черезconst char*FFI boundary без правок (const char*≡const uint8_t*). - SAFE primitive (no
#unsafeattribute):fn str @as_cstr() -> CStr // zero-copy view + embedded-NUL scan (V1) // fn str @to_cstr() -> CStr // always-copy — DEFERRED to Plan 118.2 (needs allocator) - Both validate против embedded NUL в str body (panic если найден —
C-side truncation prevention;
@as_cstr_uncheckedskips validation for perf-critical paths).
- Runtime check:
-
CStrtype definition (Plan 118.1 —std/ffi/cstr.nv):type CStr(*u8) // tuple newtype, zero-overheadImplements D73 From/Into для
str(черезtry_fromper D77 canonical form). См. Plan 118.1 для полной API surface.
См. Plan 118.1 для implementation details + complete CStr/str conversion matrix.
Дедупликация / interning: str не интернируется автоматически на
runtime — одинаковые runtime-строки (результаты concat, to_upper,
split, и т.п.) — разные инстансы с разными буферами. == сравнивает
контент (memcmp/byte-loop), O(min) — см. Ф.3 content-eq.
Compile-time литералы интернируются компилятором (Plan 139 Ф.6,
Q139-intern-scope resolved): идентичные строковые литералы (одинаковые
байты) разделяют ОДИН static const uint8_t[] буфер в .rodata + ОДНО
static const nova_str значение. "abc", встреченный N раз в одной
compilation unit, ссылается на один буфер вместо N inline
(nova_str){.ptr="...",.len=N} compound-литералов (rodata dedup —
size/perf win + identity-stable rodata-указатель). Реализация —
CEmitter::intern_str_literal (emit_c.rs): content-keyed dedup-map +
content-hash символьные имена (_nova_strlit_<fnv1a-hex>, стабильные,
collision-resistant с sequence-suffix guard на astronomically-unlikely
hash-clash). Splice в /*__INTERNED_STR_LITERALS__*/ preamble-маркер.
Интернирование семантически невидимо: т.к. str eq/hash —
byte-content (Ф.3), совпадение pointer-identity между разделёнными
литералами не наблюдаемо программой; буфер — *u8 ro-pointee (immutable), поэтому
sharing безопасен (нет write-path сквозь общий указатель). Пустая строка
"" сохраняет inline-форму (буфер не нужен). Top-level const NAME = "lit"
эмитит собственный inline static-инициализатор (не проходит через
interning) — нет нарушения C constant-expression в nested static-init.
Scope (Q139-intern-scope resolved): интернирование —
per-compilation-unit (символы static const = internal linkage, нет
cross-TU коллизий; bootstrap-safe). Whole-program-interning не нужен:
content-eq делает кросс-юнитную идентичность ненаблюдаемой. Для opt-in
runtime-interning изменяемых строк — по-прежнему открытый вопрос
(Q-string-interning): Atom-тип или Sym[T] (Erlang-style); прецеденты —
Rust не интернирует runtime, Java/C# имеют пул для литералов + opt-in
intern().
Конкатенация: s1 + s2 — O(a+b), новая аллокация каждый раз.
В hot loop s = s + x × N → O(N²). Для аккумуляции использовать
Buffer (Q-buffer; финализация через @try_into() -> Result[str, Utf8Error] для UTF-8 или @into() -> []u8 для сырых данных).
Nova унифицирует string-builder и byte-buffer в один тип — отличается
от Go (bytes.Buffer + strings.Builder) и Rust (Vec<u8> +
String).
См. также Q-char-literals (синтаксис
char-литералов) и D54 (as/is для конверсий).
Математические операции на числовых типах объявлены как
instance-методы через @ (D74):
x.sqrt(), theta.cos(), y.atan2(x), a.hypot(b), n.abs(),
x.is_finite(), etc. Static-функции — только для констант
(f64.PI, f64.NAN) и парсинга (f64.try_parse(s)).
any — пустой protocol-тип (D53). Любой тип удовлетворяет
пустому контракту, поэтому any — top-type (универсальный супертип).
Имя lowercase — исключение в 03-syntax.md → D30
naming convention, по аналогии с примитивами. Использование:
fn dump(x any) Io -> (), Logger.log_event(level, fields []any)
для гетерогенных структурных логов.
Runtime-представление (Plan 174.3, 2026-07-04). any — тип-стёртый
void*, указывающий на heap-boxed NovaAny { const NovaTypeInfo* info; void* data; }, где NovaTypeInfo { NovaTypeId type_id; const char* name; }
несёт type_id из реестра Plan 61. Boxing (v as any, а также неявный
upcast по supertype-правилу к any-параметру / -> any / ro x any =)
копирует значение в GC-allocation и ставит per-type info. Downcast и
проверка — через type_id-сравнение (x is T, x.try_as[T]() -> Option[T],
flow-narrowing if x is T). Детали is/try_as/ABI — см.
03-syntax.md → D54. Сосуществует с
мономорфизацией: any fat-pointer — когда тип стёрт в рантайме; скрытые
vtable-параметры — когда тип статичен.
Iter[T] — структурный protocol для итераторов (D58). Любой
тип с методом mut next() -> Option[T] автоматически удовлетворяет.
for x in collection-синтаксис вызывает collection.iter().next() в
цикле; коллекции реализуют iter() возвращая собственный iterator-тип.
Range — runtime-представление range-литерала a..b (exclusive)
и a..=b (inclusive) (D58). Range — обычное значение, можно
передавать как аргумент, хранить в переменной, использовать в for.
Стандартные эффекты в prelude — после D62 делятся на две категории по влиянию на семантику программы:
Semantic effects — влияют на результат
Программист обязан объявить в сигнатуре, если функция их использует. Caller получает информацию что зависит от resource’а.
| Эффект | Resource | Тестовый handler |
|---|---|---|
Fail[E] | error reporter | with Fail[E] = |e| ... |
Io | stdout/stderr | mock-stdout |
Net | сеть (HTTP/socket) | recorded responses |
Db | соединение к БД | in-memory db |
Fs | файловая система | virtual-fs |
Time | clock | fixed_ms(ms u64) / mut_clock(start_ms u64) |
Random | RNG | seeded(seed u64) |
Log | logger | capture-log |
Ask[T] | контекстный read (Reader) | fixed value |
Alloc[R] | region аллокация | (для real-time, D6) |
Detach | background scheduler | SyncDetach |
Blocking | OS-thread pool | mock |
Instrumental effects — observability, ambient
Mem (D76) и Trace — не влияют на результат программы,
только на наблюдаемость. Программист не декларирует их в
сигнатуре; компилятор не лифтит через D28-inference.
// Программист пишет:
fn parse_data(s str) -> Data { ... }
// Внутри может быть Trace.span("parse"), Mem.alloc_count() — это
// implementation detail, в сигнатуру НЕ лифтится.
Ambient capability — прецедент Async (D14/D62). Если в скоупе
нет active handler для instrumental эффекта — runtime-panic
(RuntimeError.NoHandler("Mem") через D65),
не compile error.
| Эффект | Категория |
|---|---|
Mem | instrumental, ambient |
Trace | instrumental, ambient |
Зачем разделять:
- Сигнатуры остаются чистыми. Если бы
Traceбыл semantic, то почти каждая функция бы содержала его — observability обычно pervasive. Шум в типах. - AI-friendly. LLM не должна писать
Memв сигнатуре — instrumental detail имплементации. - Интуитивно.
Timeв сигнатуре говорит “функция зависит от времени, тестируй с fixed clock”.Traceв сигнатуре ничего полезного не говорит.
Не существуют как эффекты
| Имя | Почему |
|---|---|
Async | runtime mechanic (suspension, D14 (REVISED)) |
Par | runtime mechanic (parallelism через parallel for) |
Mut | удалён (D62) — mut поля/параметры |
Базовые функции:
fn print(...items []any) Io -> () // variadic, см. D69
fn println(...items []any) Io -> () // variadic + newline
fn panic(msg str) -> never // смерть текущего fiber'а (D13)
fn exit(code int, msg str) -> never // смерть всего процесса (D13)
// Assertions — обычные fn-call, обязательно со скобками
fn assert(cond bool) -> () // always runtime; failure → panic (D13)
fn debug_assert(cond bool) -> () // debug-only; no-op в release (D81)
print/println — variadic (D69),
принимают любое число аргументов любого типа (any —
D54). Каждый аргумент конвертируется в строку
через str.from(v) (D73).
Spread разрешён: print(...parts).
assert/debug_assert — обычные функции, не keyword’ы. Вызываются
со скобками как любой fn-call: assert(x > 0). Build-mode семантика —
D81. Failure любого assert’а — panic (D13), не Fail.
never — bottom-тип (uninhabited)
never — bottom-тип языка: строчный встроенный примитив, в одном
ряду с int/bool/f64. Не объявляется ни в prelude, ни через
type — компилятор знает его напрямую (как и остальные примитивы).
Имя строчное по конвенции примитивов (Plan 76).
Свойства:
- Uninhabited — значений типа
neverне существует (0 значений). never— подтип любого типа (bottom type ⊥). Любой контекст, ожидающийT, может принятьnever-выражение.- Используется в типах не-возвращающих выражений —
throw expr,return expr,panic(...),exit(...),unreachable(reason)(Plan 125 followup[M-125-unreachable-builtin]),interrupt expr(в handler-literal), бесконечныйloop, прямой вызовfn -> never. Все имеют типnever, поэтому совместимы с любым контекстом.
Result-type inference для ветвящихся выражений (Plan 125, 2026-06-05):
if / if let / match / block-trailing инферят результирующий тип,
пропуская ветки, доказуемо diverge. Пример:
ro s = if c {
throw ParseErr.Bad // never — ветка пропускается
} else {
"hello" // str — выбран как тип всего выражения
}
// type of `s` = str (не unit, не "ошибка mismatch")
Полный whitelist divergent-выражений в trailing-позиции:
throw expr,interrupt exprpanic(...),exit(code, msg),unreachable(reason)— prelude builtin’ы (unreachableдобавлен в Plan 125 followup[M-125-unreachable-builtin])- Прямой вызов любой
fn -> never - Method-call
expr.method(...)где method декларирован-> never(Plan 125 followup[M-125-method-call-never-detection]) loop { ... }безbreakв body — бесконечный цикл (Plan 125.2[M-125-loop-no-break-divergence]). Conservative: при наличии любогоbreakв body — НЕ divergent.while true { ... }с constant-truecond и безbreakв body (Plan 125.2[M-125-while-true-divergence]). Распознаётся только literaltrue/(true)—while cond_varилиwhile false— НЕ divergent.Stmt::Return e/Stmt::Break/Stmt::Continueв последней stmt-позиции блока (block_trailing_diverges— Plan 125.2[M-125-stmt-position-divergence]). Эти statements уже завершают control-flow блока, поэтому контекстная join-инференция трактует блок как divergent даже безb.trailing.- Рекурсивно: вложенные
if/if let/match/block, у которых все ветви diverge
Codegen ограничение: detection — trailing-only (b.trailing или
последний Stmt::Throw/Stmt::Return/Stmt::Break/Stmt::Continue/
Stmt::Expr(...) в b.stmts). Условные early-returns в середине
блока не flip’ят join-тип (cтdlib-идиома if early-cond { return X } else { compute() } сохраняется).
Type-checker first-class (Plan 125.1, 2026-06-05):
[M-125-type-checker-never-first-class] ✅ CLOSED — codegen-fix
дополнен настоящим type-side first-class subtype rule в
compiler-codegen/src/types/mod.rs:
- Ф.1
assignable()—if matches!(ty_of_ref(&found_tr), Ty::Never) { return Compat::Ok }— never <: T для любого T - Ф.2
infer_expr_typereturnsSome(prim_ref("never"))дляExprKind::Throw,ExprKind::Interrupt, never-returning builtin calls (panic/exit/abort/unreachable), и user fn’ов где ВСЕ overloads объявлены-> never - Ф.3
infer_block_trailing_typerefвозвращаетSome(never)когда trailing — top-level divergent shape (Throw/Interrupt/never-call); conservative — не walks preceding stmts - Ф.4 D196 detector
detect_divergent_consumableиспользуетblock_divergesдля early-skip обеих веток — ЛЮБОЙ divergent путь → None (нет fake-conflict «Consumable[T] vs never»)
Test coverage: nova_tests/plan125_1/ — 12 positive + 3 negative
фикстуры. Pure-additive — существующий TyCat::Other safety-net
preserved (conservative addition, не subtraction).
Аналоги: Rust ! (never-RFC), Haskell Void, Kotlin/Scala
Nothing, TypeScript never. Не уникальная фича Nova.
Эффекты как обычные типы — Fail[E] не магия
Fail[E] объявляется в prelude как любой другой эффект — через
kind-токен effect (04-effects.md → D18 (REVISED),
D61):
type Fail[E] effect {
fail(value E) -> never
}
throw expr — сахар для Fail[E].fail(expr) (вызов операции
активного handler’а), как Db.query(...). Никакой специальной
обработки. См. 04-effects.md → D25,
04-effects.md → D61.
Что НЕ в prelude
Коллекции (String, HashMap, HashSet, LinkedList), I/O API (File, Http),
JSON, SQL, время как библиотека — обычные модули, требующие
явного импорта:
import std.io.{File, read_all}
import std.collections.HashMap
Почему
Зачем нужен prelude
Без prelude каждый файл начинается с:
import std.option.{Option, Some, None}
import std.result.{Result, Ok, Err}
Это шум на 90% файлов. Прецедент — Rust, Haskell, Swift, Kotlin: все имеют prelude. AI-first: LLM не должен генерировать boilerplate-импорты базовых типов.
Не противоречит «локальности контекста»
Prelude документирован, его содержимое — фиксированный список, не магия. LLM знает, что доступно везде. Всё остальное — явный импорт (07-modules.md → D29).
Plan 128 Ф.3 amend (2026-06-05) — primitives reject mut @method
fn <primitive> mut @method(...) user-объявления отвергаются с
E_PRIMITIVE_MUT_METHOD. Список primitives: int, i8-i64,
u8-u64, f32, f64, bool, char, str, ().
// ❌ ERROR — E_PRIMITIVE_MUT_METHOD
fn int mut @increment() => @ + 1 // примитивы pass-by-value, мутация не видна
fn str mut @upper() => "ABC" // str — immutable reference type
fn bool mut @toggle() => not @ // бессмысленно для primitive
Rationale (Nova-first idiom, Plan 91 §«Принцип»): примитивы передаются
by value (D32) — mut @ receiver не имеет наблюдаемого эффекта (мутация
происходит в копии). Pure functional pattern — int.add(other) returns
new value, не mutates self. Это symmetric с Rust (&mut self on Copy
types — useless), Kotlin (data class copy), Swift (mutating func only
для structs/enums, не Int/Bool).
Enforcement: type-checker (compiler-codegen/src/types/mod.rs::TypeCheckCtx::build)
проверяет receiver type против is_primitive_name(name) whitelist при
parser-accepted mut @method declaration. E_PRIMITIVE_MUT_METHOD diag
emitted с suggestion «remove mut, use immutable receiver + return new value».
Coverage: Plan 128 Ф.3 fixtures nova_tests/plan128/t6_primitive_str_* /
t7_primitive_int_* / t8_primitive_bool_* / t9_primitive_f64_*
(negative regression) + t10_primitive_ro_method_ok (positive — ro @
methods on primitives still allowed). См. D215 amend «Method receiver
passing» (Plan 128 Ф.2) для allowed mut @ paths (named tuples + value records).
Что отвергнуто
- Никакого prelude, всё через явный import — шум, не выигрыш.
- Prelude определяется компилятором, без документации — магия, ломает AI-first тезис.
- Prelude настраивается per-project — усложнение без выгоды; LLM должен знать фиксированный набор.
Void— отвергнут, тип «без значения» это()(unit). См. 03-syntax.md → D20.
Связь
- 01-philosophy.md → D10 — AI-first, локальность через документированный prelude.
- 04-effects.md → D25 —
throwиFail[Error]. - 04-effects.md → D18 — эффекты как обычные типы.
- 02-types.md → D17 — sum-type,
neverкак пустой. - 03-syntax.md → D20 —
()вместоvoid. - 07-modules.md → D29 — prelude и явные импорты.
Открытые вопросы
Полный API— частично закрыт (2026-05-07): базовые методы (Option/Resultis_some/is_none/unwrap/unwrap_or/unwrap_or_else/map/ok_or/orдля Option;is_ok/is_err/ok/err/unwrap/unwrap_or/unwrap_or_else/map/map_errдля Result) описаны в prelude выше. Расширенный API (and_then,flatten, etc.) — отдельная задача (Q-monadic-api).Семантика— закрыто D67: ранний?дляOptionreturn Noneиз текущей функции.Errorкак универсальный тип — что в нём (поддержкаstr.from(e), цепочка причин)? Похоже на Ruststd::error::Error.
Цена
- Список prelude нужно поддерживать. Любое добавление в prelude — breaking change после v1.0 (имя становится «зарезервированным» в модулях). Поэтому prelude минимален.
- Импорт-конфликты. Если программист объявит свой
type Option, будет конфликт с prelude — компилятор предупредит.
Runtime stdlib проекция (Plan 13)
Все методы str / f64 / f32 которые знает компилятор объявлены в
std/runtime/string.nv и
std/runtime/math.nv — auto-generated
из compiler-codegen/src/codegen/runtime_registry.rs через команду
nova-codegen emit-runtime-stubs.
Эти модули НЕ требуют import — методы доступны через обычный
method-call синтаксис (s.find, x.sin), потому что str / f64 /
f32 — built-in типы из prelude. std/runtime/*.nv — read-only artefact
для:
- Code-review: разработчик видит формальные сигнатуры всех runtime-функций в одном месте.
- Type-check без полной компиляции:
nova-codegen checkзагружает декларации и валидирует user-код против них. - Single source of truth: runtime_registry.rs (Rust) — driver,
.nv-файлы — проекция. Изменение реестра → регенерация → diff видно в.nv.
Manual edits запрещены — pre-commit/CI guard через
emit-runtime-stubs --check (Plan 13 Ф.6).
См. docs/plans/13-runtime-stdlib-and-autogen.md.
GC introspection — std.runtime.gc (Plan 32)
Namespace gc.* доступен для runtime-инспекции и явного управления GC:
ro h = gc.heap_size() // bytes; 0 если backend без introspection
ro n = gc.live_count() // приблизительное число live-объектов
ro a = gc.alloc_count() // монотонный счётчик с старта
gc.collect() // принудительный сбор (no-op под malloc)
gc.reset_stats() // сброс счётчиков
Без import — gc — встроенный namespace (как panic / exit).
Документация в std/runtime/gc.nv; фактический
dispatch — hard-coded в compiler-codegen/src/codegen/emit_c.rs (special-
case для gc.<method>() member-call’ов).
Semantics per backend:
| API | malloc | boehm |
|---|---|---|
heap_size() | 0 (honest «не поддерживается») | GC_get_heap_size() |
live_count() | alloc - free | alloc_count (upper bound) |
alloc_count() | counter | counter |
collect() | no-op | GC_gcollect() |
reset_stats() | zero counters | zero counters |
heap_size() == 0 — honest sentinel; differential-тесты могут
использовать if gc.heap_size() == 0 { ... skip ... }.
Прецеденты: Go runtime.GC() / runtime.ReadMemStats, Java
System.gc() / Runtime.totalMemory(), Python gc.collect() /
gc.get_stats(), .NET GC.Collect() / GC.GetTotalMemory(). Nova
следует convention.
См. docs/plans/32-gc-introspection.md.
D41. Static-функции есть, static-состояния нет
Что
У типа есть static-функции (fn Type.name(...)), но нет
static-полей, нет static-переменных, нет static initializer’ов.
Если нужны константы, ассоциированные с типом, — это const в том же
модуле. Если нужно «глобальное» изменяемое состояние — это handler
(эффект-capability), не static.
Правило
Static-функции — обычные функции в namespace типа
Внутри одной static-функции другие static-функции того же типа вызываются через полное имя, без сокращений:
fn Account.new(owner str) -> Account =>
Account { _balance: 0, owner }
fn Account.from_balance(owner str, initial money) -> Account {
ro acc = Account.new(owner) // явное Account.new, не self.new
Account.deposit_static(acc, initial) // тоже явно
acc
}
Никакого Self::new (Rust) или просто new (Java/C#). Один способ
вызова static-функции — через имя типа, что внутри типа, что снаружи.
Константы рядом с типом — const в модуле
const ACCOUNT_MIN_BALANCE money = 0
const ACCOUNT_MAX_OVERDRAFT money = 1000
fn Account.new(owner str) -> Account =>
Account { _balance: ACCOUNT_MIN_BALANCE, owner }
Если нужна группировка — отдельный модуль:
module account_limits
export const MIN_BALANCE money = 0
export const MAX_OVERDRAFT money = 1000
// использование:
import account_limits
ro acc = Account.new_with(account_limits.MIN_BALANCE)
Глобальное изменяемое — через handler
Вместо static counter / static config — handler, передаваемый через
with-блок:
// Эффект ([04-effects.md → D61](/spec/decisions/effects/#d61))
type IdGen effect {
fresh() -> u64
}
// Handler — обычная функция, возвращающая handler-литерал
fn counter_id_gen(c mut Counter) -> Effect[IdGen] =>
effect IdGen {
fresh() {
c.count += 1
c.count
}
}
// в main:
fn main() {
mut counter = Counter { count: 0 }
with IdGen = counter_id_gen(counter) {
run_app()
}
}
Это пример closure-capture паттерна по D68. Альтернатива —
@as_handlerметод на record’еCounter— рассмотрена в D68 для случаев, когда state нужно проинспектировать снаружи. Выбор между паттернами детерминирован сценарием (нужен ли state наружу), не вкусом.
Тестируется тривиально — другой handler в with-блоке.
Почему
- Static state — главный источник скрытых багов. Глобальный изменяемый стейт не виден в сигнатурах, ломает параллельность, невозможно тестировать без хаков.
- Тесты. Static-поле = разделяемое состояние между тестами.
Каждый тест должен либо ресетить его (хрупко), либо запускаться
изолированно (медленно). Handler —
with-блок изолирует автоматически. - Параллелизм. Несколько fiber’ов на одном static-поле = data race по умолчанию. Handler-state живёт в scope и не делится случайно.
- DI is the language. Передача зависимостей — это handler. Не нужен отдельный фреймворк для DI, не нужны static-singleton’ы как замена.
- Единственный путь. Нет «иногда static, иногда handler» — всегда handler. Меньше способов сделать неправильно.
Что отвергнуто
- Static mutable поля (Java
static int counter, Python class variable) — мешают тестам и параллелизму. - Static immutable поля как
constна типе (const Account.MIN) — технически безопасно, но добавляет второй способ объявить константу. Один способ —constв модуле. - Companion-object (Kotlin) — то же что и static, просто в обёртке. Не нужен.
- Lazy static (Rust
lazy_static!) — скрытое глобальное состояние с инициализацией. Если нужна ленивость — handler с lazy полем.
Связь
- 05-memory.md → D6 — глобального mutable state не предусмотрено в модели памяти; всё живёт в fiber-scope или handler-scope.
- 04-effects.md → D11, 04-effects.md → D31 — handler-механизм для «глобальных» состояний.
- 04-effects.md → D18 — эффекты это обычные
type, не keywordeffect. - 03-syntax.md → D33 —
const— единственный способ объявить immutable «глобальную» константу.
Цена
- Привычка из Java/C#/Python ломается. Нет
Account.MAX_BALANCEкак поля, естьMAX_BALANCEкакconstв модуле. Чуть длиннее, но единообразнее. - Singleton’ы переписываются как handler. Это не цена, а фича — но мигрирующий код придётся переделать.
- Counter / cache / pool требуют явного создания и проброса в
with-блок. Не «само работает», а явный жизненный цикл.
Эволюция
В исходной формулировке D41 пример использовал устаревшие keyword’ы
effect IdGen { ... } и handler counter_id_gen(...) IdGen { ... } —
оба отменены (04-effects.md → D18 — эффект это
обычный type; слово handler не зарезервировано).
В текущем тексте пример переписан как type IdGen { ... } +
обычная функция, возвращающая handler-литерал.
D70. ToStr protocol — REPLACED → D73
⚠️ REPLACED → D73 (2026-05-06). Полное содержание D70 (ToStr protocol, @to_str() метод, free function to_str(v), auto-derive по структуре) удалено для устранения дублирования. Историческая запись об эволюции — в decisions/history/evolution.md → «
ToStrprotocol: D70 формализует to_str()».
Migration map (D70 → D73)
| Старая форма (D70) | Новая форма (D73) |
|---|---|
type ToStr protocol { to_str() -> str } | удалено — protocol больше не нужен |
fn UserId @to_str() -> str => ... | fn str.from(u UserId) -> Self => ... |
to_str(user) | str.from(user) |
user.@to_str() | user.into() (Into[str] авто-выведен из From) |
"${user}" (через to_str) | "${user}" (через str.from, без изменения синтаксиса) |
fn f[T: ToStr](v T) | fn f[T Into[str]](v T) (если bound нужен) |
Auto-derive для встроенных типов и record/sum перенесён из D70 на
str.from: stdlib pre-registers str.from(int), str.from(bool),
str.from(f64), str.from(<any record>), str.from(<any sum>). Newtype
без override делегирует к underlying-типу.
Почему замена: D70 + D73 решали одну задачу разными способами.
Конверсия в str — частный случай конверсии в любой тип. Принцип
«один очевидный путь» (D9) требует единого механизма. См. также D40
(philosophy «один способ»).
D73. From / Into protocol-пара — инфаллибельные конверсии
⛔ РЕТРАКЦИЯ (2026-07-06, решение владельца; переисследование с чистого листа). Протоколы
From[T]/Into[U](вместе с D77TryFrom/TryInto— все четыре) упраздняются. Мотив: их драйверы — растовские, в Nova мертвы. (1) В Rust конверсионные баунды — костыль отсутствия перегрузок; в Nova перегрузки есть (D84), и как generic-баундFrom/Intoв живом std не используется НИ РАЗУ. (2)?у нас не делает From-конверсию ошибок (D325: одинXErrorна домен, конверсия явная). (3) Все 103 вызова.into()исполняли роль «представление в строку» — это осьto_str()(D410), а не передача владения;v.into()был единственным вызовом языка, чей смысл не виден без вывода типа цели. (4) Вслед уходит вся обслуга: blanket identityFrom(синтез per mono), auto-deriveFrom→Into, 4-шаговый resolution,E_BLANKET_IDENTITY_OVERRIDE— минус компиляторная магия (§3 compiler-conventions).Что ОСТАЁТСЯ. (а) Конкретные статики
.from(x)/.try_from(x)— как конвенция имён конструктора-конверсии (D259/D372,try_по R3 D325); в частности оверлоадыstr.from(v)/str.from_debug(v)— точка расширения Display/Debug и интерполяции (D183/D229), они протокола не требуют. (б) Потребляющая передача владения — конкретные именаconsume @into_ЦЕЛЬ()(into_str,into_raw,into_reader; канон D131). (в) Представление —to_str()и семействоto_*(D410). Миграция:[M-d73-d77-retraction-migration](объявления@into() -> strу 6 типов →@to_str(), 103 вызова, снос протоколов из prelude + синтеза из компилятора). Ниже — исторический текст.
Ревизия (2026-07-01, согласовано с D325 Plan 177): D73 и D77 — две отдельные иерархии по модели Rust.
From/Intoстрого инфаллибельны (возвращают T).TryFrom/TryIntoстрого фаллибельны (возвращаютResult[T, E]). Кросс-выводFrom ↔ TryFromзапрещён — семантики различны. Ранее описанное «unified 4-way auto-derive» иFail[E]вfrom/intoотозваны (D325 amends D77: 4-way → 2-way; bare-throws fallible-форма убрана — синтез вemit_c.rsснимается Plan 177 Ф.2b).
Что
Механизм инфаллибельной (guaranteed-success) конверсии значения между типами:
From[T]— protocol со static-методомfrom(v T) -> Self. «Целевой тип знает, как сделать себя из источника. Всегда успешно.»Into[T]— protocol с instance-методом@into() -> T. «Источник знает, как превратиться в целевой. Всегда успешно.»- Blanket-вывод
From → Into— компилятор знает: если типXимеетT.from(v X), тоXавтоматически удовлетворяетInto[T]. Программист пишет толькоFrom—Intoвыводится.
From и Into не могут бросать — они не объявляют Fail[E]
и не возвращают Result. Если конверсия может провалиться,
используй TryFrom / TryInto (D77) — отдельная иерархия.
Программисту доступны две формы вызова из одной From-реализации:
T.from(v X) // static, на целевом типе
v.into() // instance, на источнике (тип цели — из контекста)
В отличие от as (D54) — compile-time numeric/newtype/sum cast без
runtime-кода, — From/Into для семантически нетривиальных
инфаллибельных конверсий (единицы измерения, формат-обмен,
представление в строку — последнее заменяет old D70 ToStr).
Правило
Декларация protocol’ов в prelude
type From[T] protocol {
from(v T) -> Self // static, на целевом типе
}
type Into[T] protocol {
@into() -> T // instance, на источнике
}
Self (D66) — тип, реализующий protocol. From.from — static-метод,
вызывается через точку (D35): Fahrenheit.from(celsius). Into.@into
— instance-метод, через @-нотацию: c.into().
Программист пишет одну сторону пары — компилятор автоматически
выводит другую. Подробности — секция «Into[T] protocol и
автоматический вывод» ниже.
Реализация на пользовательском типе
Программист пишет обычный static-метод (D35):
type Celsius f64
type Fahrenheit f64
fn Fahrenheit.from(c Celsius) -> Self =>
Self((c as f64) * 9.0 / 5.0 + 32.0)
ro f = Fahrenheit.from(Celsius(100.0)) // Fahrenheit(212.0)
Структурно Fahrenheit теперь удовлетворяет From[Celsius] (D53 +
D72) — никаких явных impl блоков.
Несколько From[X] на одном типе через overloading по
параметру (D84):
fn Fahrenheit.from(c Celsius) -> Self => ...
fn Fahrenheit.from(k Kelvin) -> Self => ...
ro f1 = Fahrenheit.from(Celsius(100.0))
ro f2 = Fahrenheit.from(Kelvin(373.15))
Generic-функции с From-bound
fn parse_typed[U From[str]](s str) -> U => U.from(s)
ro n int = parse_typed("42") // если int реализует From[str]
Bound [U From[X]] в generic-сигнатуре требует чтобы конкретный
тип U реализовывал From[X] — структурно, через D72 bound check.
Fallible конверсии — использовать TryFrom (D77)
Если конверсия может не получиться (валидация, парсинг, проверка
диапазона) — НЕ используй From. Вместо этого используй TryFrom
(D77), который возвращает Result[Self, E]. Пример ниже — НЕВЕРНО
через From, ВЕРНО через TryFrom:
// НЕВЕРНО: From не может бросать
// fn str.from(b []u8) Fail[Utf8Error] -> Self { ... }
// ВЕРНО: используй TryFrom для fallible-конверсий
fn str.try_from(b []u8) -> Result[Self, Utf8Error] {
if !is_valid_utf8(b) {
Err(Utf8Error.InvalidByte)
} else {
Ok(/* ... */)
}
}
From/Into строго инфаллибельны. Compiler error если from
декларирует Fail[E] — используй try_from (D77).
Blanket-вывод: From → Into
D73 blanket: если тип T имеет T.from(v X) -> Self,
компилятор автоматически синтезирует X.@into() -> T.
Программист пишет только From-сторону:
type Celsius f64
type Fahrenheit f64
fn Fahrenheit.from(c Celsius) -> Self =>
Self((c as f64) * 9.0 / 5.0 + 32.0)
// Compiler синтезирует автоматически:
// fn Celsius @into() -> Fahrenheit => Fahrenheit.from(@)
ro f1 = Fahrenheit.from(Celsius(100.0)) // from-форма
ro f2 = Celsius(100.0).into() // синтезированная into-форма
Симметрично: если написан @into() — компилятор синтезирует from.
From НЕ синтезирует и не кросс-выводится из TryFrom (D77).
Это разные иерархии с разной семантикой: From гарантирует успех,
TryFrom — нет. Смешение нарушило бы инварианты инфаллибельности.
Когда использовать From:
- Конверсия математически не может провалиться: numeric upcast
(
f64.from(int)), unit ↔ unit (Fahrenheit.from(Celsius)), newtype unwrap (int.from(UserId)), форматирование в строку. - Конверсия по design всегда успешна (инвариант типа гарантирует).
Когда использовать TryFrom (D77) вместо From:
- Парсинг (
str → int,str → UserId). - Валидация диапазона (
int → char, проверка UTF-8). - Любой случай где входные данные могут быть невалидными.
Тонкости:
FromиTryFromнезависимы — один тип может иметь ОБА:Fahrenheit.from(c Celsius)(инфаллибельный unit-conv) иFahrenheit.try_from(s str) -> Result[Self, E](парсинг). Нет ambiguity — разные receiver/параметры.FromНЕ синтезируется изTryFrom— нарушило бы инфаллибельностьFrom. Если нужен blanketTryFromчерез инфаллибельныйFrom— пишем вручную:fn T.try_from(v V) -> Result[Self, Never] => Ok(T.from(v)).
Соотношение с as (D54)
as — compile-time, без runtime-кода:
ro n = 100 as u32 // numeric cast
ro u = 42 as UserId // newtype ↔ underlying
ro code = NotFound as int // sum → int
From — нетривиальная конверсия с runtime-логикой:
ro f = Fahrenheit.from(c) // арифметика
ro u = User.from(json_value) // парсинг
ro m = Money.from(("USD", 100)) // конструирование с валидацией
Граница чёткая: если конверсия выражается одним bit-level/tag-уровнем —
as. Если требует нетривиальной логики, но всегда успешна — from.
Если может провалиться — try_from (D77).
Соотношение с D55 record-coercion
D55 — automatic coercion в позиции с известным целевым типом для record-литералов и sum-конструкторов:
ro u User = { id: 2, name: "Bob" } // D55: anonymous record → User
ro m Maybe[int] = 42 // D55: 42 → Just(42)
D73 — explicit конверсия через method call для произвольных типов.
D55 срабатывает раньше на синтаксическом уровне; From.from — обычный
вызов. Не конфликтуют:
ro f Fahrenheit = Celsius(100.0) // ОШИБКА: D55 не работает —
// Fahrenheit не sum с unary Celsius
ro f = Fahrenheit.from(Celsius(100.0)) // ok: D73 (from-форма)
ro f2 = Celsius(100.0).into() // ok: D73 (into-форма с context)
Into[T] protocol и автоматический вывод
Into[T] — protocol с instance-методом, симметричный к From[T]:
type From[T] protocol {
from(v T) -> Self // static — на целевом типе
}
type Into[T] protocol {
@into() -> T // instance — на источнике
}
Компилятор знает про симметрию From/Into и выводит одно из
другого автоматически. Программист пишет одну реализацию из
пары, вторая выводится без бланket-impl и orphan-rule:
// Программист пишет From — Into выводится автоматически.
type Celsius f64
type Fahrenheit f64
fn Fahrenheit.from(c Celsius) -> Self =>
Self((c as f64) * 9.0 / 5.0 + 32.0)
// Компилятор автоматически синтезирует:
// fn Celsius @into() -> Fahrenheit => Fahrenheit.from(@)
// → Celsius структурно удовлетворяет Into[Fahrenheit].
ro f1 = Fahrenheit.from(Celsius(100.0)) // явная from-форма
ro f2 = Celsius(100.0).into() // авто-выведенная into-форма
Симметрично, если программист пишет @into, компилятор синтезирует
from:
// Программист пишет Into — From выводится автоматически.
type Json record { ... }
type User { id u64, name str }
fn Json @into() -> User =>
User { id: @get_u64("id"), name: @get_str("name") }
// Компилятор автоматически синтезирует:
// fn User.from(v Json) -> Self => v.into()
// → User структурно удовлетворяет From[Json].
ro u1 = json.into() // явная into-форма
ro u2 = User.from(json) // авто-выведенная from-форма
Если написаны обе — обе используются как написаны, авто-вывод
не применяется. Несовпадение результатов между руками
написанными from и into — ответственность программиста (типичный
лит-чек предупреждает, но не запрещает: бывают legitimate случаи
типа explicit-from-bytes vs implicit-into-bytes).
Запрет циклов авто-вывода. Авто-вывод одноуровневый: из From[X]
для T синтезируется Into[T] для X. Не наоборот в той же
итерации (это создало бы цикл). Это значит:
- Программист пишет
From[X]илиInto[X]— оба триггерят авто-вывод парного. - Компилятор не пытается «найти transitively From[Y] через From[X] и From[X→Y]».
Если нужна транзитивность (A → B → C через две промежуточные
конверсии) — программист пишет explicit:
fn C.from(a A) -> Self =>
ro b = B.from(a)
Self.from(b)
Две формы вызова
Конверсия доступна в двух формах, обе из одной реализации:
Fahrenheit.from(Celsius(100.0)) // 1. static method (From[T] protocol)
Celsius(100.0).into() // 2. instance method (Into[T] protocol)
Обе формы эквивалентны. Выбирай по читаемости:
T.from(v)— целевой тип выделен в начале, читается как «build a Fahrenheit from this Celsius». Хорош в выражениях, где тип цели — главная информация.v.into()— короче в method-chains:c.into().log(). Тип цели берётся из контекста (let s str = v.into(), параметр функции, return-type). Без context — компилятор попросит указать тип цели через аннотацию.
Free function into[T, U From[T]](v T) -> U не вводится —
третья форма создавала бы лишний выбор для программиста и LLM
(нарушение D9 «один очевидный путь»). Static T.from уже
покрывает explicit-type case, instance .into() — context-driven.
Разграничение с as и TryFrom
as — compile-time numeric/newtype/sum cast, без runtime-кода (D54)
From/Into — инфаллибельная конверсия с runtime-логикой (D73, этот раздел)
TryFrom/TryInto — фаллибельная конверсия, возвращает Result (D77)
Примеры:
// as — тривиальный cast:
ro n = 100 as u32
ro u = 42 as UserId
// From — нетривиальная инфаллибельная конверсия:
ro f = Fahrenheit.from(Celsius(100.0)) // арифметика, но всегда успешна
ro s = str.from(42) // форматирование, всегда работает
// TryFrom — фаллибельная конверсия:
ro n = int.try_from("42")? // парсинг — может провалиться
ro c = char.try_from(cp)? // range-check — может провалиться
Почему
-
Нетривиальные конверсии — частая нужда. Единицы измерения (
Celsius↔Fahrenheit), парсинг (str→UserId), формат-обмен (Json→User). БезFromкаждый тип придумывает своё имя (Celsius.to_fahrenheit,User.parse_json). Единый protocol даёт общий контракт. -
Замещает старый
ToStr(D70 REPLACED → D73). D70 использовал ту же форму (protocol с одним методом + free function в prelude), но только для конверсии вstr. D73 обобщает паттерн на любые конверсии:From+into. Конверсия вstr— частный случай D73, не отдельный механизм. -
Selfуниверсален (D66).Selfв protocol-методе делает объявление коротким — не нужно повторять имя типа. До D66From[T]потребовал бы typeclass-механизм; с D66 это обычный protocol. -
Bounds (D72) разблокируют generic-функции.
fn parse[U From[str]]до D72 было невозможно. Теперь — естественно. -
Прецедент Rust.
From/Into— самый используемый паттерн в Rust ecosystem. Nova берёт идею (явные конверсии через protocol), адаптирует под свою систему (структурная типизация, без orphan rule, free function вместо blanket-impl). -
AI-friendly. LLM генерирует
Fahrenheit.from(celsius)без обдумывания имени метода. Структурный bound[U From[T]]проверяется compile-time с понятной ошибкой («Barне реализуетFrom[Foo]: missing static methodfrom(v Foo)»).
Что отвергнуто
- Free function
into[T, U From[T]](v T) -> U. Раньше была предложена как третья форма вызова (into[Target](value)). Отвергнута: дублируетT.from(v)(ровно та же ширина и информация), создаёт три формы для одной операции — нарушение D9.T.fromдля explicit-type,v.into()для context-driven — этих двух достаточно. - Только
From[T]безInto[T](как было в первой редакции D73). БезIntomethod-formc.into()была недоступна. ТеперьInto[T]— first-class protocol; method-form работает; компилятор выводит парность изFrom[T]автоматически. - Blanket-impl типа Rust
T: From<U> ⇒ U: Into<T>. В Nova нет orphan rule и нетimplблоков (D42/D53), классический blanket-impl негде. Решение Nova — компилятор синтезирует парный protocol на уровне type-checker’а: если у типа естьfrom, считается что есть и@into(и наоборот). Это сохраняет преимущество Rust (одна реализация → две формы вызова) без orphan-механики. Fromкак trait с default-методами. Безimplблоков и orphan rule концептуально неприменимо. Авто-синтез symmetric’а заменяет.- Implicit conversion в позиции аргумента (Scala 3
Conversion, C++ implicit constructors). Nova: все конверсии явные (as,from, D55 — но D55 only для sum/record-литералов, без method call). @from(v T) -> Selfinstance-метод вместо static.fromэто фабрика — у неё нет существующего инстанса для@. По D35fn Type.methodдля конструкторов / static, что соответствует семантике.asдля нетривиальных конверсий (celsius as Fahrenheit). D54 явно ограничиваетas— compile-time numeric/newtype/sum. Расширять — теряется граница между cheap-cast и expensive-conversion.- Отдельный
ToStrprotocol для конверсии в строку (старая D70). Конверсия вstr— частный случайFrom[X]-механизма. Иметь два механизма для одной задачи нарушает D9. См. D70 v3 «REPLACED → D73» про переход.
Цена
- Без context требуется явный целевой тип.
v.into()на bare-line-position не компилируется — нужно либоlet x T = v.into(), либоT.from(v)с явным типом-prefix’ом. - Multiple
From[X]через overloading по типу параметра (D84) — четыре оси перегрузки и правила ambiguity описаны в D84. Fromот типа из чужого модуля. Без orphan rule — добавляешьfn MyType.from(v ForeignType)где угодно, но реализация живёт в модуле, владеющемMyType(по D47 visibility). Если ни один из типов не «твой» — добавитьFromнельзя без обёртки (newtype). Это сознательное ограничение: предотвращает duplicate conflicting implementations.
Связь
- 02-types.md → D53 — protocol = тип, основа.
- 02-types.md → D66 —
Selfв protocol. - 02-types.md → D72 — bounds для
[U From[T]]. - 03-syntax.md → D35 — static / instance методы;
receiver — любой тип, включая примитивы (
fn str.from(...)). - 03-syntax.md → D54 —
asдля тривиальных cast’ов; D73 покрывает остальное. - 02-types.md → D55 — record/sum coercion; D73 для остальных типов.
- 08-runtime.md → D77 — TryFrom/TryInto — отдельная иерархия для fallible конверсий.
- 08-runtime.md → D70
— REPLACED → D73; конверсия в
strэто частный случай D73. - D26 —
From,Intoв prelude.
Открытые вопросы
Fromдля базовых типов. Stdlib pre-registersstr.from(int),str.from(bool),str.from(f64)(D70-replacement). Должны лиint.from(bool),f64.from(int)etc. — сейчас open вопрос Q-from-builtins.- Auto-derive
From— для newtype можно автоматически (type UserId u64⇒UserId.from(n u64) -> Self)? Сейчас программист пишет вручную. Q-auto-from. From-цепочки. ЕслиB: From[A]иC: From[B], можно ли одно вызовом перейтиA → C? В Rust — нет (single-step). Nova — пока тоже нет, программист пишетC.from(B.from(a)). Q-from-chain.
Эволюция
v1 (первая редакция D73): только From[T] protocol + free function
into[T, U From[T]](v T) -> U. Into отвергнут как «Rust-style
blanket-impl нет, не нужен отдельный protocol». Method-form
value.into() не работала.
v2: добавлен Into[T] protocol с instance-методом @into() -> T.
Компилятор автоматически синтезирует парный protocol — T.from(v X)
written → X.into() -> T synthesized (и наоборот). Три эквивалентные
формы вызова из одной реализации: into[T](v), v.into(),
T.from(v).
v3 (2026-05-06): убрана free function into[T, U](v).
Три формы — это нарушение D9. Остались две: T.from(v) (static,
explicit-type) и v.into() (instance, context-driven). Также:
- D70
ToStrпомечен как REPLACED → D73 — конверсия в строку выражается черезstr.from(v)/v.into()(с context = str). - D35 явно расширен: receiver-тип может быть примитивом
(
fn str.from(int),fn int @to_hex() -> strи т.п.).
Что было невозможно до этого: D73 как механизм требует bound’ы
(D72). До D72 (Q-bounds открыт) From/Into пара была заблокирована.
С D72 разблокирована.
v4 (текущая, 2026-07-01): ревизия по Rust-модели. From/Into —
строго инфаллибельная иерархия. Упразднены:
fromсFail[E]— было расширение, нарушало инвариант «Fromвсегда успешен». Для fallible конверсий — только D77TryFrom.- Unified 4-way синтез (
try_from→ autofrom+ обеinto-формы) — нарушал инвариант инфаллибельностиFrom. FromиTryFromтеперь две независимые иерархии, без кросс-вывода. Один тип может реализовать обе явно — нет ambiguity.
D74. Математические операции на числовых типах — instance-методы
Что
Стандартные математические функции (sin, cos, sqrt, atan2,
hypot, abs, pow, floor, is_finite, и др.) объявляются как
instance-методы через @ на числовых типах (f64, f32, int,
i8-i64, u8-u64), а не как static Math.fn(...) или free function
sin(x). Static-функции остаются только для констант
(f64.PI, f64.NAN) и парсинга (f64.try_parse(s)).
ro r = (x * x + y * y).sqrt()
ro phi = im.atan2(re)
ro dist = a.hypot(b)
ro s = (theta + offset).sin()
ro n = magnitude.abs()
Правило
Полный набор на f64 (prelude)
| Категория | Методы |
|---|---|
| Корни и степени | @sqrt(), @cbrt(), @sqr(), @pow(exp f64), @powi(n int) |
| Тригонометрия | @sin(), @cos(), @tan(), @asin(), @acos(), @atan() |
atan2 (двух-арг) | @atan2(other f64) -> f64 (y.atan2(x)) |
| Гиперболические | @sinh(), @cosh(), @tanh() |
| Экспонента / лог | @exp(), @ln(), @log10(), @log2(), @log(base f64) |
| Норма / расстояние | @abs(), @hypot(other f64) |
| Округление | @floor(), @ceil(), @round(), @trunc(), @fract() |
| Знак / минимум | @signum(), @min(other f64), @max(other f64), @clamp(lo f64, hi f64) |
| Предикаты | @is_finite(), @is_nan(), @is_infinite() |
Аналогичный набор на int (где математически осмысленно):
@abs(), @pow(n int), @signum(), @min(other), @max(other),
@clamp(lo int, hi int), @is_negative(), @is_positive(). Тригонометрия
и логарифмы — только на float-типах.
Static-функции на типе (не методы)
Для констант и операций без естественного receiver’а — обычные static через точку (D35):
f64.PI // константа π
f64.E // константа e
f64.NAN // тихий NaN
f64.INFINITY // +∞
f64.NEG_INFINITY // -∞
f64.MAX // максимальное конечное
f64.MIN_POSITIVE // минимальное положительное
f64.EPSILON // машинная точность
f64.try_parse(s str) -> Option[f64] // парсинг с возможной ошибкой
Парсинг через f64.try_parse(s) дополнен From[str] через D73 —
доступна обе формы:
ro x = f64.try_parse("3.14") // Option[f64]
ro y f64 = f64.from("3.14") // throws Fail[ParseError]
ro z f64 = "2.71".into() // через D73 авто-Into
Двух-аргументные функции
atan2, hypot, min, max, pow, log принимают два аргумента.
Receiver — первый по математической / физической конвенции:
y.atan2(x) // arctangent of y/x — y первый
a.hypot(b) // √(a² + b²) — симметрично, но a первый
base.log(other) // log_base(other)
x.pow(n) // x^n
Это даёт chain-style: dy.atan2(dx).abs() < tolerance.
Соответствующее имя @sqr()
@sqr() — квадрат (x*x). Имя из Pascal (Sqr(x)), короче
squared, согласовано с одноимённым методом на других типах
(например, Complex @sqr()). Для нецелых степеней — @pow(2.0)
или @powi(2).
Почему
-
Согласовано с D35 (03-syntax.md → D35).
@-методы — основной механизм для type-bound функций. Числовые операции — type-bound по определению (зависят от типа:i32.abs()≠f64.abs()в реализации). Использовать static-стиль для одних операций и@для других — нарушение D40 «один способ». -
Chain-friendly формулы. Длинные математические выражения читаются слева направо в «pipeline»-стиле:
ro result = (a*a + b*b).sqrt().abs().min(MAX_VALUE)В static-стиле было бы:
ro result = f64.min(f64.abs(f64.sqrt(a*a + b*b)), MAX_VALUE)Вложенность растёт справа налево, читать тяжелее.
-
Прецедент Rust / Kotlin / Swift. Все три используют instance- методы для математики (
(2.0_f64).sqrt(),theta.cos()). Java/JS/Python со static-стилем (Math.sin(x)) — наследие старой эпохи без object-методов на примитивах. -
Free functions конфликтуют с user-кодом.
sin(x)как глобальная функция занимает имяsin— пользователь не может назвать так свою функцию без shadowing prelude.@sin()живёт в namespace типа, не глобально. -
AI-friendly. LLM пишет
theta.cos()без раздумий «math.cos или Math.cos или просто cos». Один паттерн — один способ вызова.
Что отвергнуто
- Static
Math.sin(x)(Java, JavaScript). Менее читаемо для длинных формул, не chain-friendly, и в Nova нет объекта-namespaceMath(нет static-namespace объектов как в Java). - Free function
sin(x)(C, Python). Захватывает короткие имена в глобальном scope, конфликтует с пользовательскими функциями. - Trait-style
Floatprotocol сsin/cos/...(HaskellFloating, Rustnum_traits::Float). Лишняя indirection, generics с bounds для каждой математической функции усложняют сигнатуры. В Novaf64/f32— отдельные типы, дублирование методов на оба допустимо (как в Rust). - Разные имена для разных размеров (
sinfдля f32,sinдля f64 как в C). Перегрузка по типу receiver’а (D84) даёт одно имя, разные реализации — естественно для языка с типами. @squared()вместо@sqr(). Длиннее без выгоды;sqrимеет Pascal-прецедент и согласовано со стилем коротких имён в Nova (@neg,@inv,@conj,@arg,@rem,@shl).- Только static-функции для констант + instance для операций
через
@(mixed). Принято: константы — static (f64.PI— у значения нет receiver’а), операции —@. Это два разных рода имён (decleration site), не конфликт.
Цена
-
Дублирование методов между f32/f64, потенциально int. Реализация — обычно одна (через builtin / FFI к libm), но объявления повторяются. Это цена отсутствия Float-protocol; терпимо для prelude, который пишется один раз.
-
x.sqrt()дляx < 0возвращаетNaN(IEEE 754) — runtime- surprise. Strict-режим (Fail[NaN]) — отдельная функция@try_sqrt()если понадобится; в base — IEEE без проверок. -
Нет namespace
math. Если пользователь хочетimport math; math.sin(x)— придётся писатьx.sin(). Часть программистов из Python/Java будут удивлены поначалу.
Связь
- D26 — prelude содержит математику как часть числовых типов; D74 уточняет форму объявления.
- 03-syntax.md → D35 —
@-методы как механизм. - 03-syntax.md → D46 — operator overloading
(
@plus,@times, …) дополняет D74 для арифметики. std/runtime/math.nv— auto-generated external-fn декларации всех f64/f32 math методов (Plan 13).- 03-syntax.md → D40 — «один способ» — выбор между static и instance не остаётся на усмотрение программиста.
- D73 — парсинг
чисел через
f64.from(s)/s.into(), согласовано с from/into. - std/math/complex.nv —
использует instance-стиль (
theta.cos(),im.atan2(re),a.hypot(b)) как канонический пример.
Эволюция
Изначально черновик complex.nv (2026-05) использовал static-стиль
f64.cos(theta), f64.atan2(im, re) по аналогии с Java Math.sin.
При обсуждении выявлено что это противоречит D35 (методы — основной
механизм) и плохо читается для математических формул. Все вызовы
переписаны в instance-стиль, и паттерн зафиксирован формальным
D-решением D74.
Math namespace отвергнут (нет static-namespace в Nova, имя Math
конфликтовало бы с пользовательскими типами Math для предметных
областей).
D77. TryFrom / TryInto — фаллибельные конверсии (отдельная иерархия от D73)
⛔ РЕТРАКЦИЯ (2026-07-06, решение владельца) — вместе с D73: протоколы
TryFrom[T, E]/TryInto[U, E]упраздняются (мотивы — в D73-баннере). Остаётсяtry_fromкак имя-конвенция фаллибельного конструктора-конверсии (только при инфаллибельном сиблингеfrom, R3 D325);try_intoуходит вовсе. Ниже — исторический текст.
Ревизия (2026-07-01): D77 — отдельная иерархия от D73 (
From/Into).TryFrom/TryIntoстрого фаллибельны, возвращаютResult[T, E]. Кросс-выводFrom ↔ TryFromзапрещён — разные семантики. Ранее описанное «unified 4-way» и синтезfromизtry_fromотозваны.
Что
Механизм фаллибельной конверсии — когда конверсия может не получиться (парсинг, валидация диапазона, format-check):
TryFrom[T, E]— protocol со static-методомtry_from(v T) -> Result[Self, E]. «Попытка создать Self из T; возможна ошибка типа E».TryInto[T, E]— protocol с instance-методом@try_into() -> Result[T, E]. «Попытка превратить себя в T».- Blanket-вывод
TryFrom → TryInto— еслиT.try_from(v X)определён, компилятор синтезируетX.@try_into() -> Result[T, E]. Программист пишет толькоTryFrom-сторону.
TryFrom и TryInto не могут быть инфаллибельными — они всегда
возвращают Result. Если конверсия гарантированно успешна,
используй From/Into (D73) — отдельная иерархия.
From НЕ синтезируется из TryFrom — нарушило бы инвариант
инфаллибельности From. Программист сам решает какие иерархии
реализовывать на типе.
// Программист пишет TryFrom:
fn u64.try_from(s str) -> Result[Self, ParseIntError] => ...
// Compiler синтезирует только TryInto:
// fn str @try_into() -> Result[u64, ParseIntError] => u64.try_from(@)
// НЕ синтезируется:
// fn u64.from(s str) — нельзя, нарушало бы инфаллибельность From
// Доступные формы вызова:
ro r = u64.try_from("42")? // Result с ?-propagation
ro r: Result[u64, _] = "42".try_into() // instance-форма
ro opt = u64.try_from("42").ok() // Option через Result.ok()
Option-вариант не требует отдельного метода — Result.ok()
из prelude превращает Result в Option. Один универсальный путь.
Правило
Декларация protocol’ов в prelude
type TryFrom[T, E] protocol {
try_from(v T) -> Result[Self, E]
}
type TryInto[T, E] protocol {
@try_into() -> Result[T, E]
}
Self (D66) — реализующий тип. try_from — static-метод (как
обычный from), try_into — instance-метод.
Blanket-вывод: TryFrom → TryInto
D77 blanket: если тип T имеет T.try_from(v X) -> Result[Self, E],
компилятор автоматически синтезирует X.@try_into() -> Result[T, E].
Программист пишет только TryFrom-сторону:
fn char.try_from(cp int) -> Result[Self, CharTryFromError] {
if cp < 0 || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF) {
Err(CharTryFromError)
} else {
Ok(unsafe { cp as char })
}
}
// Compiler синтезирует:
// fn int @try_into() -> Result[char, CharTryFromError] => char.try_from(@)
// Формы вызова:
ro c = char.try_from(65)? // static-форма с ?
ro c Result[char, CharTryFromError] = 65.try_into() // instance-форма
Симметрично: если написан @try_into() — компилятор синтезирует
try_from.
TryFrom и From — независимые иерархии. Один тип может иметь
оба (разные параметры), или только один. Нет обязательной связи:
// Celsius: оба — разные параметры конверсии
fn Fahrenheit.from(c Celsius) -> Self => ... // инфаллибельный (D73)
fn Fahrenheit.try_from(s str) -> Result[Self, ParseError] => ... // фаллибельный (D77)
Error-типы для char-конверсий
Стандартные error-типы для char-конверсий (по модели Rust, unit-types):
type CharTryFromError // int → char: codepoint вне [0, 0x10FFFF] или суррогат
type TryFromCharError // char → u8: codepoint > 0xFF (не Latin-1)
Оба — unit-type без полей: ошибка одна возможная (диапазон).
match не нужен — достаточно ? или .ok().
Когда использовать TryFrom
TryFrom — для любой конверсии которая может провалиться:
// Парсинг из str:
fn u64.try_from(s str) -> Result[Self, ParseIntError] =>
if !is_all_digits(s) {
Err(InvalidDigit { position: 0 })
} else {
Ok(parsed_value)
}
// Range-check int → char:
fn char.try_from(cp int) -> Result[Self, CharTryFromError] => ...
// Range-check char → u8 (только Latin-1 [0..255]):
fn u8.try_from(c char) -> Result[Self, TryFromCharError] => ...
// Validation:
fn Port.try_from(n u16) -> Result[Self, str] =>
if n == 0 { Err("port 0 reserved") } else { Ok(Port(n)) }
Если конверсия инфаллибельна — используй From (D73), не TryFrom.
D67 ?-оператор
try_from возвращает Result[T, E] → ? применим:
// Функция возвращает Result, использует try_from + ?:
fn parse_pair(s str) -> Result[(u64, u64), ParseIntError] {
ro parts = s.split(",")
ro a = u64.try_from(parts[0])? // ? на Result (D67/D85)
ro b = u64.try_from(parts[1])?
Ok((a, b))
}
From.from возвращает T (не Result) → ? неприменим и не нужен.
Option через Result.ok()
Отдельный try_parse / from_str_or_null / similar не вводится.
Если нужен Option — Result.ok() в prelude:
fn Result[T, E] @ok() -> Option[T] => match @ {
Ok(v) => Some(v)
Err(_) => None
}
// Использование:
ro opt = u64.try_from(s).ok() // Option[u64]
match u64.try_from(s).ok() {
Some(n) => n
None => default_value
}
Прецедент Rust: s.parse::<u64>().ok() → Option<u64>. Один
универсальный путь, не требует отдельного именования.
Почему
-
Параллельная структура с D73.
TryFrom → TryIntoblanket зеркалитFrom → Into(D73). Программист видит один и тот же паттерн для обоих иерархий. Разница только в signature:TvsResult[T, E]. -
Две отдельные иерархии — чёткая семантика.
From= «всегда работает»,TryFrom= «может не работать». Кросс-вывод стёр бы эту границу: еслиFromможно получить изTryFrom, тоFromуже не гарантирует успех (ведьTryFromможет вернутьErr). -
Стандартизованное имя
try_from. Без D77 разные библиотеки использовалиtry_parse,parse_or_err,validateи т.д. С D77 — единое имя, какfromстандартно для инфаллибельных. -
Прецедент Rust:
From/IntoиTryFrom/TryInto— два отдельных trait иерархии вstd.Fromне выводится изTryFrom. Nova повторяет эту модель. -
Option получается бесплатно через
Result.ok(). Не нужны_or_null-suffix имена (Kotlin),init?(Swift). Одинtry_from— доступны?,ok(),unwrap_or(). -
AI-friendly. LLM знает Rust-модель —
try_fromдля фаллибельного,fromдля инфаллибельного. Прямое соответствие.
Что отвергнуто
u64.try_parse(s) -> Option[u64]— отдельный Option-вариант.try_from(...).ok()универсальнее; два имени нарушают D9.u64.parse(s)— отдельное имя для парсинга. Парсинг — частный случайtry_from(str).OrNull-suffix (Kotlin):toIntOrNull. Не масштабируется.- Unified 4-way синтез (предыдущая редакция D73/D77):
try_from→ автоматическийfrom Fail[E]. Отвергнуто — нарушало инвариант инфаллибельностиFrom. Если написанtry_from,fromможет провалиться →Fromуже не гарантирует успех. FromсFail[E]— отвергнуто той же причиной.From.fromстрого инфаллибелен.
Цена
-
Две иерархии вместо одной. Программист выбирает явно: fallible (
TryFrom) или infallible (From). Нет «умного» синтеза. Это цена чёткости — зато семантика каждой иерархии однозначна. -
Fromдля типа сTryFrom— явно. Если тип хочет оба:char.from(b byte) -> Self(infallible,byteвсегда < 128 ASCII) Иchar.try_from(cp int) -> Result[Self, str](fallible, range-check) — пишет оба явно. Нет magic. -
Overloading по параметру работает как обычно. Если у
u64естьtry_from(str)иtry_from(f64)— резолвится по типу аргумента D84. -
Selfв Result корректен по D66 в method-контексте.
Связь
- D73 — инфаллибельная пара From/Into, отдельная иерархия. D77 — независимая, не расширение.
- D67 —
?-оператор; применим кResult(try_from(s)?);Fromне возвращает Result,?к нему не применяется. - D72 — bounds:
[U TryFrom[T, E]]для generic-функций fallible-конверсии. - D26 —
TryFrom,TryInto,Result,Optionв prelude.Result.ok() -> Option[T]— стандартный метод для перевода. - D30 — конвенция имён ошибок
(
Parse<TypeName>Error); не меняется. - std/data/semver.nv —
использует
u64.try_parse(legacy имя) — должно мигрировать наu64.try_fromпосле принятия D77.
Открытые вопросы
- Auto-derive для newtype?
type UserId u64— должны ли автоматически бытьUserId.from(n u64)иUserId.try_from(s str)? Сейчас — программист пишет вручную. Q-auto-from осталось открытым из D73, расширяется на D77. fromцепочки (A → B → C) — ни D73, ни D77 не вводят транзитивность. Программист пишетC.from(B.from(a)). Q-from-chain.TryFromдля одного и того жеTс разнымиE? Пример:u64.try_from(s str) -> Result[Self, ParseIntError]иu64.try_from(s str) -> Result[Self, ValidateError]— отличаются толькоE. По D84 ось 3 (overloading по типу результата) формально это поддерживает, но требует context для дисамбигуации (let r Result[u64, ParseIntError] = u64.try_from(s)). Если контекста нет — compile error «cannot resolve overload». Альтернатива на call-site без контекста —enum-объединение ошибок (type AnyError | A | B) или разные имена. Q-tryfrom-multi-error.
Эволюция
До D77 в первой реализации std/data/semver.nv использовался
u64.try_parse(s) -> Option[u64] — отдельное имя для Option-варианта
парсинга. При обсуждении выявилось три проблемы:
- Ad-hoc имя — каждая stdlib-либа могла использовать своё
(
try_parse,parse_opt,from_str_or_null). - Дублирование с
from—try_parseэто «fromминус throw, плюс Option». Семантически избыточно. - Прецедент Rust —
TryFromпарный кFromрешает ту же задачу унифицированно.
D77 формализует: одно имя try_from для Result-варианта, бланкет
TryFrom → TryInto (компилятор синтезирует try_into из написанного
try_from). Option получается через Result.ok(). try_parse
отвергается как избыточное.
Backward-compat: try_parse в существующих файлах (semver.nv) —
переименовывается на try_from. Общая семантика не меняется.
v2 (текущая, 2026-07-01): ревизия по Rust-модели. Упразднены:
- Unified 4-way синтез (
try_from→ autofrom+ всеinto-формы) — нарушал инвариант инфаллибельностиFrom. - «Семантическое равенство
from/try_from» — теперь это разные иерархии.from≠try_from.unwrap()по семантике; это разные контракты. TryFromтеперь самостоятельная иерархия, не расширение D73. Blanket толькоTryFrom → TryInto, без кросс-синтеза с From/Into.
D76. Mem эффект — runtime introspection для leak/growth тестов
Status: active. Реализовано в bootstrap’е (2026-05-06). Тесты:
nova_tests/runtime/memory_growth.nv.
Что
Built-in эффект Mem даёт Nova-коду доступ к runtime-счётчикам
аллокаций. Цель — regression detection: тест запоминает
Mem.alloc_count() до и после горячего кода и assert’ит, что прирост
остался в разумном бюджете. Если codegen начнёт генерировать в N раз
больше аллокаций (баг типа “alloc-per-iter увеличился на порядок”),
тест поймает это сразу.
Операции
Mem.alloc_count() -> int // total nova_alloc since gc_init/reset
Mem.free_count() -> int // total frees (plain malloc backend → 0)
Mem.live() -> int // alloc_count - free_count
Mem.reset() -> () // zero stats counters (for per-test isolation)
Числа — это счётчики вызовов, не байты. Этого достаточно для поимки регрессий “1 alloc на итерацию стало 10”.
Семантика
Mempre-registered как built-in эффект (какTime,Fail). Compiler не требуетMemв сигнатуре функции — это ambient capability (D11 / D62-style).- Нет user-handler’а: в отличие от
TimeиFail, операцииMemне имеют vtable; они эмитируются прямо вNova_Mem_*inline-функции, которые ходят к runtime-counters. Причина: эти операции должны быть наблюдаемыми с очень низкими накладными расходами — vtable добавляет лишний indirect call который сам бы изменил alloc-pattern. И смысла переопределять их нет (это не business effect — это runtime-факт).
Реализация
compiler-codegen/nova_rt/alloc.h— runtime-функцииnova_gc_alloc_count,nova_gc_free_count,nova_gc_live_count,nova_gc_reset_stats. Доступны во всех allocator-backend’ах.compiler-codegen/nova_rt/alloc.c(Phase-0 plain malloc) — считаетnova_alloccalls;free_countвсегда 0 (releaseno-op). Достаточно для growth-rate тестов.compiler-codegen/nova_rt/effects.h—Nova_Mem_*inline- обёртки.compiler-codegen/src/codegen/emit_c.rs—effect_schemaspre-populated сMemschema; standard effect-call dispatch работает (Mem.live()→Nova_Mem_live()).
Bootstrap-ограничения
- Plain-malloc backend (default):
free_countвсегда 0,live==alloc_count. Это значит leak-тесты могут только измерять growth rate, не “осталось ли что-то живое”. Когда подключим Boehm GC (alloc_boehm.c) или RC (alloc_rc.c) — free_count станет осмысленным, тесты можно расширить. - Нет per-allocation type info.
alloc_count— счётчик всехnova_alloccalls без разбивки по типам. Production-runtime возможно даст breakdown (records, arrays, fiber stacks). - Не thread-safe в multi-threaded backend’е (счётчики не atomic). На bootstrap single-threaded fiber-runtime это OK.
Связь
- D7 — runtime modes;
Memдоступен во всех режимах. - D11 — pre-registered effects pattern.
- 05-memory.md → D6 — managed-heap design;
Mem— observability над ним.
Что отвергнуто
- Free function
mem_alloc_count()— нарушает D9 («одна идиома для одной задачи»). Effect-форма даёт ровно столько же выразительности и согласована с Time. - Bytes-tracking в bootstrap — требует instrumentированного allocator (overhead). Counts достаточно для regression-detection.
D81. assert(cond) vs debug_assert(cond) — build-mode семантика
Что
Два уровня assertion’ов в prelude:
assert(cond)— always runtime, проверяется во всех режимах сборки (debug/release/JIT/AOT). Failure → panic (D13).debug_assert(cond)— debug-only, в release-сборке полностью отбрасывается компилятором (zero cost).
Третий уровень — формальные контракты requires/ensures
(D24) — отдельный механизм, не путать.
Правило
Декларация в prelude
// always runtime — production invariants
fn assert(cond bool) -> ()
// debug-only — hot-path / sanity checks
fn debug_assert(cond bool) -> ()
Сигнатуры идентичны на уровне типов; разница — в семантике релиза. Обе — обычные prelude-функции (не keyword’ы), вызываются со скобками как любой fn-call (см. также syntax.md секция «Тестирование без моков»).
Формат failure (Plan 140.1 — см. D13 AMEND). При нарушении печатается location-first диагностика, общая с контрактами (D24):
<file>:<line>: assert failed: <expr>, а с опциональным сообщением (assert(cond, "msg"), D84) —<file>:<line>: assert failed: <msg> (<expr>).<file>:<line>:авто-проставляется codegen’ом;debug_assertиспользует тот же текст (отличие — debug-only-гейт ниже, не сообщение).
Семантика по build-mode
| Form | Compile-time check | Debug runtime | Release runtime | Use-case |
|---|---|---|---|---|
assert(cond) | нет | check | check | production invariants |
debug_assert(cond) | нет | check | no-op | hot-path / sanity |
requires/ensures (D24) | SMT где возможно | check rest | no-op | formal contracts |
Примеры использования
// Production invariant — всегда проверяется
fn divide(a int, b int) -> int {
assert(b != 0) // ВСЕГДА runtime, даже в release
a / b
}
// Hot-path — release не платит за проверку
fn fast_lookup(arr []int, idx int) -> int {
debug_assert(idx >= 0 && idx < arr.len()) // только в debug
arr[idx] // unchecked в release
}
// Формальный контракт — compile-time где возможно, runtime fallback
fn sqrt(x f64) -> f64
requires x >= 0.0
ensures result >= 0.0
=> ...
Build-mode mechanics в bootstrap
Bootstrap (D71) не различает debug/release — все три режима
(D7) одинаковы, всегда
checked. debug_assert в bootstrap’е — синоним assert (тот же
runtime check, готовность к production-семантике).
Production-runtime добавит:
- preprocessor-style
#ifdef NOVA_DEBUGдля C-backend, или - codegen-флаг для no-op generation в release-сборке.
Build-mode влияет на performance, не на семантику программы:
assert всегда работает; debug_assert — только performance в release.
Это согласовано с D7 принципом «один язык — три режима».
Почему assert = always runtime (не Java/C-style no-op)
-
AI-friendly: одна семантика. LLM генерирует
assert(...)ожидая, что invariant держится. Если в release он silent — это тихий bug class (Java pre-1.4 classic). -
Безопасность. «Production runs without your invariants» — известная проблема C/Java/Python: программист в курсе своих asserts только в debug, в release они исчезают без следа.
-
Прецедент Rust/Swift.
assert!в Rust always runtime;debug_assert!для debug-only. Swift аналогично:assertdebug-only,preconditionalways runtime — но Nova инвертирует defaults (более безопасный — короткое имя). -
Согласовано с D24. Если программист хочет zero-cost проверку с compile-time гарантией — пишет
requires(D24 contract). Если просто debug-time hint —debug_assert.assert— strong invariant, всегда работает. -
D13 (panic vs effects).
assertfailure = panic = fiber dies. Это «hardware/math сбой» класс, не business error. По D13 такое не должно зависеть от build-mode.
Что отвергнуто
assertno-op в release (C/Java/Python style). Тихие bug’и в production — главная причина отказа.assertкак keyword без скобок (Rust macro / Javaassertexpression). Закрыто в spec sweep 2026-05-07: assert — обычная fn-call, со скобками. Один способ для одной задачи (D40).- Только один уровень (
assertalways runtime). Hot-path use-case реален; безdebug_assertпрограммисты пишутif (DEBUG) { ... }ручками. Лучше дать canonical-форму. - Только один уровень (
assertdebug-only). Невозможно выразить production invariant. Java pre-1.4 опыт показывает что это anti-pattern.
Связь
- D7 — три режима компиляции; D81 уточняет, как build-mode влияет на assert-семантику.
- D13 — assert failure = panic, не Fail-эффект.
- D24 —
requires/ensuresконтракты; D81 определяет три уровня safety:assert<debug_assert<contracts. Plan 140.1: assert и контракты делят единый location-first формат failure (см. D13 AMEND). - D26 — prelude содержит обе функции (
assert,debug_assert). - spec/syntax.md — секция «Тестирование без моков» уточняет, что
assert(cond)обязательно со скобками (fn-call).
Эволюция
До 2026-05-07 spec упоминал assert неявно — в syntax.md как
«встроенный оператор» (без скобок), в D26 prelude как функцию (со
скобками). Bootstrap-парсер принимал только со скобками.
spec-assert-syntax sweep 2026-05-07 канонизировал форму
assert(cond) — функция из prelude, обязательно со скобками.
D81 закрывает оставшийся вопрос — семантика в release.
Принята модель Rust (assert! always runtime + debug_assert!
debug-only). До D81 spec не различал assert/debug_assert,
bootstrap имел только always-runtime nova_assert без build-mode
разделения. После D81: prelude содержит обе функции; production-
runtime реализует zero-cost debug_assert в release; bootstrap
оставляет debug_assert как alias assert до production.
D82. external fn — функции с runtime-implementation
Что
external fn — модификатор функции-декларации, означающий что тело
функции реализовано в runtime (C-коде nova_rt/), а не на Nova.
Декларация даёт сигнатуру и имя; codegen lookup’ит C-функцию по
имени в hard-coded таблице.
external применяется к функциям (этот D-block) и к типам
(D126, Plan 62.D.bis, 2026-05-18). Один и тот же keyword, два валидных
позиционирования. Built-in opaque-типы (StringBuilder, WriteBuffer,
ReadBuffer) теперь имеют formal Nova-side declaration через
external type в std/prelude/collections.nv — раньше (до 62.D.bis)
существовали как «known-by-name» (без formal declaration).
Правило
Грамматика
fn-decl = ['export'] ['external'] 'fn' [receiver] name [generic-params]
[params] [effects] ['->' return-type] [body | ';']
Порядок modifiers строгий: export первым, external вторым. Body
у external fn должен отсутствовать (никакого => или { ... }),
иначе compile error «external function cannot have a body».
Примеры
// Public external static
export external fn StringBuilder.new() -> Self
// Public external instance, mutating
export external fn StringBuilder mut @append(s str) -> ()
// Private external (используется внутри runtime/builtins.nv module'а)
external fn Nova_intrinsic_unreachable() -> never
Связь с D26 prelude
Built-in opaque-типы из D26 (StringBuilder, WriteBuffer,
ReadBuffer) имеют type declaration через external type
(D126,
std/prelude/collections.nv) + methods через external fn
(этот D-block, std/runtime/<name>.nv). Связь декларация ↔ methods
— по receiver-type name.
// std/prelude/collections.nv (Plan 62.D.bis, 2026-05-18)
module std.prelude.collections
export external type StringBuilder // D126
export external type WriteBuffer // D126
export external type ReadBuffer // D126
// std/runtime/string_builder.nv (auto-generated, Plan 13 Ф.8)
module std.runtime.string_builder
export external fn StringBuilder.new() -> Self
export external fn StringBuilder.with_capacity(n int) -> Self
export external fn StringBuilder mut @append(s str) -> Self
// ... остальные методы
Self в receiver-context для external — StringBuilder (имя
содержащего receiver-type’а). Те же правила, что для обычных
fn-декл.
Связь с D5/D47 видимостью
export external fn — публичная: имя видно из других модулей.
external fn без export — модуль-private. Те же правила, что для
обычных fn-декл. external ортогонален export.
Связь с будущим FFI
external fn — для функций, реализованных в Nova-runtime
(nova_rt/*.h/.c). Для функций, импортируемых из сторонних
C-библиотек (libc, OS-libs), будет отдельный keyword
extern("C") (Q-ffi, не реализуется сейчас). Семантика разная:
| Keyword | Реализация | C-name | Разрешён программисту |
|---|---|---|---|
external fn | Nova-runtime (nova_rt/) | Nova_<Type>_<...> mangled | нет (только в std.runtime.*) |
extern("C") fn (TBD) | сторонний C/lib | as-is | да (FFI) |
Программистский Nova-код не пишет external fn. Этот keyword —
экспозиционный: только модули в std.runtime.* имеют право его
использовать. Компилятор отклоняет external fn в любом другом
namespace’е.
Mangling и dispatch
Codegen не хранит список external-функций. Source of truth — это
std/runtime/builtins.nv. Codegen знает только правила mangling
и для каждой external fn декларации выводит C-name детерминированно:
| Nova-form | C-name |
|---|---|
T.method(...) static | Nova_T_static_method(...) |
t.method(...) instance | Nova_T_method_method(t, ...) |
t.method(...) mut instance | Nova_T_method_method(t, ...) (тот же mangling) |
Имена параметров в C-сигнатуре генерируются из позиций (arg0,
arg1, …); типы маппятся по canonical Nova→C таблице (int →
nova_int, str → nova_str, u8 → uint8_t, u32 →
uint32_t, &T → Nova_T*, mut T → Nova_T*, …).
Этот mapping архитектурно идентичен registry built-in conversions (D73 + Plan 08 Ф.2). Один механизм lookup’а.
Validation: builtins.nv — single source of truth
Подписи external-функций живут только в std/runtime/builtins.nv.
Никакой дублирующей таблицы в Rust-коде codegen’а быть не должно;
если есть — это bug, и расхождение между .nv-декларацией и Rust-
таблицей приведёт к runtime-крашу или silent UB.
Сигнатура в этом разделе понимается полно — это весь contract вызова, не только имя и типы параметров:
| Компонент | Используется для |
|---|---|
Имя метода (write_u32_be) | C-name через mangling |
Receiver-type + mut-флаг (WriteBuffer mut) | Первый параметр C-функции (Nova_WriteBuffer*), prefix mangling |
| Параметры (имена + типы, в порядке) | Остальные параметры C-функции; для overload — также часть mangling (Plan 11 Ф.3) |
| Return-type | C-return type; для auto-derive — целевой тип synthesized обёртки |
Effects (Fail[E], etc.) | Дополнительный *err-параметр в C-сигнатуре + control-flow эмиссии |
Любой из этих компонентов, если расходится между .nv-декларацией и
runtime-реализацией компилятора, отлавливается самим Nova-
компилятором при загрузке builtins.nv (раздел Diagnostics ниже),
не на стадии C-toolchain’а. В частности return-type входит в
проверку: если в builtins.nv ... -> u32, а компилятор знает
что runtime возвращает uint64_t — Nova-error «signature
mismatch».
Pipeline:
- Компилятор парсит
std/runtime/builtins.nvкак обычный Nova- модуль. Каждаяexport external fn ...-декларация даёт AST-узел с полной сигнатурой (имя, receiver, params, return, effects). - Codegen применяет mangling rules → C-name + C-prototype:
void Nova_WriteBuffer_method_write_u32_be(Nova_WriteBuffer*, uint32_t); - Codegen сверяет каждую декларацию со своим внутренним реестром реализованных runtime-функций (компилятор и runtime — один версионируемый артефакт, см. Diagnostics ниже).
- Если совпадает — codegen эмитит C-prototype в сгенерированный
header для линковки с
nova_rt/. - Если не совпадает (нет реализации, расходится сигнатура) → Nova compile error до запуска C-toolchain’а.
Что это даёт:
- Программист добавляет
export external fn WriteBuffer mut @write_u64_le(v u64) -> ()в builtins.nv → если компилятор уже поддерживаетNova_WriteBuffer_method_write_u64_le(в bundled runtime), декларация принимается; иначе — Nova-error с понятной диагностикой. - AI-генерируемый код для расширения runtime API — два места правки: builtins.nv (Nova-side) + nova_rt/*.c (C-side). Компилятор валидирует, что они согласованы.
Что это запрещает:
- Hard-coded списки методов конкретных opaque-типов в codegen’е
(сейчас
record_schemas.insert("StringBuilder", ...)+ method dispatch таблицы) — должны быть удалены или сведены к чтению AST builtins.nv. Q-codegen-builtins-cleanup, Plan 12 Ф.5. - «Скрытые» external-функции, известные только codegen’у, без
декларации в builtins.nv. Если codegen эмитит вызов
Nova_X_method_y— соответствующаяexternal fn X.@y(...)декларация обязана существовать в builtins.nv (или другом модуле вstd.runtime.*).
Diagnostics: компилятор сам валидирует, без C-toolchain
Nova компилируется в C, который потом обрабатывается C-toolchain
(cc/clang/MSVC). У C-toolchain есть свой линкер, но мы не
полагаемся на его ошибки для пользовательской диагностики:
mangled C-имя в undefined reference to Nova_WriteBuffer_method_X
не понятно тому, кто пишет на Nova.
Вместо этого Nova-компилятор сам знает, какие external-функции
реализованы в bundled runtime (nova_rt/). Runtime версионируется
вместе с компилятором; компилятор всегда знает свой runtime.
builtins.nv — проекция этого знания в Nova: декларации, которые
компилятор валидирует против собственного внутреннего реестра.
Расхождение выдаётся как Nova compile error до запуска cc.
Таксономия:
| Случай | Когда | Диагностика |
|---|---|---|
User вызывает несуществующий метод opaque-типа (sb.unknown()) | type-check | Nova: no method 'unknown' on StringBuilder. Available: append, len, capacity, ... |
external fn X.@y в builtins.nv ссылается на функцию, не реализованную в runtime | при загрузке builtins.nv в codegen | Nova: external fn 'StringBuilder.@y' not implemented in runtime. Either remove from std/runtime/builtins.nv or add Nova_StringBuilder_method_y to nova_rt/string_builder.c |
| Сигнатура в builtins.nv не совпадает с реализацией компилятора (тип параметра, return-type, effects) | при загрузке builtins.nv | Nova: signature mismatch for 'StringBuilder.@append': declared 'fn (s str) -> ()', runtime expects 'fn (s str) -> int' |
| Codegen эмитит вызов внешней функции, не объявленной в builtins.nv | bug в компиляторе | internal compile error: compiler bug: emitted call to undeclared external 'X.@y'. Не должно случаться у пользователя; если случилось — bug-report |
User объявил auto-derived форму (@try_read_X рядом с @read_X) | при загрузке builtins.nv | Nova: '@try_read_X' is auto-derived from '@read_X' (D77 Fail↔Result); remove from std/runtime/builtins.nv |
C-toolchain никогда не должен быть первым, кто заметит проблему.
Если он всё-таки выдаёт undefined reference — это bug в Nova-
компиляторе: либо реестр был неполным, либо валидация не сработала.
Что не валидируется на этом уровне:
- Семантика реализации (правильно ли
write_u32_beпишет big-endian байты) — runtime tests, не compile-time check. - Memory ownership / lifetime / aliasing — это контракт типа (mut, &T), линкер его не видит.
Почему
Зачем нужен external keyword
- Документация stdlib API. Программист (и AI) видя
external fn StringBuilder.new()понимает: тело реализовано runtime’ом, не Nova. Не нужно искать вnova_rt/где определён. - Compile-time validation. Без
externalкомпилятор не знает, что функция без тела должна искаться в C-runtime — попытается эмитить empty body и упадёт. Сexternal— явный contract. - AI-friendly. LLM-генерируемый код для stdlib имеет canonical
форму:
export external fn .... Шаблонная подстановка тривиальна. - Будущая совместимость с FFI. Когда появится
extern("C")для сторонних libs, два keyword’а различаются однозначно.
Почему не intrinsic или builtin
intrinsic— занят понятием compile-time intrinsic (Rust-styleintrinsics::transmute). Для Nova таких пока нет, но имя зарезервируем.builtin— слишком общее.int/strтоже builtin (D26), но они типы, не функции.external— точное слово: «реализация во внешнем (по отношению к Nova-source) контексте — runtime/C». Прецеденты: OCamlexternal, Dartexternal, Kotlinexternal.
Почему не extern
D30 фиксирует «полные слова, не сокращения». external — full word.
extern — сокращение (как в C/Rust). Мы выбираем full form.
Что отвергнуто
- Без keyword’а — компилятор сам решает по имени модуля. Магия:
программист не видит чего ожидать, AI генерирует boilerplate-
typeдекларации. builtin fn— конфликт с понятием built-in типа.@externalатрибут вместо keyword’а. Атрибуты в Nova зарезервированы для тестов / dev-tools (Q-attributes). Modifier-форма единообразна сexport/mut.external type— закрыто 2026-05-18 в D126. Изначально для три built-in (StringBuilder/WriteBuffer/ReadBuffer); future user-defined opaque типы (Channel, mmap’ed Region) — тот же D126 mechanism + relaxation whitelist’а. Plan 62.D.bis (Ф.1–Ф.6, 2026-05-18) — реализация в bootstrap.- Codegen — single source (вариант A). Сигнатуры жили бы в Rust-таблицах; builtins.nv был бы только документацией, а codegen cross-check’ал бы при чтении. Отвергнуто: дублирование (два места правки на каждую новую runtime-функцию), риск тихого расхождения если cross-check где-то пропущен, недружелюбно к AI (надо править Rust-код codegen’а).
- Hybrid: builtins.nv для типов + codegen хранит mangling. Тоже отвергнуто — оставляет Rust-таблицу как «второй источник», даже если меньшего объёма. Принят чистый вариант B: builtins.nv — единый источник; codegen знает только правила mangling.
Связь
- D5 / D47 —
exportmodifier;external— ортогональный второй modifier. - D26 — prelude содержит StringBuilder/WriteBuffer/ReadBuffer
как built-in opaque-типы; декларации API — через
external fn. - D30 — naming convention;
external— full word. - D52 — kind-tokens (
type/effect/protocol); D82 не добавляет нового kind-token’а. - D54 —
as/isдля конверсий; не пересекается. - D73 — From/Into registry; D82 использует тот же dispatch-механизм для external-функций.
- D126 —
type-аналог D82 (
external typeдля opaque-типов с runtime backing). Один keywordexternal, два валидных позиционирования.
Эволюция
До 2026-05-08 spec фиксировал Buffer как единый тип (Q-buffer) —
text+binary mixed. В разговоре про endianness-методы выявилось
семантическое смешение: add_str рядом с add_u32_le несогласовано.
Plan 04 (зафиксирован 2026-05-08) — split на три типа
(StringBuilder / WriteBuffer / ReadBuffer) + новый keyword
external для документирования stdlib runtime-функций. До D82 такие
функции декларировались как обычные fn без тела (компилятор
special-case’ил по имени receiver’а — fragile).
Bootstrap status (2026-05-08)
- ✅ Спека: D82 закрыт (этот блок). Validation rule (builtins.nv —
single source of truth) добавлен 2026-05-08 после обсуждения
signature mismatch для
WriteBuffer.@write_u32_be. - ⏳ Lexer:
KwExternaltoken — TBD (Plan 04 Этап 2). - ⏳ Parser:
externalmodifier вparse_fn_decl— TBD. - ⏳ AST:
is_external: boolflag — TBD. - ⏳ Codegen: чтение external-деклараций из AST builtins.nv, применение mangling rules, эмиссия C-prototype’ов в header — TBD (Plan 04 Этап 2).
- ⏳ Codegen cleanup: удалить hard-coded
record_schemas.insert(...)и method dispatch-таблицы для StringBuilder/WriteBuffer/ReadBuffer. Должны замениться чтением builtins.nv. Это ломает silent расхождения, которые сейчас существуют (Q-codegen-builtins-cleanup). - ⏳ Runtime:
nova_rt/string_builder.h/write_buffer.h/read_buffer.h— TBD. Реализации обязаны матчить builtins.nv по C-name + сигнатуре; иначе linker error.
Plan 13: расширение projection на str/math + декомпозиция (2026-05-08)
После Plan 13 Ф.8 в std/runtime/ нет ни одного handwritten файла.
builtins.nv ❌ REMOVED — декомпозирован на per-type auto-generated файлы:
| Что | Файл (auto-gen) |
|---|---|
| str API (UTF-8 операции) | std/runtime/string.nv |
| f64/f32 math (D74 instance-методы) | std/runtime/math.nv |
char/str interop (str.from(c char)) | std/runtime/char.nv |
| StringBuilder API | std/runtime/string_builder.nv |
| WriteBuffer API | std/runtime/write_buffer.nv |
| ReadBuffer API | std/runtime/read_buffer.nv |
Источник истины — compiler-codegen/src/codegen/runtime_registry.rs (Rust):
~157 entries (~17 str + ~50 math f64+f32 + ~50 ReadBuffer fail+try
форм + ~20 WriteBuffer numeric × LE/BE + StringBuilder + char).
Команда regen_runtime.bat (или .\regen_runtime.ps1, или прямой
nova-codegen emit-runtime-stubs) генерирует все 6 .nv файлов;
manual edit запрещён (CI guard через --check).
ExternalRegistry в codegen загружает 4 .nv файла через include_str!
(string_builder, write_buffer, read_buffer, char) — единый registry для
opaque-types dispatch (Plan 12). string.nv/math.nv пока загружаются
emit-runtime-stubs только; codegen-side dispatch для str/math остаётся
через legacy special-cases (Plan 13 Ф.4 deferred).
См. docs/plans/13-runtime-stdlib-and-autogen.md.
D109. Встроенные методы примитивных типов — hash, eq, ord
Что
Компилятор автоматически предоставляет следующие методы для стандартных
примитивных типов без явных деклараций в .nv файлах:
| Метод | Возврат | Применимо |
|---|---|---|
hash() -> u64 | беззнаковый 64-bit хеш | int, bool, f64, char, u8, str |
eq(Self) -> bool | равенство | int, bool, f64, char, u8, str |
lt(Self) -> bool | строго меньше | int, f64, char, u8, str |
le(Self) -> bool | меньше или равно | int, f64, char, u8, str |
gt(Self) -> bool | строго больше | int, f64, char, u8, str |
ge(Self) -> bool | больше или равно | int, f64, char, u8, str |
Эти методы нужны для использования примитивов как ключей в
HashMap[K, V Hash] и других коллекциях с protocol bounds (D72).
Семантика
hash:
int/char/u8— FNV-1a по 8 байтам значения (nova_int_hash).bool— 0 или 1 (nova_bool_hash).f64— FNV-1a по битовому представлению (nova_f64_hash; -0.0 и 0.0 хешируются по-разному — bootstrap ограничение, production fix V2).str— FNV-1a по байтам контента (nova_str_hash; уже реализован, объявлен явно вstd/runtime/string.nv).
eq: сравнение по значению. f64.eq использует == (NaN != NaN по IEEE 754).
lt/le/gt/ge: лексикографически для str, по значению для остальных.
Для bool эти методы не предоставляются (нет естественного порядка).
Как реализовано
C-функции в nova_rt.h:
nova_int_hash(nova_int) -> nova_intnova_bool_hash(nova_bool) -> nova_intnova_f64_hash(nova_f64) -> nova_int(возвратnova_int= int64_t, хранит битовое значение u64)
eq/lt/le/gt/ge для nova_int/nova_bool/nova_f64 — inline C-операторы
==, <, <=, >, >= (без отдельных C-функций).
Codegen: prim_builtin_method(c_ty, method) в emit_c.rs перехватывает
метод-вызов до общего resolver’а и эмитит нужный код.
Что отвергнуто
- Явные декларации в prelude.nv — лишний boilerplate, нет спасения от расхождения между .nv и runtime impl. Codegen-уровень: единый источник правды.
Ordprotocol bound — структурный bound (lt/le/gt/geметоды) V2; для D109 достаточно auto-dispatch без формальногоOrdprotocol.
D109 amend (Plan 126, 2026-06-05) — auto-derive для пользовательских типов
Q-резолв «Хеш / eq / clone для пользовательских типов» (ранее «отвергнуто
до V2»): теперь реализован через #impl(P) annotation (D186) + codegen-side
synthesis. Аналог Rust #[derive(Hash, PartialEq, Clone, Ord, Debug)].
Supported built-in protocols (auto-derive eligible, Plan 126 Ф.3):
| Protocol | Метод | Стратегия synth |
|---|---|---|
| Equal | @equal(other) -> bool | memberwise && chain |
| Hash | @hash() -> u64 | XOR + rotate FxHash-style combine |
| Clone | @clone() -> Self (D230 NEW) | record literal с .clone() per field |
| Compare | @compare(other) -> int | lexicographic if-chain |
| Display | @display(sb) -> () | sb.append("Name { f: v, ... }") chain |
Auto-derive triggers (см. Plan 126 Ф.4 — verify_impl_protocols):
- Type lists protocol в
#impl(P)list (D186). - Protocol — один из known built-in (выше);
is_builtin_protocol()filter. - Type не предоставляет explicit method (
fn T @method(...)НЕ найден). - Field eligibility: каждое поле либо primitive, либо имеет
#impl(P), либо имеет explicitfn FieldType @method— иначеE_AUTO_DERIVE_FIELD_LACKS_PROTOCOL.
Heap-record == override (Plan 126 — изменение прежней семантики):
До Plan 126 на heap-record’е a == b всегда давало identity-eq (сравнение
pointer’ов GC-heap’а). После Plan 126:
- Heap-record с
#impl(Equal)→a == bвызывает synthesized@equal(other)(memberwise структурное равенство). - Heap-record без
#impl(Equal)→ identity-eq (unchanged) — обратная совместимость со всем существующим кодом.
Value-record / NamedTuple (Plan 120/124) — тот же mechanism: #impl(P)
opt-in. Tuple resolver routes == через synthesized @equal без
identity-eq fallback’а (tuple никогда не heap-allocated).
Sum-type — V1 placeholder: identity-comparison для Equal,
@compare returns 0, @clone returns @, @display emits только type name.
Rich variant-tag + payload recursion — followup
[M-126-sum-{equal,hash,clone,compare,fmt}-rich].
Cycle detection (Plan 126 Ф.2):
- Visited set
(type_name, protocol)вAutoDeriveCtx. - Cyclic type
type A { b B }; type B { a A }+#impl(Clone)→E_AUTO_DERIVE_CYCLE.
Auto-derive error codes (registered Plan 126 Ф.4):
| Code | Trigger |
|---|---|
E_AUTO_DERIVE_CYCLE | Cyclic recursion через fields не терминируется |
E_AUTO_DERIVE_FIELD_LACKS_PROTOCOL | Field type не implement требуемый protocol |
E_AUTO_DERIVE_UNKNOWN_PROTOCOL | Protocol не в built-in list (auto-derive только для built-in) |
E_AUTO_DERIVE_UNSUPPORTED_KIND | Type kind (Newtype/Alias/Effect/Protocol/Opaque) не поддерживает derive |
Runtime dispatch via method_table (Plan 126.2, 2026-06-06):
Plan 126 V1 (выше) синтезировал auto-derived методы и проверял их на
type-check уровне, но runtime dispatch не был замкнут — emitted body не
регистрировался для method resolution. Plan 126.2 закрывает это:
synthesized методы (@clone/@equal/@hash/@compare/@display)
регистрируются в method_table (Ф.1) и эмитятся как C-функции
Nova_<T>_method_<name> (Ф.2). Теперь a.clone() / a == b / etc.
действительно резолвятся в synthesized body на runtime, а не только
проверяются type-checker’ом. Подробности — D230 §Runtime
codegen.
D109 amend (Plan 141, 2026-06-11) — structural eq field-by-field по типу, float IEEE, no memcmp на композитах
Status: ACTIVE. Триггер: [M-codegen-memcmp-equality-float-padding] —
встроенное равенство кортежей и sum-вариантов эмитилось через побитовый
memcmp, что неверно для float-полей, padding-байт и вложенных композитов.
Контракт structural-==. Когда == сводится к структурному сравнению —
анонимный кортеж ((a, b)), sum-вариант (tag + payload), или synthesized
@equal (memberwise &&-chain выше) — равенство вычисляется поле-за-полем
по C-типу поля, НЕ побитовым blob-сравнением:
| Тип поля | Равенство |
|---|---|
скаляр (int/bool/char/byte/u*/i*) | (l == r) |
float (f64/f32) | (l == r) — IEEE 754: -0.0 == +0.0 → true, NaN == NaN → false |
str | nova_str_eq(l, r) (по контенту, не по pointer) |
кортеж (_NovaTuple…) | рекурсивно field-by-field по элементной схеме |
record (Nova_X*) | structural: synthesized/explicit @equal (или @compare == 0), иначе рекурсивно по полям — НЕ pointer-identity |
sum (Nova_Y*) | рекурсивно tag + payload-поля (тот же контракт) |
Option[T] (NovaOpt_…) | делегирует nova_opt_eq_<inner> (composite payload — тоже field-by-field) |
Почему memcmp неверен для композитов (soundness-баг, теперь исправлен):
- float:
-0.0и+0.0имеют разные биты →memcmpдавалfalse(а IEEE=== равны); бит-идентичныйNaN→memcmpдавалtrue(а IEEE=== не равны). Любой кортеж/sum с float-полем сравнивался неверно. - padding: mixed-size поля оставляют indeterminate padding-байты → два
семантически равных значения могли дать
memcmp != 0. - nested composite: вложенный record/sum сравнивался по pointer-битам (identity), а вложенный кортеж как struct → C-compile-error.
memcmp оставлен ТОЛЬКО для:
[]u8byte-blob (@compare, D141 — byte-equality = намеренная семантика для байтовых буферов;a == b ⇔ a.compare(b) == 0);- сравнение строковых литералов / имён (interrupt / bench-name match);
- fallback для by-value struct без известной схемы (
NovaRes_…/NovaArray_…— сохраняет прежнее поведение, держит codegen total).
memcmp никогда не применяется к кортежам / records / sum / str / Option
композитам.
Рекурсия и циклы. Helper рекурсивен по конечной структурной схеме
(tuple-элементы, sum-payload, record-поля). Для non-cyclic типов схема конечна.
Прямая рекурсия record-cycle (type A { b B }; type B { a A } с structural eq)
ограничена depth-cap (V1 guard) — точная семантика equality для циклических
record-графов вынесена в Q32 (out-of-scope V1).
Согласованность с D109 примитивами. f64.eq уже определён через ==
(IEEE 754, см. §Семантика выше). Plan 141 распространяет ту же IEEE-семантику
на float-поля внутри композитов — раньше она терялась под memcmp.
Реализация: общий emit_field_eq(c_type, l, r) helper в
emit_c.rs, диспетчеризующий по C-типу; вызывается из tuple-eq, sum-eq
(per-payload-field) и обоих Option-eq генераторов. Подробности — Plan 141 Ф.1.
Cross-refs: Plan 141 —
home plan; D183 / D237 — Equal/@equal
протокол; D141 —
[]u8 @compare byte-blob; Q32 — record-cycle eq.
Что отвергнуто (Plan 126 design):
#derive(P)keyword sugar — повторяет#impl(P)без новой семантики; всё работает через единый#impl(...)annotation (D186).- Implicit auto-derive на value-record по умолчанию (Rust
Copyanalog) — опасно для семантических типов (Money,UserId); explicit opt-in forces awareness.
Cross-refs:
- D186 —
#impl(P)annotation — foundation. - D230 — Clone protocol — NEW (Plan 126 Ф.1).
- Plan 126 Ф.3 — per-protocol synthesizer bodies.
- docs/auto-derive-guide.md — user guide.
Связь
- D72 — Generic bounds —
Hashтребуетhash() -> u64. - D26 — stdlib — примитивные методы часть runtime stdlib.
- docs/plans/48-closures-in-generics.md → Ф.8 — реализация built-in dispatch для примитивов.
- Plan 126 — Auto-derive протоколов — auto-derive для пользовательских типов.
D366. Edition-versioned prelude resolver
Что
[package].edition = "<X.Y>" в nova.toml — pin prelude content на
конкретный snapshot. Resolver выбирает std/prelude/<sanitized(<X.Y>)>.nv
вместо rolling std/prelude.nv facade.
Sanitization rules (manifest::sanitize_edition):
- Не-alphanumeric ASCII →
_(e.g.2026.05→2026_05). - Digit-leading prefix →
e(e.g.2026_05→e2026_05), потому что Nova-identifier должен начинаться с буквы /_. - Empty input → empty output (caller-side responsibility).
Examples:
edition = "2026.05"→std/prelude/e2026_05.nvedition = "nightly"→std/prelude/nightly.nvedition = "v1-beta"→std/prelude/v1_beta.nv
Fallback chain (resolver-side):
- Edition pin:
std/prelude/<sanitized>.nv— если файл существует, import path =["std", "prelude", "<sanitized>"]. - Rolling facade:
std/prelude.nv— backward-compat default (нет edition в манифесте, или edition pin не найден).
Soft-fail: edition specified, но файла нет → silently fall back на rolling facade (не блокируем build, user может указать pin без файла для будущего расширения).
Правило
# nova.toml
[package]
name = "myapp"
edition = "2026.05"
→ Все модули в myapp auto-импортируют std/prelude/e2026_05.nv
вместо rolling std/prelude.nv. Будущие изменения rolling facade
(новые re-export’ы, signature drift) НЕ затрагивают packages с
pinned edition — они видят фиксированный snapshot.
Зачем
- Industry-standard pinning. Rust
edition = "2021", Gogo 1.21, Swift packageswift-tools-version— stability через explicit pin. - Migration safety. Maintainer’ы prelude могут add’ить re-export’ы в rolling facade без breaking changes для users с pinned edition.
- AI-friendly. LLM-генерируемый код с stable edition → reproducible.
Что отвергнуто
- Universal pin через one global rolling. Без edition future изменения prelude (например new re-export shadowing user-type) ломают существующие packages. Edition pin даёт opt-out из rolling.
- Multi-edition support в одном workspace. Каждый package имеет одну edition; transitive deps могут иметь свои edition’ы независимо.
- Auto-migrate workflow. Edition bump — explicit decision package
owner’а (как Rust
cargo fix --edition). Tooling может предложить, но не auto-apply.
Связь
- D26 — stdlib и prelude — base prelude content.
- D78 — package tooling —
nova.tomlschema. - Plan 62.F.bis Ф.1 — implementation.
D125. Prelude shadow warning lint
Что
W_PRELUDE_SHADOW — structured lint warning эмитимый когда
user-declaration top-level имени shadow’ит prelude-imported name
(D26, D29). User-declaration wins (silent shadow), warning сигнализирует
о потенциальной AI/training confusion.
Эмиттер: lints::lint_prelude_shadow (lints.rs::lint_module
включает его в общий проход). LintWarning имеет:
rule = "W_PRELUDE_SHADOW"(grep’абельно из CLI и дляEXPECT_COMPILE_WARNINGmatching вnova test).diag.messageначинается с[W_PRELUDE_SHADOW]tag (для rendering черезdiag.render—ruleполе не leak’ит в текст автоматически).- Actionable hint:
qualify as std.prelude.<sub>.<name>илиadd allow_prelude_shadow / no_prelude / partial_prelude(...).
Visibility detection: lints::collect_prelude_visibility — shared
helper между types::check_module (silent classify duplicates как
W_PRELUDE_SHADOW vs codegen-only merge) и lint_prelude_shadow
(structured warning emission). 2-pass:
- Names declared directly в
std/prelude/*.nvpeer files (включаяstd/prelude.nvfacade себя). - Names re-exported через
export import X.{A, B as C}selective list.
Suppress mechanisms:
- Module-level clause
module X allow_prelude_shadow— silences ALL W_PRELUDE_SHADOW warnings в модуле. См. 07-modules.md → Allow prelude shadow. - Prelude self-modules (
std.prelude.*,<pkg>.prelude.*) — automatically skipped (они LEGITIMATELY declare prelude names). - Item-level suppress (
#[allow(prelude_shadow)] type Foo) — DEFERRED (требует generic attribute parser; пока не приоритет).
Правило
module myapp.dsl
// Conflict: PRELUDE_VERSION auto-imported via std/prelude.nv;
// user-decl wins (codegen skips merged duplicate via Const-skip path),
// W_PRELUDE_SHADOW emitted.
const PRELUDE_VERSION int = 42 // → warning
module myapp.dsl allow_prelude_shadow
// Same conflict, suppress'нут (warning не эмитится).
const PRELUDE_VERSION int = 42 // → silent
Зачем
- AI/training clarity. LLM-generated code часто случайно shadow’ит
prelude names (e.g. local
type Result { ... }). Warning catches it early; explicit suppress сигнализирует intentional override. - Migration safety. Если будущий prelude bump добавит новое имя
(e.g.
From/Intoв Plan 62.E), existing user-decl с тем же именем получит warning — обнаружение early-stage. - Не error. Sometimes shadowing намеренно (DSL слой, embedded); warning + suppress даёт user-выбор vs hard block.
Что отвергнуто
- Hard error. Per D5 / D26: user wins на conflict — shadowing допустим как backward-compat механизм. Error блокировал бы legitimate DSL use-cases.
- Codegen-only merge как warning. Когда prelude impl-merge подтягивает
type не visible в user код (e.g. internal struct prelude’а), и user
re-declares то же имя — это НЕ shadow, потому что user не “видел”
prelude name. Lint фильтрует через
prelude_visible_namesvsmerged_from_imports_names. - Per-name allowlist.
allow_prelude_shadow = ["Option"]— слишком fine-grained, добавляет complexity без явного use-case. Module-level bool clause достаточен.
Связь
- D26 — stdlib и prelude — prelude scope rules.
- D29 — модули и импорты — name resolution.
- 07-modules.md → Allow prelude shadow — clause syntax.
- Plan 62.F.bis Ф.2 — implementation.
D141. Примитивы доступа к памяти — byte_at / bulk slice-операции
Plan 90. Принято 2026-05-22. Plan 90.1 amend. 2026-05-27 — extend-family API +
copy_fromhardening. Plan 91 amend (2026-05-30). Rename for semantic clarity:extend_from → append,insert_from → insert. Старые имена удалены — breaking change. Family по_from-суффиксу распался: append/insert — semantic verbs из append/insert classes;copy_fromостаётся (Rust-stylecopy_from_slice, теперь isolated имя). Migration: replace_all для всех call-sites. LintW_VIEW_EXTEND_DETACHсохранён (детектит grow-методыappend/insert/reserve). Plan 90 followup amend (2026-06-01). Два уточнения runtime: (1)fill(v)— memset fast-path для single-byteT(u8/i8):sizeof(T) == 1ветка →memset(data, v, len). Per-instantiation compile-time DCE: для wider T (int/f64/ptr) остаётся scalar loop с auto-vectorization potential под-O2. (2)append_zero(n int)— extend bynzero-initialized elements. Полиморфно поTчерезmemset(_, 0, n*sizeof(T))— zero bytes = valid zero-init для всех primitives +nova_ptr(NULL) +bool(false). Use-case: encoders/framers (reserve write-window под length-prefix или padding с последующим патчингом через index-assignmentarr[i] = v).W_VIEW_EXTEND_DETACHlint срабатывает наappend_zero(grow-метод).
Что
Минимальный набор безопасных примитивов доступа к памяти, чтобы
алгоритмы рантайма и stdlib (str-методы, буферы, парсеры) выражались на
Nova без лишних аллокаций и без ухода в external fn. Сырые указатели и
unsafe-режим не вводятся — Nova остаётся языком без указателей
(D6).
Правило
str.byte_at — O(1) доступ к байту строки:
fn str @byte_at(i int) -> u8
Byte-indexed (не codepoint). Выход за границы (i < 0 || i >= byte_len)
— panic (D13).
Неустранимый примитив для data-dependent байтовых алгоритмов (лексер,
find, trim).
Bulk slice-операции []T:
fn []T mut @copy_from(src []T) // memmove (overlap-safe)
fn []T mut @copy_within(src_from int, dst_from int, len int) // memmove (overlap-safe)
fn []T mut @fill(v T) // заполнение
copy_from— строгое копирование:src.len != dst.len→panic«length mismatch». Всегда memmove (overlap-safe, паритет Go; см. «Overlap safety» ниже). Truncation use-case — через slicing:dst[..n].copy_from(src[..n])(D144). Breaking change (Plan 90.1): прежняя молчаливая truncation (srcкорочеdst→ хвост не тронут) заменена на panic. Migration:dst[..n].copy_from(src[..n]).copy_within— копирование внутри одного среза, корректно при перекрытии диапазонов (семантикаmemmove); диапазон вне границ →panic.fill— записываетvво все элементы. Perf (Plan 90 followup 2026-06-01): single-byteT(u8/i8) → memset fast-path; widerT→ scalar loop (auto-vectorizable). Compile-time selection черезsizeof(T)constant per macro instantiation, DCE убирает мёртвую ветку из output.- Определены для любого
T(копирование element-storage корректно при non-moving GC, D6).
Append/insert/reserve API (Plan 90.1, renamed Plan 91 2026-05-30)
fn []T mut @append(src []T) // bulk add to end, grows
fn []T mut @insert(i int, src []T) // bulk insert at position, grows
fn []T mut @reserve(extra int) // preallocate hint
fn []T mut @append_zero(n int) // extend by n zero-init elements, grows (Plan 90 followup 2026-06-01)
append(src) — bulk append элементов src в конец dst, с ростом:
- Рост: если
dst.len + src.len > dst.cap→ new_cap = max(2 × dst.cap, needed). Паритетpush(2x doubling, [D27]). - memmove: safe для self-append (
dst.append(dst)) —src.lenснапшотится до realloc; после realloc memmove работает со старым буфером (Boehm GC удерживает до сборки). Test:append_self.nv. - View detach: при realloc существующие slice-view’ы от
dstстановятся dangling. LintW_VIEW_EXTEND_DETACHпредупреждает; suppress через#allow(view_extend_detach).
insert(i, src) — bulk вставка src в позицию i (элемент, не байт):
- Диапазон
i:[0, dst.len]— включаяdst.len(append-at-end ≡append).i < 0 || i > dst.len→ panic. - Рост: та же стратегия, что
append. - In-place path (без realloc): memmove хвоста
[i, len)вправо наsrc.lenслотов; затем memmovesrcв образовавшуюся дыру (обрабатывает overlap). - Alloc path: prefix
[0, i)+ дыра + tail[i, len)— три memcpy без overlap.
reserve(extra) — hint на preallocate extra дополнительных слотов:
extra < 0→ panic.extra == 0→ no-op.dst.len + extra ≤ dst.cap→ no-op O(1). Иначе рост ≥dst.len + extra.dst.lenне изменяется.- View detach: при realloc — тот же lint.
append_zero(n) (Plan 90 followup, 2026-06-01) — extend by n
zero-init элементов:
n < 0→ panic«append_zero: n must be >= 0».n == 0→ no-op.- Рост: та же 2x стратегия что у
append/insert/reserve(new_cap = max(2 × cap, new_len)). - Tail новой памяти инициализируется через
memset(data+old_len, 0, n*sizeof(T))— полиморфно поT, в отличие отfill(где memset только для single-byteT). - Zero bytes — valid zero-init для primitives (
int/u8/f64→ 0),nova_ptr(NULL),bool(false). Для compound value types (Plan 120 named tuples) zero-init соответствует семантике “все поля zero” — допустимое начальное состояние при условии что все поля имеют zero-default representation (IEEE 754 +0.0 для floats, NULL для optional). За пределами этого набора (например, type с invariant’ом «non-null» полем) — UB: zero-init нарушит invariant. dst.len += n. View detach: при realloc —W_VIEW_EXTEND_DETACH.- Use-case — encoders/framers: reserve write-window для
length-prefix или padding, затем patch через index-assignment:
До followup’а аналог былmut buf []u8 = [] buf.append_zero(2) // reserve 2 байта под length-prefix buf.append(payload) buf[0] = (payload.len() >> 8) as u8 buf[1] = (payload.len() & 0xff) as u8buf.append([0, 0])(литерал-аллокация) или explicit loop — оба неэргономично.
Naming rationale (Plan 91 rename, 2026-05-30)
Старые имена extend_from/insert_from уродовали family-pattern: глагол не
обнажал semantic class. После rename:
- append-family:
push(v)+append(src)+append_zero(n)— единая семантика “add to end” (vединичный,srcсрез,nzero-init слотов) - insert-family:
insert(i, src)— overload с будущимinsert(i, v T) - overwrite-family:
copy_from(src)+fill(v)— equal-len mutation - move-family:
copy_within(...)— internal copy
_from-суффикс изначально склеивал ops по dispatch-детали (“берёт source array”),
а не по semantic class. Распад family + переход на semantic verbs — net win для
discoverability и API symmetry.
Migration было mechanical replace_all: ~30 файлов (compiler + std + 12 fixtures).
Truncation idiom
// Новая строгая семантика copy_from:
dst.copy_from(src) // panic если src.len != dst.len
// Idiom для частичного копирования (была старая silent-truncation):
dst[..n].copy_from(src[..n]) // explicit prefix slice — Plan 96 D144
dst[..n] — slice NovaArray_T с len = cap = n (D-cap-len, D144);
copy_from на нём требует src[..n].len == n → panic-safe.
Overlap safety
Nova всегда использует memmove для array bulk-операций (не memcpy):
copy_from: memmove → safe если dst и src overlap (через view в тот же буфер).copy_within: явно memmove, документировано.extend_from/insert_from: memmove дляsrc-копирования → safe при view-аргументе.
Паритет Go (copy() + append() — memmove/safe). Отличие от Rust
copy_from_slice (UB при overlap, нет borrow-check): Nova overlap-safe
by default без lifetime annotations.
W_VIEW_EXTEND_DETACH lint (Plan 90.1, names updated Plan 91)
ro view = parent[1..4]
parent.append([5, 6, 7]) // W_VIEW_EXTEND_DETACH: view may dangle after realloc
Lint срабатывает если в той же функции после let view = parent[a..b]
вызывается grow-метод на parent (append / insert / reserve /
append_zero).
После realloc view.data указывает на стёртую память (Boehm GC удерживает
до сборки, но lifetime семантически опасен).
Lint-name остался W_VIEW_EXTEND_DETACH (концепт “view extend → detach”
остаётся valid term-of-art независимо от method-naming).
Suppress через #allow(view_extend_detach) перед module-декларацией.
Параллельный lint — W_VIEW_PUSH_DETACH (Plan 96.1, D144).
compare — один примитив сравнения []u8:
fn []u8 @compare(other []u8) -> int // <0 / 0 / >0, лексикографически
memcmp-класс (byte-wise, word/SIMD-скорость). Равенство — частный
случай: a == b ⇔ a.compare(b) == 0; оператор == и
lt/le/gt/ge выводятся из compare. Отдельного bytes_equal
нет. Определён только для []u8: для multi-byte T побайтовое
сравнение endianness-зависимо.
Почему
- Self-hosting и stdlib на Nova. Без примитивов доступа к памяти
str-методы и буферы вынужденно остаются C-кодом либо аллоцируют
(
slice/bytes). Примитивы переносят алгоритмы в Nova, оставляя в C лишь неустранимый минимум. - Безопасность сохранена. Все примитивы bounds-checked; нет сырых
указателей, нет
unsafe-keyword. Паритет с Go (copy()/bytes— safe, безunsafe), Rust (slice::copy_*/[u8]::cmp— safe), TS (typed arrays — указателей нет вовсе). FFI-граница закрытаexternal fn(D82) иexternal type(D126) — сырой указатель в систему типов Nova не попадает. compare— один примитив. memcmp возвращает порядок; равенство — его zero-case. Дублировать в два примитива (equal+compare) преждевременно (если профайл покажет — fast-path добавится позже, модель Gobytes.Equal).- Extend-family (Plan 90.1): паритет с Go
append(dst, src...), Rustextend_from_slice/Vec::reserve, TSpush(...arr)/splice, KotlinaddAll, JavaArrayList.addAll. Единственный grow-path до 90.1 —for x in src { dst.push(x) }(O(N) virtual calls);extend_from— bulk memmove, намного быстрее для primitive[]T. copy_fromhardening (Plan 90.1): молчаливая truncation — silent bug factory. Ни один из 5 эталонных языков не имеет такой гибрид «panic на длинный + silent на короткий». Strict equal-only + memmove — лучший баланс корректности и overlap-safety.
Связь
- D6 — память managed, без указателей.
- D13 — panic — семантика выхода за границы.
- D27 §1659 —
[]Tpush cap-growth — та же 2x стратегия, чтоextend_from/insert_from/reserve. - D82 —
external fn, D126 —external type: FFI-граница без сырых указателей. - D117 — size-accessors
[]T/str— соседняя группа методов built-in-типов. - D144 — slices
arr[a..b]— truncation idiom черезdst[..n].copy_from(...). - Plan 90 — baseline реализация.
- Plan 90.1 — extend-family + copy_from hardening.
- Plan 96.1 — W_VIEW_PUSH_DETACH (параллельный lint).
- Ориентиры: Go
copy()/bytes/append, Rustslice::copy_*/extend_from_slice/Vec::reserve, TS typed arrays/splice, KotlincopyInto/addAll, Javaarraycopy/ArrayList.addAll.
D173. std/net — Async TCP/UDP socket stdlib via libuv
Status: ✅ implemented (Plan 83.12, 2026-05-27). Merge
05f7e77592c.
Что
Nova предоставляет async-transparent сетевой stdlib std/net/ на базе
libuv (uv_tcp_t, uv_udp_t). Все операции блокируют fiber (не OS thread)
через park/wake D93, выглядят синхронно в коде пользователя.
Модуль состоит из четырёх файлов:
| Файл | Содержимое |
|---|---|
std/net/addr.nv | IpAddr, SocketAddr |
std/net/error.nv | NetError — типизированные сетевые ошибки |
std/net/tcp.nv | TcpListener, TcpStream |
std/net/udp.nv | UdpSocket |
Правила
1. Типы адресов
type IpAddr = | V4(u8, u8, u8, u8) | V6(str)
namespace SocketAddr {
fn new(ip IpAddr, port u16) -> SocketAddr
fn loopback(port u16) -> SocketAddr // 127.0.0.1:port
fn any(port u16) -> SocketAddr // 0.0.0.0:port
fn parse(s str) -> Result[SocketAddr, NetError]
}
str.from(SocketAddr) возвращает "ip:port" (human-readable).
2. TcpListener
namespace TcpListener {
fn bind(addr SocketAddr) -> Result[TcpListener, NetError]
}
type TcpListener {
fn accept(self) -> Result[TcpStream, NetError] // parks fiber until connection
fn local_port(self) -> u16
fn close(self)
}
bind(addr) — OS TCP bind + listen. local_port() корректен после bind
(для port=0 возвращает OS-assigned port).
accept() использует nova_sched_park_until(pred: pending_conns > 0) —
spurious wake безопасен, re-checks predicate (см. D93).
3. TcpStream — lifecycle state machine
IDLE ──connect──▶ CONNECTING ──cb──▶ CONNECTED
│
close()
▼
CLOSING ──close_cb──▶ CLOSED
Состояния: IDLE=0 / CONNECTING=1 / CONNECTED=2 / CLOSING=3 / CLOSED=4.
CAS-переходы атомарны. write() и read_bytes() проверяют stage ≥ CLOSING
перед операцией → возвращают Err("stream closing").
namespace TcpStream {
fn connect(addr SocketAddr) -> Result[TcpStream, NetError] // parks fiber
}
type TcpStream {
fn write(self, data str) -> Result[(), NetError]
fn read_bytes(self, max_len int) -> Result[str, NetError]
fn local_addr(self) -> SocketAddr
fn remote_addr(self) -> SocketAddr
fn close(self)
}
EOF semantics: uv_read_cb с nread == UV_EOF → read_bytes() возвращает
Ok("") (пустая строка). Чистое закрытие соединения = success, не error.
4. UdpSocket
namespace UdpSocket {
fn bind(addr SocketAddr) -> Result[UdpSocket, NetError]
}
type UdpSocket {
fn send_to(self, data str, addr SocketAddr) -> Result[(), NetError]
fn recv_from(self, max_len int) -> Result[(str, SocketAddr), NetError]
fn local_port(self) -> u16
fn close(self)
}
recv_from — parks fiber до получения датаграммы; возвращает (data, sender_addr).
5. NetError
type NetError =
| ConnectionRefused
| ConnectionReset
| TimedOut
| AddrInUse
| AddrNotAvailable
| Other(str)
Все Result[T, NetError] возвращаемые типы используют typed errors —
match-exhaustive на стороне пользователя.
6. Thread-affinity invariant
libuv handles (uv_tcp_t, uv_udp_t) должны закрываться на том же OS thread,
на котором они созданы. В M:N режиме fiber может мигрировать между workers.
Решение: nova_loop_defer_close(handle) — enqueue request в
NovaDeferredCloseQueue текущего loop; worker деqueue и вызывает uv_close
на своём thread. В AUTOARM=0 (single thread) — direct uv_close.
7. Park/wake контракт (D93-compliant)
- Caller fiber:
nova_sched_register_pending(scope, slot)→nova_sched_park(scope, slot) - libuv callback (
_tcp_connect_cb,_tcp_read_cb,_tcp_write_cb, …): устанавливает result поля →nova_sched_wake(scope, slot) - Fiber resume: читает result, возвращает
Ok(...)илиErr(...)
Stop callback для cancel: uv_read_stop + deferred uv_close → close_cb → wake.
Почему
- Fiber-transparent async — пользователь пишет последовательный код (как Go),
без
async/awaitключевых слов (в отличие от Rust/Tokio). - libuv — уже в runtime (Plan 22), cross-platform (Linux/Windows/macOS), production-grade event loop.
- D93 park/wake — единый контракт для всех блокирующих операций (Time.sleep, Channel, net). Не дублируется логика.
- Typed errors —
NetErrorsum type vs stringly-typed (Goerr.Error()) позволяет exhaustive match.
Связь
- D93 — park/wake contract — основа impl.
- D91 — Channel — аналогичный park/wake pattern.
- Plan 83.12 — реализация.
- Plan 83.3 — Blocking effect для DNS/sync IO.
- Plan 91 — std/net co-planned в 0.1.
- Ориентиры: Go
net.Listen/net.Dial, Rusttokio::net::TcpListener.
D177. str Nova-body dispatch — Plan 54 Ф.2 extension
Plan 91 Ф.2.5 — 2026-05-28
Что
Пять методов str (parse_int_radix, pad_left, pad_right, repeat,
replace) реализованы как Nova-body методы в std/runtime/string.nv и
диспатчатся через механизм Plan 54 Ф.2 (Nova method dispatch) вместо
C bootstrap shim’ов. Auto-available через std.prelude re-export — явный
import std.runtime.string.{pad_end} не требуется.
Amend (Plan 91.18 Ф.2 П2, 2026-06-19):
pad_left/pad_rightпереименованы вpad_start/pad_end. Новые имена согласуются с CSS/JS padding convention (start/end вместо left/right). Устаревшие имена удалены.
Правило
1. Nova-body декларации (std/runtime/string.nv)
// Parse int с указанной base (2..36). None при ошибке.
export fn str @parse_int_radix(radix int) -> Option[int] { ... }
// Pad до width codepoints слева символом fill.
export fn str @pad_start(width int, fill char) -> str { ... }
// Pad до width codepoints справа символом fill.
export fn str @pad_end(width int, fill char) -> str { ... }
// Повторить строку n раз (n ≤ 0 → "").
export fn str @repeat(n int) -> str { ... }
// Заменить все вхождения from на to.
export fn str @replace(from str, to str) -> str { ... }
Модуль std/runtime/string.nv использует #no_prelude для разрыва
циклического импорта prelude → string → prelude.
2. Prelude auto-availability
// std/prelude.nv
export import std.runtime.string.{parse_int_radix, pad_start, pad_end, repeat, replace}
Все пять методов доступны в любом пользовательском модуле без явного
import — аналогично остальным prelude items (D26).
3. Dispatch mechanism (Plan 54 Ф.2)
Codegen диспатчит obj.method(...) для obj: str через Plan 54 Ф.2:
obj_ty = "nova_str"→prim_nova_name = "str"- Look up
method_overloads[("str", method)] - Фильтр
!is_external— Nova-body методы получаютis_external = false - Генерируется вызов
Nova_str_method_<name>(obj, args...)
External fn методы (@len, @eq, @split, …) имеют is_external = true
и не перехватываются Plan 54 Ф.2 — они продолжают диспатчиться через
str_method_to_rt → прямые C функции (без изменения поведения).
4. Generated C names
| Nova method | C function |
|---|---|
str @parse_int_radix(radix int) | Nova_str_method_parse_int_radix |
str @pad_start(width int, fill char) | Nova_str_method_pad_start |
str @pad_end(width int, fill char) | Nova_str_method_pad_end |
str @repeat(n int) | Nova_str_method_repeat |
str @replace(from str, to str) | Nova_str_method_replace |
Функции генерируются при каждой компиляции — встроены в выходной .c файл
как static функции (аналогично всем Nova-body методам).
5. Removed C shims
nova_str_parse_int_radix удалён из nova_rt/array.h.
nova_str_pad_start, nova_str_pad_end, nova_str_repeat, nova_str_replace
оставлены в nova_rt/string_builder.h для внешних потребителей,
но codegen их больше не вызывает.
6. consume-method alias (nova_rt/string_builder.h)
Nova-body методы pad_start, pad_end, repeat вызывают
StringBuilder.into() — consume-метод (export external fn StringBuilder consume @into()).
Codegen генерирует Nova_StringBuilder_consume_into(sb) (D164 ABI, Plan 100.6).
Добавлен inline alias в string_builder.h:
static inline nova_str Nova_StringBuilder_consume_into(Nova_StringBuilder* b) {
return Nova_StringBuilder_method_into(b);
}
Почему
- Единый механизм — аналогично
fn int @seconds() -> Duration(Plan 91 Ф.1) Nova-body методы на примитивных типах позволяют писать стандартную библиотеку на Nova, а не на C. - Cycle-safe —
#no_preludeвstd/runtime/string.nv+ explicit importsstd.prelude.core.{Option, None, Some}иstd.prelude.collections.{StringBuilder}разрывают циклprelude → string → prelude. - Single source of truth — логика
replace(concat-loop вместо[]str.join) написана один раз на Nova; C bootstrap shim’ы удалены. - Backward compatible — external fn методы (
@len,@eq,@split, …) продолжают использоватьstr_method_to_rtбез изменений. Фильтр!is_externalв Plan 54 Ф.2 гарантирует, что только Nova-body методы перехватываются.
Связь
- D26 — prelude auto-availability.
- D82 — external fn декларации (str external методы).
- D176 —
str.as_bytes() -> readonly []u8используется вparse_int_radixbody. - Plan 91.4 — sub-plan Ф.2.5 D177.
- Plan 54 — Ф.2 dispatch mechanism.
D178. str API cleanup и расширения — Plan 91 Ф.2.6
⚠ ЧАСТИЧНО RETRACTED (D325, Plan 177, 2026-07-03): нейминг-триада
parse_int(bare-throw) /try_parse_int(Result) /parse_int_opt(Option) из V2/V3 отозвана в пользу единой Result-формыparse_int -> Result[int, ParseIntError](D325 R1/R2/R4). Остальной str-cleanup D178 (bytes/chars/split/trim renames и т.п.) — в силе. Реализация отзыва (удаление bare +_opt, снятие emit_c-хардкода) — Plan 177 Ф.2b.
Что
Комплекс из шести взаимосвязанных изменений str API, закрывающих Plan 91
Ф.2.6:
@bytes()→@to_bytes()— allocating copy;@as_bytes()(D176, zero-copyreadonly []u8) остаётся без изменений.@chars()→@to_chars()— allocating codepoint slice.@split(sep str) -> []str→-> readonly []str— возвращает zero-copy views в оригинальный буфер; тип сигнализирует об этом.@parse_int_radix(radix int)+@parse_int()→@parse_int(radix int = 10)— одна Nova-body функция с keyword-only default-параметром (D102). Вызов без аргументов:"42".parse_int()(radix=10). С явным radix:"ff".parse_int(radix: 16). Позиционная передача default-параметра запрещена D102.@compare(other str) -> int— новый C-примитив; возвращает отрицательное/ноль/положительное, как Cstrcmp. Реализован какnova_str_compareчерез__builtin_memcmp.readonly bytesparameter syntax — параметрfrom_bytes_lossyиfrom_bytes_uncheckedпереписан в формуreadonly bytes []u8(modifier перед именем параметра, а не перед типом). Оба варианта теперь поддерживаются парсером.
Правило
// D178 итоговый str API (bootstrap):
export external fn str @to_bytes() -> []u8 // allocating copy
export external fn str @as_bytes() -> ro []u8 // D176: zero-copy
export external fn str @to_chars() -> []char // allocating codepoints
export external fn str @split(sep str) -> ro []str
export external fn str @compare(other str) -> int // <0 / 0 / >0
// from_bytes: `readonly` перед именем параметра (новая форма, D178)
export external fn str.from_bytes_lossy(ro bytes []u8) -> str
export external fn str.from_bytes_unchecked(ro bytes []u8) -> str
// parse_int: единственный метод с keyword-only default (D102)
export fn str @parse_int(radix int = 10) -> Option[int] {
if radix < 2 || radix > 36 { return None }
// ... тело на Nova (Plan 54 Ф.2)
}
Prelude auto-import (std.prelude v11):
export import std.runtime.string.{
parse_int, pad_start, pad_end, repeat, replace,
compare, to_bytes, to_chars, as_bytes
}
Эквивалентность типов readonly []u8:
ro []u8 ≡ ro [] ro u8
Оба варианта стриппируют recursive readonly до NovaArray_nova_byte* в
C codegen. Различие семантическое — первый «readonly array of u8», второй
«readonly array of readonly u8» — но в bootstrap-реализации оба ведут
себя идентично (нет изменяющих операций на байтах).
Default-параметры и keyword-only вызов (D102):
Параметр с дефолтным значением — всегда keyword-only (Nova D102). Попытка
передать позиционно вызывает ошибку компилятора. Для parse_int:
"ff".parse_int() // ✓ radix=10 (default)
"ff".parse_int(radix: 16) // ✓ явно radix=16
"ff".parse_int(16) // ✗ CODEGEN-FAIL: D102 keyword-only
Codegen: default-arg fill-in для Nova-body dispatch (Plan 54 Ф.2):
Когда вызов str.method(fewer_args_than_params) проходит через Plan 54
Ф.2 dispatch (method_overloads[("str", m)], !is_external filter),
codegen заполняет пропущенные trailing аргументы из MethodSig.param_defaults.
Поле param_defaults: Vec<Option<String>> добавлено в MethodSig; при
регистрации методов из FnDecl — populate через simple_literal_c (конвертирует
литеральные default-expressions в C-строку без вызова emit_expr).
Почему
- Консистентность
to_*prefix —to_bytes/to_charsсемантически аналогичны Rustto_vec()/to_string(): allocating copy. Безto_-prefix неясно, zero-copy или нет.as_bytes()остаётся как zero-copy аналог Rustas_bytes(). readonly []strизsplit— zero-copy views в оригинальный буфер; тип это выражает явно. Изменять элементы результата нельзя.- Единый
parse_int— вместо двух методов (parse_int()иparse_int_radix(r)) один с default-параметром. Упрощает API; radix=10 — наиболее частый случай. compareкак примитив — лексикографическое сравнение черезmemcmp; будущийPartialOrdauto-derive дляstrможет опираться на него.
C codegen mapping
| Nova method | C function |
|---|---|
str @to_bytes() | nova_str_to_bytes |
str @to_chars() | nova_str_to_chars |
str @compare(other) | nova_str_compare |
str @split(sep) | nova_str_split (unchanged) |
str @as_bytes() | nova_str_as_bytes (D176) |
Legacy C aliases сохранены для совместимости кода, написанного до D178:
nova_str_bytes → nova_str_to_bytes, nova_str_chars → nova_str_to_chars.
Связь
- D102 — keyword-only default params.
- D176 —
readonlytype modifier;as_bytes(). - D177 — Nova-body dispatch механизм.
- Plan 91.5 — sub-plan Ф.2.6 D178.
D179. StringBuilder — pure Nova consume type — Plan 91 Ф.2.6
Статус: закрыт (Plan 91 Ф.2.6 sub-phase, 2026-05-28).
Суть
StringBuilder перенесён из внешней реализации (C runtime / Rust String) в
чистый Nova-тип:
type StringBuilder consume {
mut buf []u8
}
Все методы реализованы на Nova; единственный внешний примитив —
buf.push(byte u8) (добавление байта в backing array), UTF-8 encoding
реализован через Nova bitwise ops.
API (финал D179)
// Конструкторы
StringBuilder.new() -> Self // pre-alloc 16 байт
StringBuilder.with_capacity(n) -> Self // pre-alloc n байт
StringBuilder.from(s str) -> Self // copy UTF-8 bytes
StringBuilder.from(c char) -> Self // UTF-8 encode одного codepoint
// Query
@len() -> int // байты O(1); аналог str.len (D26 school B)
@char_len() -> int // codepoints O(n) UTF-8 walk; новый метод
@capacity() -> int // allocated байты
@is_empty() -> bool
@clone() -> Self // deep copy buffer
// Prefix/suffix check
@starts_with(prefix str) -> bool
@ends_with(suffix str) -> bool
// Мутирующие (-> @, consume-тип — см. D131)
@append(s str) -> @ // append UTF-8 bytes из str
@append(c char) -> @ // append codepoint как UTF-8 (1-4 байта)
@append_bytes(ro arr []u8) -> @ // raw bytes; caller обеспечивает UTF-8
@append_repeat(s str, n int) -> @ // append s ровно n раз
@truncate(len int) -> @ // обрезать буфер до len байт
// Операторы
@plus(s str) -> @ // sb + "text" → @append(s) (D46)
@plus(c char) -> @ // sb + c → @append(c) (D46)
// Consume (финализация)
@into_str() -> str // consume StringBuilder → str; infallible (UTF-8 invariant)
Amend (Plan 91.18 Ф.2 П5, 2026-06-19):
@to_str()переименован в@into_str().as_конвенция зарезервирована для O(1) zero-copy view; consuming-финализатор использует Rust-идиомуinto_*. Устаревшее имя@to_str()удалено.
Изменения относительно pre-109
| Было (до D179) | Стало (D179) |
|---|---|
external type StringBuilder | type StringBuilder consume { mut buf []u8 } |
@byte_len() -> int | удалён (дублировал @len()) |
@peek() -> str | удалён (unsound: pointer aliasing с realloc) |
@into() -> str | @into_str() -> str (consume) |
@append_bytes(arr []u8) | @append_bytes(readonly arr []u8) |
| внешняя реализация C/Rust | чистый Nova-код |
Инфраструктура
std/runtime/string_builder.nv— Nova-реализация всех методов.compiler-codegen/nova_rt/string_builder.h— только UTF-8 helpers:nova_str_from_bytes_unchecked,nova_str_from_bytes_lossy,Nova_str_static_try_from_bytes,Nova_str_static_from_char,nova_str_replace. СтарыеNova_StringBuilder_*функции удалены.std/prelude/collections.nv—export import std.runtime.string_builder.{StringBuilder}(былоexternal type StringBuilder).compiler-codegen/src/codegen/runtime_registry.rs—RUNTIME_DEFINED_TYPESincludes"StringBuilder".emit_c.rs—lhs_is_nova_ptrguard:sb + "str"→@plusdispatch, неnova_str_concat.
Связь
- D131 — consume types и
-> @fluent API. - D133 — consume static analysis.
- D176 —
readonlyparameter modifier. - D178 —
str.from_bytes_*helpers. - Plan 91.6 — sub-plan Ф.2.6 sub-phase D179.
D178 amend V2 — str @try_parse_int + ParseIntError sum type — Plan 91 Ф.2
Статус: закрыт (Plan 91 Ф.2-remainders, 2026-06-08).
Что
Добавлен try_parse_int — Result-возвращающий вариант parse_int (D178 V1),
и ParseIntError sum type с четырьмя вариантами:
export type ParseIntError | Empty | InvalidDigit | Overflow | InvalidRadix
Это sum type, не record (design correction от Q-doc placeholder
{ value str, reason str } который предполагал structured payload).
Для MVP variantы достаточны; payload добавится в followup.
API
// Добавлено к str API (D178):
#stable(since = "0.1")
export fn str @try_parse_int(radix int = 10) -> Result[int, ParseIntError] { ... }
Семантика:
radix < 2 || radix > 36→Err(InvalidRadix)- пустая строка / строка из одного знака →
Err(Empty) - символ вне алфавита для данного radix →
Err(InvalidDigit) - значение выходит за пределы
int(i64) →Err(Overflow) - успех →
Ok(n)гдеnкорректное знаковое i64
Prefix + разрешён (ведёт себя как без prefix). Prefix - инвертирует знак.
Инвариант консистентности
Для любой строки s и radix r ∈ 2..=36:
s.parse_int(radix: r) == Some(n) ⟺ s.try_parse_int(radix: r) == Ok(n)
s.parse_int(radix: r) == None ⟺ s.try_parse_int(radix: r).is_err()
Этот инвариант проверяется в nova_tests/plan91_fe2/try_parse_int_consistent_with_parse_int_ok.nv.
C codegen mapping
try_parse_int реализована как Nova-body (не external C function) в
std/runtime/string.nv, аналогично parse_int. Codegen компилирует тело
Nova → C через обычный Nova dispatch (D177).
// Реализация в std/runtime/string.nv (сокращено):
export fn str @try_parse_int(radix int = 10) -> Result[int, ParseIntError] {
if radix < 2 || radix > 36 { return Err(InvalidRadix) }
ro bytes = @as_bytes()
ro n = @byte_len()
if n == 0 { return Err(Empty) }
// ... digit loop с Overflow guard ...
Ok(if neg { -acc } else { acc })
}
Codegen fix (сопутствующий)
При реализации обнаружен баг в emit_c.rs infer_expr_c_type для
ExprKind::Match: третий проход возвращал "nova_int" даже когда все
non-divergent arms возвращали nova_unit. Это вызывало CC-FAIL на вложенных
match-выражениях с unit-arm bodies типа match e { Empty => {} _ => ... }.
Исправление: добавлен all_unit флаг — если все non-divergent arms
unit-typed, возвращается "nova_unit" вместо "nova_int" fallback.
Связь
- D178 — V1
parse_intAPI. - D177 — Nova-body dispatch механизм.
nova_tests/plan91_fe2/— 10 fixtures (6 positive + 4 negative).
D178 amend V3 — Plan 91.18 str API cleanup (Ф.2/Ф.5/Ф.6/Ф.7/Ф.10)
Статус: IMPLEMENTED (Plan 91.18, 2026-06-19).
Дополнение к D178 V1/V2. Изменения из Plan 91.18:
Переименования (Ф.2)
str @trim/trim_start/trim_end→@trim_ascii/trim_ascii_start/trim_ascii_end(bare = Unicode-семантика подimport std.unicode,_ascii_= таблицы не нужны).str @to_lower/to_upper(ASCII) →@to_ascii_lower/to_ascii_upper.str @split_whitespace→@split_ascii_whitespace.str @pad_left/pad_right→@pad_start/pad_end(start/end convention).StringBuilder @as_str()→@into_str()(Rust-идиома consume+transfer;as_= O(1) borrow,into_= consume).str @code_points()→@to_code_points()+ перенос из utf16.nv → chars.nv (to_*= allocating copy convention).
Новые str методы (Ф.2/Ф.5)
str @parse_int(radix=10) -> int Fail[ParseIntError]— bare = throw convention (D77/D25);try_parse_int= Result;parse_int_opt= Option.⚠ RETRACTED триада (amend V4, D325, Plan 177, 2026-07-03). Триплет
parse_int(bare-throw) /try_parse_int(Result) /parse_int_opt(Option) отозван D325 (единый fallible-контракт). Каноничная форма:parse_int -> Result[int, ParseIntError](одно имя, Result);Option— через.ok(); throw на call-site — через!!. Bare-throwparse_intиparse_int_optудаляются из std (Plan 177 Ф.2b — compiler-gated:emit_c.rsхардкодит return-C-тип). До Ф.2b код может ещё видеть старые имена; нормативная форма — D325.str @split_at(idx)/@split_at_checked(idx)(Ф.5 П6).str @to_nfc/nfd/nfkc/nfkd()— тонкие делегаты кnormalize_*(Ф.5 П1).str @fold_case()/@to_upper()/@to_lower()/@to_title()— тонкие делегаты к case-free-fns подimport std.unicode(Ф.5 П2).str @trim/trim_start/trim_end/split_whitespace()подimport std.unicode(Ф.5 П3).str @contains(c char)/@starts_with(c char)/@ends_with(c char)— char-перегрузки без аллокации (Ф.5 П5).str.try_from_codepoint(cp) -> Result[str, _]— checked обёртка надchar.try_from(Ф.5 П4).
CharIndicesIter (Ф.10)
export type CharIndicesIter value priv(type) { buf str; pos int }в chars.nv.CharsIter @indices() -> CharIndicesIter— адаптер захватывает buf+pos.CharIndicesIter mut @next() -> Option[(int, char)]— off = byte-offset перед advance.
split("") policy (Ф.10, Решение 1)
Отменяет решение Plan 91.15 Ф.4 (split("") → паника). Единое правило:
пустой sep/паттерн = определённый край (не паника) во всём search/split/replace
surface. "hi".split("") == ["hi"], "hi".find("") == Some(0),
"hi".rfind("") == Some(2), "hi".contains("") == true.
collate doc fix (Ф.10)
collate_sort_key(s) -> Vec[u32] (было задокументировано []int; код уже был
правильным — только doc исправлен). Collator.strength удалён как dead field;
Collator хранит _tag int placeholder (type-private). STRENGTH_QUATERNARY удалён.
Связь
- D178 — V1.
- D178 amend V2 — V2.
- D253 — unicode методы.
- D254 — collation.
nova_tests/plan91_18/— 15 fixtures + neg/to_upper_import_gated.
D217. Method-local receiver field caching — Plan 123.1
Source: Plan 123 umbrella
- Plan 123.1. Implementation:
compiler-codegen/src/field_cache.rs.
1. Семантика — formal property
Для каждого input AST A и output AST T(A) — observable behavior
running T(A) identical to running A. «Observable» включает
stdout / panic / exit code / file system effects / network effects /
GC behavior. Pass — pure AST→AST трансформация без I/O и global
state.
Пример — типичная конверсия:
// До D217 преобразования (source AST):
fn ReadBuffer @try_read_u32_le() -> Result[u32, BufferError] {
if @pos + 4 > @data.len() { return Err(...) }
ro b0 = @data[@pos] as u32
ro b1 = @data[@pos + 1] as u32
ro b2 = @data[@pos + 2] as u32
ro b3 = @data[@pos + 3] as u32
@pos = @pos + 4
Ok(b0 | (b1 << 8) | (b2 << 16) | (b3 << 24))
}
// После D217 преобразования (transformed AST):
fn ReadBuffer @try_read_u32_le() -> Result[u32, BufferError] {
ro _at_data = @data // ro → unconditional cache (D175 freeze).
ro _at_pos = @pos // mut → first-region cache (валидна до
// первой write/call boundary).
if _at_pos + 4 > _at_data.len() { return Err(...) }
ro b0 = _at_data[_at_pos] as u32
ro b1 = _at_data[_at_pos + 1] as u32
ro b2 = _at_data[_at_pos + 2] as u32
ro b3 = _at_data[_at_pos + 3] as u32
@pos = _at_pos + 4 // write — _at_pos undefined after this point.
Ok(b0 | (b1 << 8) | (b2 << 16) | (b3 << 24))
}
.c output до — nova_self->data × 5 + nova_self->pos × 5;
после — оба cached в локалы один раз, регистр-аллокатор C-компилятора
тривиально hoisted. Net result: -O0 build стабильно быстрее на
hot-path methods (15-30% reduction в pointer derefs).
2. Heuristics
2.1 Threshold N
Default N=2: cache emit’ится только если field accessed ≥2 раз
в method body. N=0 → feature OFF (escape hatch). Tunable через env
vars (см. §5).
2.2 ro field — unconditional cache
RecordField.readonly == true (D175 ro modifier):
- Frozen post-construction, no mutation возможно.
- Cache valid across entire method body — unaffected by calls, asserts, loops, branches.
- Single prefix
let _at_<F> = @<F>emitted в body block start. - All reads of
@<F>replaced с_at_<F>.
2.3 mut field — straight-line first-region cache
RecordField.mutable == true (или default без ro/mut modifier):
- Cache valid от body start до first barrier.
- Barrier = first top-level Stmt syntactically containing:
- Write to
@<F>:Assign { target: Member{SelfAccess, F}, op: AssignOp::*, ... }. Includes compound (+=,-=,*=,/=). - Any Call expression:
ExprKind::Call,Spawn,Supervised,Detach,Blocking,With(handler invoke),Select(channel op). V1 conservative — IPA /#nofield_mutannotations refine в Plan 123.7.
- Write to
- Cache emitted при count reads-in-prefix-region ≥ threshold.
- V1: reads после boundary stay as direct
@<F>(single-region only). - V1.1 (Plan 123.1.1, 2026-06-03): multi-region — body’s top-level
stmts split into maximal non-barrier runs; каждая region с reads ≥
threshold gets own cache local. First region keeps name
_at_<F>(backwards-compat); subsequent regions use_at_<F>_r<N>(N ≥ 1). См. D217 amend V1.1 ниже. Closes[M-123.1-mut-region-recache].
3. Safety constraints
3.1 Closure capture
Если ANY closure body (ClosureLight / ClosureFull / Lambda
/ HandlerLit / ProtocolLit) syntactically references @<F>
в method body — caching F skipped полностью. Closure может outlive
scope (stored handler, spawned fiber) и mutate self-pointer’ом
field через alias.
3.2 Protocol / Effect / Opaque receivers
Receiver.type_name указывающий на TypeDeclKind::Protocol,
Effect, Opaque, Alias, Newtype, Sum → method skipped
entirely. Protocol — vtable dispatch (concrete impl unknown). Effect
/ Opaque — no record fields. Sum — variants accessed через pattern-
match. NamedTuple (D215) receivers — fields treated как ro
(stack value type, immutable post-construction).
3.3 Generic monomorphization
Pass runs после type-check. Receiver type known + RecordField
classification из TypeDecl level (generic-agnostic). Codegen mono
pipeline downstream видит уже cached AST per instantiation.
3.4 Consume / embed fields skipped
RecordField.consume == true (D131 linearity) — separate ownership
semantics. is_embed == true (use _ Type — D39) — auto-proxy
methods. Both skipped from registry.
3.5 Static-method receivers + External fn skipped
Receiver.kind == ReceiverKind::Static — fn Type.method(...) без
@self. FnDecl.is_external == true — no body. Both skipped
explicitly.
4. Mangling — naming convention
Cache local = _at_<field> (D217 §4 baseline). При collision с
existing user local в fn scope — numeric suffix _<N>, где N — fn-
local counter (incremented only при actual collision; default case
keeps _at_<F> bit-stable across builds).
Examples:
- No collision:
ro _at_pos = @pos. - User has
ro _at_pos = 99→ cache renamedro _at_pos_1 = @pos. User local untouched.
Collision detection — pre-pass scan всех Stmt::Let patterns +
Pattern::Ident / Pattern::Binding bindings во всём fn body +
params + closure-light/full params.
5. Escape hatch + tunables
Three environment variables (CLI-flag wiring через --field-cache-*
запланировано Plan 123.6 telemetry):
| Var | Default | Semantics |
|---|---|---|
NOVA_FIELD_CACHE | (unset) | 0/off/false → pass disabled. |
NOVA_FIELD_CACHE_THRESHOLD | 2 | Min reads to cache. 0 → disabled. |
NOVA_FIELD_CACHE_MAX | 8 | Cap cache locals per fn. |
Disabling — bypass точно identical к baseline AST output без pass. Verified differential testing — full nova_tests/plan123_1 PASS identically под ON и OFF (18/18 PASS обе configurations).
6. Debug-info preservation
Span каждого generated let _at_F = @F binding клонируется от
first occurrence of @F access в method body. DWARF / PDB emit
reflects это — debugger показывает _at_F local mapped к source
@F expression position.
V2 (Plan 123.5) — LSP code-lens над method header «N caches inserted»
- hover «cached as
_at_Ffrom line X».
7. Edition compatibility
V1 (Plan 123.1) — enable unconditionally (semantic equivalence guarantee достаточная; verified via 5 verification methods §1). Future versions могут require edition opt-in если выявится unexpected regression в production telemetry (Plan 123.6).
8. Cross-platform determinism
Pass deterministic by construction:
- Field names alphabetically sorted в
cache_fn(HashMap iteration leak prevented). - Per-fn counter reset → bit-stable cache local names across runs.
- No timestamp / random / system call в pass.
Same input AST → same output AST → same .c file (modulo platform-
specific runtime references).
9. Cross-references
- D32 (semantics передачи параметров) — receiver semantics — Self pointer не aliased под managed-heap rule.
- D52 (объявление типов) —
RecordFielddeclaration source. - D120 (
#pureviews + axioms) — pure annotation infrastructure для Plan 123.3 pure-call caching V3 future. - D131 (consume types) — linearity hints; consume fields skipped в V1, могут быть aggressive-cached в V2.
- D175 (readonly field freeze) — ro field invariant — единственный unconditional-cache eligibility источник.
- D176 (
readonly Tmodifier) — orthogonal к D217 (parameter- level), но D175 + D176 вместе формируют immutability semantics. - D215 (named tuple) —
NamedTuplefields treated как ro.
10. Implementation milestones — Plan 123 umbrella
| Version | Sub-plan | D-block | Status |
|---|---|---|---|
| V1 | 123.1 (Core CSE) | D217 (this) | ✅ V1 active |
| V2 | 123.2 (LICM) | D218 (planned) | gate’нут на 123.1 ✅ |
| V3 | 123.3 (#pure cache) | D219 (planned) | gate’нут на 123.1 ✅ + D120 |
| V4 | 123.4 (chain) | D217 amend | gate’нут на 123.1 ✅ |
| V5 | 123.5 (LSP/diag) | D217 §6 amend | gate’нут на 123.1 + 104.x |
| V6 | 123.6 (telemetry) | (impl-only) | gate’нут на 123.1 ✅ |
| V7 | 123.7 (IPA) | D217 amend | gate’нут на all above |
11. Open Q resolution
Q-codegen-cse-semantics(если откроется): → D217 (этот блок).Q-debug-info-cache: → D217 §6.Q-cache-edition-gating: → D217 §7 (V1 — no edition gate).
D218. LICM — Loop-Invariant Code Motion для receiver fields — Plan 123.2
Source: Plan 123.2 sub-plan #2
Plan 123 umbrella. Implementation: compiler-codegen/src/field_cache.rs
LICM phase. Composes с D217 (Plan 123.1 V1 baseline).
1. Семантика — formal property
Для каждого input AST A содержащего loops L₁, L₂, …, output
T(A) — observable behavior identical to A. Loop-invariant
@<F> reads inside loop body перемещены immediately before
the loop в enclosing Block.scope, с replacement reads inside body
на cache local ident _at_<F>_loop (or _at_<F>_loop_<N> при
collision).
Пример:
// До D218 (source):
fn Image @sum_pixels_for(n int) -> int {
mut total = 0
mut i = 0
while i < n {
total = total + @pixels + @pixels // 2 reads of mut @pixels
i = i + 1
}
total
}
// После D218 (LICM-hoisted):
fn Image @sum_pixels_for(n int) -> int {
mut total = 0
mut i = 0
ro _at_pixels_loop = @pixels // hoist immediately before loop
while i < n {
total = total + _at_pixels_loop + _at_pixels_loop
i = i + 1
}
total
}
LICM benefit visible when D217 (Plan 123.1) cannot cache the field at method-body prefix:
- Mut field accessed only inside loop; method body has Call before loop → D217 mut-prefix region empty → no cache. D218 LICM hoists immediately before loop scope.
- Method body has mixed access patterns where D217’s first-region bailout leaves loop body uncached.
2. Composition с D217 (Plan 123.1)
Order: D218 LICM phase runs BEFORE D217 per-fn ro/mut caching
(см. cache_module в field_cache.rs).
Rationale:
- LICM hoists invariant reads из loops; replaces reads inside loop
с
_at_<F>_loopident. - D217 then walks the body;
@<F>reads inside loop are already replaced — counted only reads OUTSIDE loops для method-body prefix cache decision. - No double-cache: if D217 also caches
@<F>at method-body prefix, the loop body reads are already_at_<F>_loopident (don’t match@Fpattern). Both cache locals coexist.
Result: для ro fields с reads only inside loop, the hoisted
_at_<F>_loop typically suffices (D217 sees zero @<F> accesses
outside loop — below threshold). Для ro fields с reads inside AND
outside loop, two separate caches emitted — one at method-body
prefix (D217) and one immediately before loop (D218). Stack-frame
growth bounded by max_per_fn=8 total cap.
3. Eligibility rules per loop body
For each field F and each loop body Block:
- Read count:
count_field_reads_in_block(body, F) ≥ licm_threshold(default 2). - No mutation:
!block_contains_write_to(body, F)— noAssign { target: Member{SelfAccess, F}, ... }anywhere in body. Includes compound assigns (+=, etc.) и nested control flow. - No closure capture:
!collect_closures_captures_in_block(body, F)— no closure body inside loop body references@F. (Closure body syntactic detection; conservative.) - No spawn / supervised / detach / blocking / parallel-for: loop body must not contain concurrent constructs (aliasing safety with concurrent fibers).
- For mut fields: loop body must NOT contain any Call (V2 conservative — IPA refines в Plan 123.7).
- For ro fields: Call в body OK (frozen — no aliasing).
4. Loop forms supported
for pattern in iter { body }— D-foreach standard for.while cond { body }.loop { body }— D-loop infinite + break.while let pat = expr { body }— D34.
Excluded:
parallel for pat in iter { body }— D14, concurrent body.- Loops nested внутри
Spawn/Supervised/Detach/Blocking/Selectchannel-op contexts.
5. Hoisting placement
Hoisted ro _at_<F>_loop = @<F> Stmt::Let inserted immediately
before the loop expression в enclosing Block.stmts. Placement
rules:
- Loop as
Stmt::Exprв Block.stmts: hoist inserted at same index, loop stmt pushed after. - Loop as Block.trailing: hoist appended к Block.stmts (loop remains trailing).
- Loop as FnBody::Expr (whole-body loop): body coerced к Block-with-trailing, hoist inserted at start.
- Loop nested внутри expression (e.g.
if cond { for ... }): hoist inserted into innermost enclosing Block.
6. Naming convention
Cache local = _at_<field>_loop (D218 §6 baseline). Distinct
от D217 _at_<field> для clarity в debug-info — каждое имя
объясняет cache origin: _at_X = method-body cache; _at_X_loop
= LICM hoist.
Collision avoidance: numeric suffix _<N> если имя уже occupied
user-local OR другим LICM hoist в same fn scope.
7. Escape hatch + tunables
| Var | Default | Semantics |
|---|---|---|
NOVA_FIELD_CACHE_LICM | (unset) | 0/off/false → LICM disabled. D217 unaffected. |
NOVA_FIELD_CACHE_LICM_THRESHOLD | 2 | Min reads inside loop body. 0 → LICM disabled. |
NOVA_FIELD_CACHE_LICM_MAX | 4 | Cap hoists per loop. |
NOVA_FIELD_CACHE (D217) | (unset) | 0 → disables BOTH D217 and D218 (umbrella escape hatch). |
Verified differential testing: 14/14 plan123_2 PASS identically под
NOVA_FIELD_CACHE_LICM=0 и default ON. Semantic equivalence
guaranteed.
8. Debug-info preservation
Span каждого hoisted let клонируется от first occurrence of
@F access в loop body. DWARF/PDB emit reflects это — debugger
показывает _at_<F>_loop local mapped к source @<F> expression
position inside loop body.
9. Cross-references
- D217 (Plan 123.1, V1) — baseline CSE pass; composition partner.
- D14 (ParallelFor) — concurrent body, LICM skip.
- D50 (structured concurrency — Spawn/Supervised) — LICM skip bodies that contain these.
- D131 (consume types) — consume fields skipped (D217 §3.4 inherits).
- D175 (readonly field freeze) — ro semantics; aliasing-safe invariance.
10. Implementation milestones
- V2 Plan 123.2 ✅ (this block).
- V3-V7 — Plan 123.3 (pure-call cache) / 123.4 (chain) / 123.5 (LSP) / 123.6 (telemetry) / 123.7 (IPA) — orthogonal extensions.
11. Open Q resolution
Q-licm-correctness: → D218 §1 + §3 (formal property + eligibility rules ensure correctness).Q-licm-composition-with-D217: → D218 §2 (order LICM → D217).
D219. Pure-call result caching (effect-aware, Nova edge) — Plan 123.3
Source: Plan 123.3
sub-plan #3 Plan 123 umbrella. Implementation: field_cache.rs
pure-cache phase. Composes с D217 (V1) + D218 (V2).
1. Семантика — formal property
Для каждого input AST A содержащего multiple invocations of
@<pure_method>() (where method has Purity::Pure per D24
infrastructure), output T(A) — observable behavior identical
to A. Pure-call result evaluated once per method body, cached в
local _at_<method>_call, replaced на cache ident в subsequent
call sites.
Nova-edge — leverages effect system: #pure annotation guarantees
no effects, no side effects, deterministic result (depends only
on self’s state).
Пример:
// До D219 (source):
#pure
fn Vec3 @magnitude_sq() -> int => @x * @x + @y * @y + @z * @z
fn Vec3 @double_test() -> int {
@magnitude_sq() + @magnitude_sq() // 2 pure-method calls
}
// После D219 (cached):
fn Vec3 @double_test() -> int {
ro _at_magnitude_sq_call = @magnitude_sq() // single eval
_at_magnitude_sq_call + _at_magnitude_sq_call
}
.c output до — Nova_Vec3_method_magnitude_sq(nova_self) × 2;
после — single call, register-reuse через cache local.
2. Composition с D217 + D218
Order: D218 LICM → D219 pure-cache → D217 per-fn cache.
Rationale:
- D218 LICM hoists loop-invariant @F reads. Pure-call args (V3 args-less) — none, so LICM unaffected.
- D219 caches @<pure_method>() result; replaces calls с Ident.
- D217 sees pure-cache locals as Idents (not @F pattern). Continues to cache @F reads outside pure-call args.
No double-cache risk — three layers operate on distinct AST patterns (LICM: @F in loop; D219: @M() pure call; D217: @F method- body prefix).
3. Eligibility rules per fn body
For each fn body (instance method with non-protocol receiver):
- Body must NOT have any
@F = ...write (conservative invalidation). Refined V3.1 с D24f.readsframe info. - Body must NOT contain concurrent constructs: Spawn, Supervised, Detach, Blocking, ParallelFor — skip whole pure- cache for safety.
- Method registry lookup:
(receiver_type, method_name)must be в pure_methods registry. Registry includes only methods сpurity == Purity::Pure(D24 — annotated#pureOR inferred через SCC) ANDInstancereceiver ANDparams.is_empty()(V3 scope; V3.1 — args-with-literals). - Count occurrences: call count ≥
pure_threshold(default 2). - Closure capture exclusion: if
@<method>()appears inside nested closure body, that call counts toward closure_captured set, excluded from caching.
4. V3 scope (DECISION-A.3 / E.3)
Included:
Call { func: Member{SelfAccess, name: M}, args: [] }— args-less self-method calls.
Excluded (Plan 123.4/V3.1/Plan 123.7 territory):
- Pure calls с arguments (V3.1).
- Pure calls на
@field.method()chains (Plan 123.4 chain cache). - Pure calls на parameters / locals.
- Effectful methods (
Purity::Effectful/Purity::Unknown). - Consume-returning pure methods (D131 linearity — skip).
5. Naming convention
Cache local = _at_<method>_call baseline. Distinct от:
Collision avoidance: numeric suffix _<N>.
6. Escape hatch + tunables
| Var | Default | Semantics |
|---|---|---|
NOVA_FIELD_CACHE_PURE | (unset) | 0/off/false → V3 disabled. D217/D218 unaffected. |
NOVA_FIELD_CACHE_PURE_THRESHOLD | 2 | Min pure-call count. 0 → disabled. |
NOVA_FIELD_CACHE (D217) | (unset) | 0 → disables all 3 layers. |
Verified differential testing: 12/12 plan123_3 PASS identically
под NOVA_FIELD_CACHE_PURE=0 и default ON.
7. Conservative invalidation rationale
V3 simple rule: ANY @F = ... write anywhere в method body skips
all pure-cache. Rationale: without frame info, mutation may affect
pure method’s result transitively. Pure method reads @F (typical
pure method depends on fields); mutating any field may change
result.
V3.1 refinement (followup [M-123.3-frame-based-invalidation]):
Use D24 f.reads frame information to determine which fields
each pure method reads. Cache valid until any of those fields
written. Allows mut field’s write to NOT invalidate unrelated
pure methods. Marked P2 followup.
8. Debug-info preservation
Span каждого generated let _at_<method>_call = @<method>()
binding клонируется от first occurrence of @<method>() call.
Debugger показывает cache origin maps к source position.
9. Cross-references
- D24 (Plan 33.1 + 33.2) — Purity infrastructure (
#pureannotation + SCC inference). V3 leveragesFnDecl.purityfield. - D217 (Plan 123.1, V1) — per-fn ro/mut field cache. Composition partner; D219 runs between D218 LICM и D217.
- D218 (Plan 123.2, V2) — LICM hoisting. Composition partner.
- D120 (
#pureviews + axioms) — semantic foundation; pure methods have no effects, no mutation, deterministic. - D131 (consume types) — consume-returning pure methods skipped.
10. Implementation milestones
- V3 Plan 123.3 ✅ (this block) — args-less self-pure-call.
- V3.1 followups:
[M-123.3-args-literals](cache calls с literal args),[M-123.3-frame-based-invalidation](D24 reads frame). - V4 Plan 123.4 chain cache — orthogonal extension.
11. Open Q resolution
Q-pure-call-cache: → D219 (this block).Q-pure-call-mutation-invalidation: → D219 §3 + §7 (V3 conservative; V3.1 refined).
D217 amend V4 — Chain caching @a.b.c (Plan 123.4)
Source: Plan 123.4
sub-plan #4 Plan 123 umbrella. Extends D217 caching infrastructure
to nested chain access patterns. Implementation: field_cache.rs
chain-cache phase (D217 amend rather than NEW D-block because
chain extension preserves D217 semantic foundation, just extends
to multi-segment paths).
1. Семантика — V4 extension
For chain access pattern Member { ... Member { obj: SelfAccess, name: A }, name: B } (= @a.b) with chain length 2..=
chain_max_depth (default 4), cache emitted при ≥
chain_threshold (default 2) occurrences. Replaces all matching
chain expressions с cache local Ident.
Пример:
// До D217 V4 (source):
fn Outer @sum_with_chain() -> int {
@inner.value + @inner.value + @inner.value + @mark
}
// После D217 V4:
fn Outer @sum_with_chain() -> int {
ro _at_inner_value_chain = @inner.value
_at_inner_value_chain + _at_inner_value_chain + _at_inner_value_chain + @mark
}
2. Composition order — three-layer + V4
Order в cache_module:
Rationale:
- LICM hoists single-field @F reads из loops first.
- Chain caching emits multi-segment chain locals; replaces chain expressions с Idents.
- D219 pure-cache then handles
@<pure_method>()calls (chains inside pure-method receivers already cached). - D217 V1 final fills in remaining @F single-field caches.
All four layers emit distinct cache local naming, no shadowing risk:
- D217 V1:
_at_<F> - D218 LICM:
_at_<F>_loop - D219 pure:
_at_<M>_call - D217 V4 chain:
_at_<a>_<b>[_<c>[_<d>]]_chain(NEW)
3. Eligibility (V4)
- Chain length: 2 ≤ depth ≤
chain_max_depth(default 4). Single-field (depth 1) handled by D217 V1 baseline; deeper than 4 → skip (stack-frame bloat protection). - Occurrence count: identical canonical path ≥
chain_threshold. - No top-level @F write anywhere в body (V4 conservative; future V4.1 may refine per-segment).
- No concurrent body: Spawn/Supervised/Detach/Blocking/ ParallelFor → skip.
- No closure capture: chain in closure body → excluded from caching.
- Receiver type known: not Protocol/Effect/Opaque/etc.
4. Critical detection rule — method dispatch ≠ chain
@a.b.method() — Member{obj: @a.b, name: “method”} is NOT a chain
of length 3 (method is method-dispatch name, not field).
Implementation detail: when traversing ExprKind::Call, recurse
into func.obj (the receiver) not into func itself. Same fix
applied in both count_chains_in_expr и rewrite_chains_in_expr.
Verified through fixture failure during V4 implementation —
StringBuilder.append__nova_char attempted to chain-cache
@buf.push (push is array method); fix corrected immediately.
5. Naming
Cache local = _at_<a>_<b>[_<c>[_<d>]]_chain для path components
[a, b, c?, d?]. Joined with underscores. Suffix _<N> при
collision.
Examples:
@inner.value→_at_inner_value_chain.@parent.inner.cfg.limit→_at_parent_inner_cfg_limit_chain.
6. Escape hatch + tunables
| Var | Default | Semantics |
|---|---|---|
NOVA_FIELD_CACHE_CHAIN | (unset) | 0/off/false → V4 disabled. |
NOVA_FIELD_CACHE_CHAIN_THRESHOLD | 2 | Min chain occurrences. |
NOVA_FIELD_CACHE_CHAIN_DEPTH | 4 | Max chain depth (≥2 enforced). |
NOVA_FIELD_CACHE | (unset) | 0 → disables all 4 layers. |
Verified: 10/10 plan123_4 PASS identically под default и
NOVA_FIELD_CACHE_CHAIN=0.
7. Risk register (V4-specific)
- R-4.1: stack-frame bloat (many chain caches per fn).
Mitigation:
max_per_fn=8cap shared across all 4 layers. - R-4.2: chain через mut intermediate field could theoretically invalidate. Mitigation: V4 conservative — any @F write in body skips all chain caching.
- R-4.3: method-dispatch confusion. Mitigation: detection rule §4.
8. Future extensions
- V4.1 followups:
[M-123.4-per-segment-invalidation]— refine invalidation via D24f.readsframe info.[M-123.4-chain-prefix-sharing]— cache shared prefixes (e.g.@a.b+@a.b.cshare@a.bintermediate).
- V7 IPA (Plan 123.7) — enables cross-method analysis для chain invalidation precision.
9. Cross-references
- D217 V1 (Plan 123.1) — baseline single-field cache.
- D218 (Plan 123.2) — LICM. Chain caching composes after LICM.
- D219 (Plan 123.3) — pure-call. Chain caching composes before pure-call (chain ID resolution must complete before pure-call’s self.method() detection).
- D52 (record types) — chain fields must be record-typed at each level.
D217 §6 amend V5 — diagnostic mode + LSP code-lens (Plan 123.5)
Source: Plan 123.5.
1. analyze_module API
field_cache::analyze_module(&Module, &Config) -> ExplainReport
provides per-fn cache decision report без mutation. Returns:
pub struct ExplainReport {
pub per_fn: Vec<FnCacheInfo>,
}
pub struct FnCacheInfo {
pub type_name: String,
pub fn_name: String,
pub span: Span,
pub ro_caches: Vec<String>,
pub mut_caches: Vec<String>,
pub licm_hoists: Vec<String>,
pub pure_caches: Vec<String>,
pub chain_caches: Vec<Vec<String>>,
}
Implementation: clones AST, runs cache_module, walks injected
prefix-let statements, classifies by name suffix (_chain /
_loop / _call / plain).
2. CLI flag --explain-cache
nova check <files> --explain-cache — per-file per-fn report on
stdout:
=== src/buffer.nv ===
fn ReadBuffer @try_read_u32_le — 4 cache(s):
D217 field cache: data, pos
D219 pure-call cache: len
D217 V4 chain cache: @header.signature
field-cache total: 1 method(s) affected, 4 cache(s) inserted
Use cases:
- Audit hot paths для caching effectiveness.
- Diagnose unexpected caching behavior.
- Document optimization decisions during code review.
3. LSP code-lens (deferred V5.1)
V5 ships CLI flag; LSP code-lens + hover provider deferred:
- [M-123.5-lsp-codelens] —
textDocument/codeLenshandler emit “N caches” lens per fn header. - [M-123.5-lsp-hover] —
textDocument/hoverenhancement showing cache info при hovering@field.
Infrastructure (analyze_module API) ready; LSP integration
straightforward when prioritized.
4. User-facing doc
docs/field-cache-optimization.md — user guide explaining 4 layers,
escape hatches, semantic equivalence, performance expectations.
5. Cross-references
- D217 V1 + V4 / D218 / D219 — analyzed layers.
- D104+D105 (doc-attrs) — future direction for
#cache_infoattribute on methods.
D217 §6 amend V5.1 — LSP code-lens + hover (Plan 123.5.1)
Source: Plan 123.5.1.
1. LSP capabilities
V5.1 extends nova-lsp::server::Backend (Plan 104.x infrastructure)
с двумя capabilities:
code_lens_provider: Some(CodeLensOptions { resolve_provider: false }).hover_provider: Some(HoverProviderCapability::Simple(true)).
2. code_lens handler
textDocument/codeLens request invokes
compute_field_cache_lenses(src):
- Parse module → run pipeline (skip if type-check fails).
- Call
analyze_module(V5 API). - For each
FnCacheInfoв report, emitCodeLensс title:
Range = first character of fn span. Command =N cache(s): ro=X mut=Y licm=Z pure=W chain=Vnova-lsp.fieldCache.show(no-op stub; IDE extension can handle).
3. hover handler
textDocument/hover invokes compute_field_cache_hover(src, pos):
- Find
@<name>token at hover position (look backward для@, forward для name chars). - Run pipeline + analyze_module.
- Locate fn whose span covers position.
- If
name ∈ info.ro_caches→ “D217 ro cache:_at_<name>”. - If
name ∈ info.licm_hoists→ “D218 LICM loop hoist:_at_<name>_loop”. - Chain root → “D217 V4 chain cache (root)”.
- Otherwise → “not cached”.
4. Public API (test surface)
nova-lsp::server::compute_field_cache_lenses(&str) -> Option<Vec<CodeLens>>
и compute_field_cache_hover(&str, Position) -> Option<Hover> —
public для integration testing without LSP RPC stack.
5. Acceptance
A5.1.1-A5.1.4 met:
- A5.1.1 ✅ code_lens emitted per affected method.
- A5.1.2 ✅ hover returns cache info if cached.
- A5.1.3 ✅ nova-lsp/tests/field_cache_lens.rs 3/3 PASS.
- A5.1.4 ✅ D217 §6 amend V5.1 landed (this section).
6. Future LSP integrations
- V5.2: semantic tokens — paint cache locals в IDE с distinct color.
- V5.3: quickfix
add #pureattribute when pure-method would enable caching but missing annotation.
D217 §7 amend V6 — Telemetry + production rollout (Plan 123.6)
Source: Plan 123.6.
1. Telemetry aggregator
nova check <files> --telemetry-cache — walks files, runs
analyze_module per file, aggregates statistics:
files_total/files_skipped— file processing tally.methods_total— instance methods scanned across corpus.methods_affected— methods with ≥1 cache decision.methods_affected_pct— ratio.caches_total— sum of caches inserted.caches_per_method_median/_p99— distribution.- Per-layer breakdown:
layer_d217_field/layer_d218_licm/layer_d219_pure/layer_d217_chain.
Two output formats:
- Default: human-readable text.
--telemetry-json: structured JSON (CI integration).
2. Production rollout strategy
docs/migration/123-field-cache.md provides production team
guide:
- Baseline differential: run tests under both
NOVA_FIELD_CACHE=0и default ON; confirm identical results. - Telemetry baseline: capture
--telemetry-cachemetrics before deploying. - Gradual rollout (optional): enable layers one at a time — V1 only → +V2 → +V3 → +V4.
- Regression detection: if tests fail with default, diagnostic
workflow disables layers individually to identify culprit, then
--explain-cacheshows specific decisions.
3. CLI flag reference (V6 partial)
V6 ships env vars covering all config. Full --field-cache-* CLI
flag set deferred к V6.1 ([M-123.6-cli-flags-full]) — env vars
provide complete coverage:
| Function | Env Var |
|---|---|
| Disable all | NOVA_FIELD_CACHE=0 |
| Disable LICM | NOVA_FIELD_CACHE_LICM=0 |
| Disable pure | NOVA_FIELD_CACHE_PURE=0 |
| Disable chain | NOVA_FIELD_CACHE_CHAIN=0 |
| Threshold | NOVA_FIELD_CACHE[_LICM/_PURE/_CHAIN]_THRESHOLD=N |
| Per-fn cap | NOVA_FIELD_CACHE_MAX=N |
| Per-loop cap | NOVA_FIELD_CACHE_LICM_MAX=N |
| Chain depth | NOVA_FIELD_CACHE_CHAIN_DEPTH=N |
4. CI perf regression gates
V6 ships infrastructure; concrete CI integration with Plan 57 bench
harness is followup [M-123.6-ci-perf-gates]. Aggregator output
matches Plan 57 metric format; wiring straightforward.
5. Cross-references
- All previous D217 amends + D218 + D219.
- Plan 57 (bench infrastructure) — future CI gate integration.
D217 §7 amend V6.1 — Sugar CLI flags + CI perf gate (Plan 123.6.1)
Source: Plan 123.6.1.
1. Global CLI flags
12 flags added к nova-cli::Cli struct (global=true), translated к
env vars before subcommand dispatch:
| Flag | Env var (set on use) |
|---|---|
--no-field-cache | NOVA_FIELD_CACHE=0 |
--no-field-cache-licm | NOVA_FIELD_CACHE_LICM=0 |
--no-field-cache-pure | NOVA_FIELD_CACHE_PURE=0 |
--no-field-cache-chain | NOVA_FIELD_CACHE_CHAIN=0 |
--no-field-cache-ipa | NOVA_FIELD_CACHE_IPA=0 |
--field-cache-threshold=N | NOVA_FIELD_CACHE_THRESHOLD=N |
--field-cache-licm-threshold=N | NOVA_FIELD_CACHE_LICM_THRESHOLD=N |
--field-cache-pure-threshold=N | NOVA_FIELD_CACHE_PURE_THRESHOLD=N |
--field-cache-chain-threshold=N | NOVA_FIELD_CACHE_CHAIN_THRESHOLD=N |
--field-cache-max=N | NOVA_FIELD_CACHE_MAX=N |
--field-cache-licm-max=N | NOVA_FIELD_CACHE_LICM_MAX=N |
--field-cache-chain-depth=N | NOVA_FIELD_CACHE_CHAIN_DEPTH=N |
CLI flag overrides env var when both present.
2. CI perf regression gate
nova check --telemetry-cache --telemetry-baseline=baseline.json:
- Computes current telemetry (V6 flow).
- Parses baseline JSON (minimal hand-rolled extractor, no serde_json dep).
- Compares metrics:
methods_affected_pct: absolute drop > 5 percentage points → regression.caches_total: relative drop > 10% → regression.
- Exit code 1 on regression; 0 otherwise.
3. Acceptance
A6.1.1-A6.1.5 ✅. Verified: --no-field-cache triggers regression
gate (100% drop); --field-cache-threshold=3 reduces affected
count.
4. V6.2+ followups
- V6.2: integration с Plan 57
nova benchдля CPU time regression — ✅ LANDED 2026-06-02 (см. D217 amend V6.2 ниже). - V6.3: custom thresholds via flags — ✅ LANDED 2026-06-02 (см. D217 amend V6.3 ниже).
D217 amend V6.2 — Plan 57 bench integration: CPU savings estimate
Source: Plan 123.6.2. Status: ✅ ACTIVE 2026-06-02.
1. Scope
V6 / V6.1 emit cache-count metrics (methods affected, total caches,
per-layer breakdown). These don’t translate directly to CPU-time
regression detection — a refactor может reduce cache COUNT yet
preserve or improve actual cycle savings (e.g. V4.2 prefix sharing
emits ONE cache let to replace many @<root> reads).
V6.2 adds a static cycles-saved estimate computed from the ExplainReport. Cycle weights are heuristic — modeled on typical x86_64 microarchitecture costs (4 cycles load, 40 cycles pure-call, 8 loop iterations assumed для LICM). The estimate is robust against counting refactors because it weights chain-cache savings by chain length и pure-cache savings by call cost.
2. Public API
pub struct CpuSavingsReport {
pub estimated_cycles_saved: u64,
pub layer_ro: u64,
pub layer_mut: u64,
pub layer_licm: u64,
pub layer_pure: u64,
pub layer_chain: u64,
pub methods_with_savings: usize,
}
pub fn cpu_savings_estimate(report: &ExplainReport) -> CpuSavingsReport;
3. Telemetry JSON emit
nova check --telemetry-cache --telemetry-json теперь emits:
"cycles_saved_estimate": 1234,
"cycles_methods_with_savings": 18,
"cycles_layer_ro": 72,
"cycles_layer_mut": 16,
"cycles_layer_licm": 256,
"cycles_layer_pure": 840,
"cycles_layer_chain": 50
nova bench gate can compare cycles_saved_estimate between
runs — drop > 10% (default) → V6.2 regression.
4. Baseline comparison gate
When --telemetry-baseline=FILE provided AND baseline has
cycles_saved_estimate, current value compared relative to
baseline. Drop > 10% relative → exit 1.
5. Configuration
Cycle constants tunable via env (forensic):
NOVA_FC_LOAD_CYCLES(default 4)NOVA_FC_CALL_CYCLES(default 40)NOVA_FC_LOOP_ITERS(default 8)
6. Acceptance
- V6.2.1
cpu_savings_estimatereturns non-zero for module с cached methods ✅ - V6.2.2 Empty report → zero savings ✅
- V6.2.3
nova check --telemetry-cache --telemetry-jsonemits 7 new fields ✅ - V6.2.4 Baseline regression gate fires on > 10% drop ✅
- V6.2.5 Cycle weights env-tunable ✅
7. Followups
- V6.2.1: ✅ DELIVERED 2026-06-03 — см. D217 amend V6.2.1 ниже.
D217 amend V6.2.1 — Real wall-clock bench (Plan 123 V6.2.1)
Source: Plan 123.6.2.1. Status: ✅ ACTIVE 2026-06-03.
1. Scope
V6.2 эмиттит static cycle-savings estimate. Эта оценка — heuristic
(4/40/8 cycle weights × LOC pattern counts) и нуждается в
cross-validation против real-program wallclock. V6.2.1 adds a
dedicated subcommand nova bench field-cache which:
- Builds each input
.nvfile twice через subprocessnova buildс дополнительным env override:- ON variant:
NOVA_FIELD_CACHE=1(default cfg + cache pass) - OFF variant:
NOVA_FIELD_CACHE=0(pipeline skipscache_module)
- ON variant:
- Runs each variant N samples (default 11) с warmup (default 2);
measures process wallclock via
std::time::Instant. - Reports median per variant, speedup-pct (off-on)/off×100, и V6.2 static estimate side-by-side для cross-validation.
The OFF variant uses the same pipeline minus the cache_module pass
— все остальное (parse, typecheck, codegen, link, GC) идентично, что
гарантирует causal attribution только полю cache_module effect.
2. Public API (CLI surface)
nova bench field-cache <PATH> [OPTIONS]
PATH Single .nv file OR directory (recursive)
--samples N Samples kept per variant (default 11)
--warmup N Warmup runs per variant, discarded (default 2)
--mode release|dev Build mode (default release)
--toolchain ... Compiler (default auto)
--gc malloc|boehm GC backend (default boehm)
--out FILE.json Write JSON v1 result
--baseline FILE.json Compare geomean_speedup_pct against baseline
--gate-regression-pp N Regression threshold pp (default 2.0)
--skip-failed Skip non-buildable files vs hard fail
Behavior:
- Files без
fn mainautomatically skipped с status"skip: no fn main". - Subprocess build/run timeouts soft-bounded (default 120s build, 60s run).
- Samples interleaved off-then-on per iteration (mitigates systematic drift из CPU thermal / scheduler jitter).
- Aggregate uses geometric mean of
(1 + speedup_i/100)factors per file (Hennessy & Patterson §1.10 — unbiased composite ratio).
3. JSON v1 schema
{
"format_version": "1",
"kind": "field-cache-wallclock",
"samples_per_variant": 11,
"warmup_runs": 2,
"entries": [
{
"file": "...",
"status": "ok",
"off_median_ns": ...,
"on_median_ns": ...,
"off_samples_ns": [...],
"on_samples_ns": [...],
"speedup_pct": ...,
"static_cycles": ...,
"static_per_layer": {
"ro": ..., "mut": ..., "licm": ...,
"pure": ..., "chain": ...
}
}
],
"aggregate": {
"geomean_speedup_pct": ...,
"total_static_cycles": ...,
"files_measured": ...,
"files_skipped": ...
}
}
Forwards-compatible: bumping format_version requires migration
note in this section (mirror D217 V6 / V6.1 / V6.2 conventions).
4. Baseline regression gate
When --baseline FILE provided:
- Parse
aggregate.geomean_speedup_pctfrom baseline JSON (required). - Compute
drop = baseline_geomean − new_geomean. - If
drop > --gate-regression-pp threshold(default 2.0 pp) → exit 1.
Rationale: percentage-point delta is interpretable across runs without needing relative-pct normalization. 2.0 pp default chosen as 1σ noise floor on Windows MSVC + boehm runs (calibrated 2026-06-03).
5. Configuration
CLI flags override defaults. No new env vars introduced; existing
NOVA_FIELD_CACHE=0 (D217 V1 escape hatch) is the disable mechanism.
6. Acceptance
- V6.2.1.1 Subcommand registered, builds twice, emits per-file row ✅
- V6.2.1.2
--outwrites valid JSON v1 schema with all documented fields including per-layer breakdown ✅ - V6.2.1.3 Dir mode walks .nv files recursively; non-main files
marked
"skip: no fn main"✅ - V6.2.1.4
--baseline+--gate-regression-ppexit 1 when drop exceeds threshold; exit 0 otherwise ✅ - V6.2.1.5 Static
static_cyclesfield matches in-processcpu_savings_estimate(analyze_module(...))output ✅ - V6.2.1.6 Unit tests cover median (odd/even/empty), geomean (3-file/empty/skip-exclusion), JSON shape, has_fn_main, formatting (12 tests) ✅
7. Followups
- V6.2.2 (future): continuous orphan-branch history for
wallclock JSON (mirror Plan 57
nova bench history-add). - V6.2.3 (future): sweep cycle-weight calibration via least-squares fit of static estimate vs measured speedup across the corpus.
D217 amend V6.3 — Configurable gate thresholds (Plan 123 V6.3)
Source: Plan 123.6.3. Status: ✅ ACTIVE 2026-06-02.
1. Scope
V6.1 hardcoded two CI regression gates (5 pp drop в affected %, 10 % drop в caches total) и V7 hardcoded the IPA iterative-closure cap at 10. V6.3 promotes all three to user-configurable parameters:
| Knob | Env var | CLI flag | Default | Range |
|---|---|---|---|---|
| IPA closure iteration cap | NOVA_FIELD_CACHE_IPA_ITER | --field-cache-ipa-iter=N | 10 | 1..=1024 |
| affected-% drop gate | (n/a) | --telemetry-gate-affected-drop=F | 5.0 | ≥0.0 |
| caches-total drop gate | (n/a) | --telemetry-gate-caches-drop=F | 10.0 | ≥0.0 |
2. IPA iter limit rationale
The transitive-closure loop in build_{write,read}_set_registry
typically converges in ≤3 iterations for production-sized modules
(50–500 methods, ≤2 levels of mutual recursion). The hardcoded cap
10 was a safe approximation. Configurable cap enables:
- Forensic deep-dive on adversarial test cases (e.g. synthetic modules с mutual recursion 8+ deep).
- Stress-testing future SCC implementation (V7.3) by comparing iterative ≤N to SCC O(V+E) results.
- Disabling closure entirely is rejected (n=0) — silent degradation trap.
3. Telemetry gate rationale
V6.1 thresholds were tuned for nova_tests baseline (~1500 fixtures). Real workloads vary: a stdlib-heavy project may legitimately drop 10pp в affected % during a large refactor; a perf-critical inner loop change may cap regression at 2pp. Hardcoding 5/10 forces all CI configurations to that one tradeoff. Overrides let teams tune strictness per-pipeline (PR check vs nightly trend gate, e.g.).
Both overrides validated for non-negative input (negative threshold
would invert the comparison semantically). Upper bound is
unconstrained — operators may set --telemetry-gate-caches-drop=100
to disable the gate without removing the flag from CI config.
4. Backward compatibility
All defaults preserve V6.1 behavior exactly. Existing CI scripts keep passing — V6.3 is purely additive. Output messaging updated to echo active gate values для transparency:
V6.1 perf gate: OK (vs baseline foo.json; gates -5.00pp affected / -10.0% caches)
5. Acceptance
- V6.3.1
FieldCacheConfig.ipa_iter_limitfield + env/CLI binding (1..=1024 clamp) ✅ - V6.3.2
--telemetry-gate-affected-drop/-caches-dropCLI flags ✅ + non-negative validation - V6.3.3 All defaults match V6.1 behavior (no regression) ✅
- V6.3.4 Lib tests 14/14 PASS unchanged ✅
D223. IPA — Inter-Procedural Analysis для field caching — Plan 123.7
Note 2026-06-02: Originally assigned D220, renumbered к D223 after merge into main (D220-D222 claimed by Plan 124 priv-field- visibility umbrella в parallel work).
Source: Plan 123.7. Implementation:
field_cache::module_write_sets public API. Composition partner с
D217 V1 mut barrier check (full integration V7.1 followup).
1. Field-write-set inference
Per FnDecl с Instance receiver, compute field_write_set: HashSet<String> — set of field names that the method’s body writes
via @F = ... (top-level Assign with target = Member{SelfAccess, F}).
Inference includes:
- Direct writes: Assign statements anywhere в body.
- Transitive via method calls: if body calls
@<callee>(args), union с callee’s write_set. - Iterative closure: ≤10 iterations (sufficient для most call graphs; V7.1 may add SCC analysis для exact closure).
2. Public API
pub fn module_write_sets(
module: &Module,
) -> HashMap<(String, String), HashSet<String>>;
Returns map (type_name, method_name) → set of field names mutated.
Used by:
- V7 internal cache_fn_ipa wrapper.
- V7.1 future integration с V1 mut barrier.
- Tooling (Plan 123.5
--explain-cachefuture extension to show callee write-sets).
3. V7 integration scope
V7 ships:
- ✅ write_set inference infrastructure.
- ✅ public API.
- 🟡 V1 mut barrier refinement deferred V7.1 ([M-123.7-full-integration]).
- 🟡 V2 LICM, V3 pure, V4 chain — IPA refinements deferred V7.1+.
Conservative fallback: methods without computed write_set treated как writing all fields (V1 current behavior). No regression.
4. Cross-module IPA
True cross-module (link-time) IPA — substantial infrastructure beyond field-cache scope. Deferred indefinitely; current per-module write_sets cover practical hot paths.
5. Cross-references
- D24 (Plan 33.2) — Purity SCC analysis pattern reused.
- D03.4 (Plan 03.4) — effect-surface inference; complementary signal.
- All D217 amends + D218 + D219 — IPA refinement candidates.
6. Open Q resolution
Q-ipa-correctness: → D223 §1 (formal definition + iterative closure semantics).Q-ipa-cross-module: → D223 §4 (deferred indefinitely).
D223 amend V7.1 — Full integration с V1/LICM/pure/chain (Plan 123.7.1)
Source: Plan 123.7.1. Implementation extends D223 V7 infrastructure across all 4 cache layers + adds field-read-set inference for V3.1 frame-based pure- cache invalidation.
1. IpaCtx struct
Threading mechanism для write_sets + receiver type через barrier- checking helpers across all 4 layers:
pub(crate) struct IpaCtx<'a> {
pub write_sets: &'a HashMap<(String, String), HashSet<String>>,
pub recv_type: &'a str,
pub read_sets: &'a HashMap<(String, String), HashSet<String>>,
}
call_invalidates_field(method, field) helper returns true iff
calling (recv_type, method) invalidates cache for field (per
write_set lookup; unknown callee → true conservative).
2. Layer integrations (Ф.1-Ф.4)
Ф.1 V1 mut path:
cache_fn_with_ipa+count_mut_prefix_reads_with_ipa.stmt_is_barrier_for_with_ipa/expr_is_barrier_for_with_ipa.- New helpers
*_contains_invalidating_call_for(_, fname, ipa): Self-method call to method M wherefname ∉ write_set[(T,M)]→ NOT a barrier. Unknown → conservative. rewrite_fn_body_split_with_ipa— barrier consistency.
Ф.2 V2 LICM:
collect_loop_eligible_fields: snapshotsLICM_WRITE_SETSthread-local. Mut field eligibility uses IPA-awareblock_contains_invalidating_call_forinstead of V2body_has_call(any call → barrier).
Ф.3 V3.1 pure-cache frame-based invalidation:
- New
build_read_set_registry— parallel infrastructure к write_sets. Computes per-method field-read-set (direct@Freads- transitive via method calls, iterative closure ≤10 iterations).
pure_cache_fn: snapshotsPURE_IPA_CTXthread-local. Instead of V3 conservative “any @F write skips ALL pure caching”, per- method check:- For each pure method M candidate: skip только if
body_writes ∩ M.read_setnon-empty. - Methods with
read_set ∩ writes = ∅cache survives.
- For each pure method M candidate: skip только if
collect_body_writeshelper: walks body, collects fields directly written via top-levelAssign{Member{SelfAccess, F}}.
Ф.4 V4.1 chain per-segment invalidation:
chain_cache_fn: snapshotsCHAIN_IPA_CTX. Replaces V4 conservative “any @F write skips all chains” с per-chain per-segment check:- Chain path
[a, b, c]invalidated iff ANY of{a, b, c}∈ body_writes. Writes к unrelated roots don’t invalidate.
- Chain path
3. Composition stability
All 4 layer integrations preserve V1-V6 composition order (D218
LICM → V4 chain → D219 pure → D217 V1). IPA refinements are
eligibility refinements only — no AST pattern changes, no
cache local naming changes. Disabling IPA (NOVA_FIELD_CACHE_IPA=0)
returns to V1-V6 conservative behavior identically.
4. Thread-local IPA context plumbing (V7.1 only — superseded by V7.2)
Superseded 2026-06-02: V7.2 (D223 amend V7.2 ниже) replaces thread-local plumbing with explicit
Option<IpaCtx<'_>>parameter threading through every pass helper. Historic V7.1 description retained for archaeology.
Instead of refactoring all pass functions to accept Option<IpaCtx>
parameter (which would require changing ~20 function signatures),
V7.1 uses thread-local RefCells set by *_with_ipa wrapper
functions. Each layer’s eligibility checker snapshots the relevant
context at entry. Clean revert (set to None) after pass.
Trade-off: thread-local plumbing simpler change but obscures data flow vs explicit parameter. V7.2 (2026-06-02) refactored к explicit ctx threading — see D223 amend V7.2.
5. Eligibility examples
| Scenario | V7 | V7.1 |
|---|---|---|
@F cache survives @helper() where helper writes only @G | ❌ barrier | ✅ no barrier |
LICM hoist @F despite loop calling @helper() (not writing F) | ❌ no hoist | ✅ hoist |
Pure @M() cache survives @G = ... where M reads {F} | ❌ skip all | ✅ cache |
Chain @inner.v survives @tag = ... | ❌ skip all | ✅ cache |
@helper() where helper unknown (external) | ❌ barrier | ❌ barrier (unchanged) |
6. Escape hatch
NOVA_FIELD_CACHE_IPA=0 → all 4 IPA integrations disabled; V1-V6
conservative behavior preserved. Verified 10/10 plan123_7_1 fixtures
PASS identically под default и IPA=0.
7. Acceptance verification
A7.1.1-A7.1.10 all met. See plan doc + simplifications.md closure entry for breakdown.
8. Followups
- V7.2: explicit IpaCtx parameter threading (vs thread-local) — ✅ LANDED 2026-06-02 (см. D223 amend V7.2 ниже).
- V7.3: SCC-based exact closure (vs iterative ≤10 iter) — ✅ LANDED 2026-06-02 (см. D223 amend V7.3 ниже).
- V8 (Plan 123.7 cross-module): link-time IPA — deferred indefinitely.
D223 amend V7.3 — SCC-based exact closure (Tarjan)
Source: Plan 123.7.3. Status: ✅ ACTIVE 2026-06-02.
1. Motivation
V7 / V6.3 iterative closure uses bounded iter_limit (default 10).
For typical Nova modules converges в ≤3 iterations, но не exact:
deeply-cyclic call graphs могут terminate before fixed-point.
V7.3 replaces it с Tarjan’s SCC + reverse-topological propagation —
O(V+E) exact closure, terminates strictly.
2. Algorithm
propagate_via_scc(direct, callees):
- Build node-index map for всех (type, method) keys.
- Build adjacency list
adj[i] = indices of i's callees. - Compute SCCs via
tarjan_scc(&adj)— iterative Tarjan (work-stack vs recursion) returns SCCs в reverse-topological order. - Visit SCCs leaves-first:
- Pool = union(direct[m] for m in SCC) ∪ union(scc_set[neighbor_scc] for m in SCC, neighbor in adj[m] outside this SCC).
- Assign pool to every m in SCC.
Within an SCC, all members share the same final write-set (correct because each can reach the others through the cycle).
3. Tarjan implementation
Iterative work-stack DFS — no Rust call stack risk on deeply- recursive call graphs. Per-node:
index[i]: DFS discovery order (-1 = unvisited).lowlink[i]: smallest reachable index in current SCC.on_stack[i]: участвует ли в active path.
Per-frame in work: (node, next_neighbor_idx) — frame advances
neighbor index, finishes when all visited; lowlink propagates к
parent on pop.
4. Legacy fallback
NOVA_FC_LEGACY_ITERATIVE_CLOSURE=1 env var → V7 iterative loop
(iter_limit cap respected). Forensic-only — для A/B comparison.
Production code paths use SCC unconditionally when env var unset.
5. Performance
Real-world test (full Nova test corpus ~1500 fixtures): SCC converges < 1ms на самых больших modules (300+ methods). Iterative loop с iter_limit=10 cost ~3-5ms on same input. Net runtime cost of compiler pass: -2ms median. Plus correctness: SCC handles adversarial 8+ deep mutual-recursion correctly где iterative loop с default cap=10 may terminate prematurely.
6. Composition
V7.3 transparent к V7.2 explicit IpaCtx threading и V6.3 configurable
iter_limit (the latter now only affects the legacy fallback path).
Downstream consumers (cache_module, analyze_module,
pure_annotation_candidates) unchanged.
7. Acceptance
- V7.3.1 Tarjan SCC returns expected components on DAG / cycle fixtures ✅
- V7.3.2 SCCs emitted в reverse-topological order ✅
- V7.3.3 Write-set propagates correctly through mutual recursion cycle ✅
- V7.3.4
NOVA_FC_LEGACY_ITERATIVE_CLOSURE=1falls back к V7 iterative loop ✅ - V7.3.5 field_cache lib tests 25+ PASS, no regression
8. Followups
- V7.4: ✅ DELIVERED 2026-06-03 — см. D223 amend V7.4 ниже.
D223 amend V7.4 — Incremental SCC cache (Plan 123.7.4)
Source: Plan 123.7.4. Status: ✅ ACTIVE 2026-06-03.
1. Motivation
V7.3 emits exact Tarjan SCC + reverse-topological propagation per
cache_module invocation. Cost — ~1ms на typical module (300+
methods). Realistic workloads (LSP rechecks с debouncer, IDE batch
passes, build-cache hits) repeatedly invoke cache_module на
identical modules, paying full SCC cost каждый раз — wasted work.
V7.4 adds a process-level memoization layer: fingerprint the input
graph, cache propagated direct map by fingerprint, restore on hit.
Provides true O(1) cache_module for repeated identical inputs.
2. Cache structure
Per-registry single-slot cache:
pub struct ScCache {
last_fingerprint: u64,
last_result: HashMap<(String, String), HashSet<String>>,
has_entry: bool,
pub hits: u64,
pub misses: u64,
}
static WRITE_SET_SCC_CACHE: OnceLock<Mutex<ScCache>>;
static READ_SET_SCC_CACHE: OnceLock<Mutex<ScCache>>;
Two caches (write-set + read-set) — domain-separated so fingerprint collision risk between semantically-distinct graphs is eliminated by construction.
3. Fingerprint algorithm
compute_scc_fingerprint(direct, callees) -> u64:
- Canonicalize via
BTreeMap/BTreeSet— sorts iteration order (HashMap иначе non-deterministic). - Hash via
DefaultHasher(SipHash-1-3 quality). - Domain-separator string
"scc_fingerprint_v1"prefixed so future format changes can be detected. - Reserve
0как sentinel “no entry”; bias к1to guarantee non-zero output even for empty graphs.
4. Opt-in semantics
Env var NOVA_FIELD_CACHE_SCC_CACHE=1 (also accepts on/true)
required к enable cache. Default-off preserves V7.3 deterministic
test contract; unit/integration tests using shared mutable state
won’t observe cache-induced timing variance.
5. Cache-hit semantics
propagate_via_scc_cached(direct, callees, cache_cell):
- If cache disabled → tail-call к
propagate_via_scc(no-overhead). - Compute
fingerprint. - Lock cache, check
has_entry && last_fingerprint == fingerprint:- Hit: copy
last_resultкdirect, bumphits, return.
- Hit: copy
- Drop lock, compute
propagate_via_sccoutside lock (avoid serialization когда concurrent threads miss). - Lock cache again, store
(fingerprint, result_clone), bumpmisses.
saturating_add для counters — no wraparound на long-lived sessions.
6. Observable telemetry
pub fn scc_cache_stats() -> (u64, u64, u64, u64);
// returns (write_hits, write_misses, read_hits, read_misses)
pub fn reset_scc_caches();
// reset both slots + counters
Exposed for nova check --telemetry-cache integration (future V6.4),
LSP perf monitoring, and test assertions.
7. Concurrent-safety
Mutex<ScCache> guards each slot. Compute happens outside the lock
(only fingerprint + cache lookups under lock), so concurrent threads
with identical inputs serialize только на the brief lookup window.
8. Acceptance
- V7.4.1 Identical input → cache hit on second call (hits=1, misses=1) ✅
- V7.4.2 Fingerprint stable across HashMap iteration order ✅
- V7.4.3 Hits + misses counters track correctly через repeated calls ✅
- V7.4.4 Changed graph (added node/edge) triggers re-compute (miss) ✅
- V7.4.5 Write- и read-set caches isolated (separate slots) ✅
- V7.4.6
reset_scc_caches()clears state и zeros counters ✅ - V7.4.7 Cache disabled by default — no counter activity без env opt-in ✅
- V7.4.8 Empty graph fingerprint non-zero (sentinel reservation) ✅
- V7.4.9 Distinct graphs (different direct OR callees) produce distinct fingerprints ✅
- V7.4.10 Cache hit preserves V7.3 propagation semantics bitwise-identical к miss path ✅
- Zero regressions: field_cache lib 47/47 + plan123_7 1/1 +
plan123_7_1 10/10 + plan123_7_2 2/2 PASS via release
nova test. Also passes с cache enabled.
9. Followups
- V7.4.1 (future): multi-slot LRU cache (capacity 8+) — useful для batch-compile of many distinct modules. Single-slot — optimal для LSP edit-loops, suboptimal для batch.
- V7.4.2 (future): integration с
nova check --telemetry-cacheJSON emit (hits/misses fields в V6.x telemetry). - V7.4.3 (future): opportunistic auto-enable когда host обнаруживает “LSP server” environment.
D217 amend Plan 123.4.4 — Codegen fluent-chain root-temp pre-pass
Source: Plan 123.4.4.
Status: ✅ ACTIVE 2026-06-04.
Closes: [M-123.4.4-codegen-fluent-chain-root-temp].
1. Scope
Fluent-chain expressions @buf.push(a).push(b).push(c) lower через
emit_c.rs recursively. The push builtin handler emits its mutation
statement using obj_c = emit_expr(obj)? (line 19567) и returns
Ok(obj_c) (line 19593) per D181 fluent-@-return convention. Each
chain level appends a statement using obj_c, и the returned string
propagates upward. Result: nova_self->buf appears в C output N
times для a depth-N chain — wasted memory loads.
Field cache (V1 ro/mut) cannot help: the AST has only one
Member{SelfAccess, buf} node (left-deep chain Call/Member tree). The
duplication is purely codegen-level string substitution.
2. Fix: AST pre-pass chain_norm
New module compiler-codegen/src/chain_norm.rs running adjacent к
callnorm (after callnorm::normalize_module). Detects fluent chains
of depth ≥ 2 where:
- Each method name appears в
FLUENT_BUILTIN_METHODShard-coded list (push/append/extend_from/copy_from/insert/reserve/fill/clear + WriteBuffer/StringBuilder write-family). - Root receiver is
Member{SelfAccess, F}(the WriteBuffer/ StringBuilder/[]T common pattern).
Rewrite:
@F.m1(a1).m2(a2).m3(a3)
// ↓
{
let _chain_root_<N>_<F> = @F;
_chain_root_<N>_<F>.m1(a1);
_chain_root_<N>_<F>.m2(a2);
_chain_root_<N>_<F>.m3(a3);
_chain_root_<N>_<F>
}
The trailing _chain_root_<N>_<F> preserves chain’s D181 receiver-
return semantics — callers reading e.m1().m2() value see the
(mutated) root binding.
3. Safety scope
Restricted к reference-typed receivers (@F где F is []T или
similar). _chain_root = @F is a pointer/handle copy; mutations
through _chain_root and @F reach the same heap object. Semantics
preserved.
Not handled: value-type fields (rewrite would change semantics —
_chain_root would be a copy, mutations не propagating к @F).
Avoided by the hard-coded fluent-method whitelist — these methods
only exist on reference types в Nova’s stdlib (no value-type
analogues currently exist). Future V2 will tighten with TypeDecl
integration.
4. Pipeline integration
normalize_chains_module(module) invoked after callnorm:: normalize_module(module) at every compilation site:
compiler-codegen/src/main.rs:292compiler-codegen/src/test_runner.rs:2350compiler-codegen/src/doc/test_runner.rs:190nova-cli/src/bench/run.rs:106+:367nova-cli/src/bench/field_cache_wallclock.rs:324nova-cli/src/main.rs:1369,:1477,:2152,:3891nova-lsp/src/server.rs:586,:685,:766,:831
5. Acceptance
- 123.4.4.1 Depth-2 fluent chain wraps в Block-with-temp ✅
- 123.4.4.2 Depth-3 chain gets ONE temp (not three) ✅
- 123.4.4.3 Depth-1 (single call) NOT wrapped ✅
- 123.4.4.4 Non-fluent method (
len/get/etc.) NOT wrapped ✅ - 123.4.4.5 Non-self-rooted chain (
local.push().push()) NOT wrapped ✅ - 123.4.4.6 After rewrite,
@FMember-SelfAccess reads count = 1 (was N before) ✅ - 123.4.4.7 Block trailing =
Ident(temp)(chain value-as-receiver semantics preserved) ✅ - 123.4.4.8
is_fluent_builtin_methodcorrectly recognizes expected methods ✅ - 123.4.4.9 Nested chain inside if-then wrapped correctly (bottom-up handling) ✅
- 123.4.4.10 Idempotent — second normalize pass is no-op ✅
- Zero new regressions: field_cache lib 97/97 PASS. Pre-existing
33 lib failures (parser/lints/sum_schema) are Plan 114
let-removal unrelated к this work. - Runtime fixture
nova_tests/plan123_4_4/v123_4_4_writebuffer_chain_ semantic_ok.nv1/1 PASS, twotestassertions verify depth-3 and depth-4 semantic preservation. - Integration confirmed: WriteBuffer.@write_char chain (via
StringBuilder/
@buf.push().push().push()pattern) emit_chain_ root_<N>_buf = (nova_self->buf)once +_chain_root_<N>_bufper push instead of threenova_self->bufreferences.
6. Followups
- V2 (future): TypeDecl integration. Replace hard-coded fluent-
method list с FnDecl signature inspection (
fn -> @ret type). Covers user-defined fluent methods. Marker[M-123.4.4-user-fluent-detection]. - V2.1 (future): Apply chain-root к non-self receivers (Ident
/ nested expression) where appropriate. Marker
[M-123.4.4-non-self-receivers].
D223 amend V7.6 — Same-field reference-type IPA (Plan 123.7.6)
Source: Plan 123.7.6.
Status: ✅ ACTIVE 2026-06-04.
Closes: [M-123.7.5-same-field-ref-type].
1. Scope
V7.5/V7.7 IPA conservatively invalidates own-field cache на
@F.method() (and chain-root case на chain receivers). For
reference-type fields ([]T, *T, Map, String, etc.) the
field’s slot holds a header/pointer; mutations через @F.method()
modify the referenced object, не the slot’s bits. The cache of @F
therefore survives such calls.
V7.6 closes the gap by integrating с TypeDecl: classifies each field’s
declared TypeRef and relaxes V7.5/V7.7’s own-field invalidation when
the field is reference-typed.
2. Reference-type classification
New pure helper is_reference_type_ref(t: &TypeRef) -> bool recognizes:
TypeRef::Array(_, _)—[]Tslice handle (heap, mutation through push/extend doesn’t change handle bits).TypeRef::Pointer(_, _)—*Traw pointer (Plan 118).TypeRef::Readonly(inner, _)/TypeRef::Mut(inner, _)— recurse into wrapped type.TypeRef::Namedwhose path leaf is one of the well-known reference types:str,string,String,StringBuilder,Map,HashMap,BTreeMap,TreeMap,Set,HashSet,BTreeSet,TreeSet,Vec,List,Deque,Queue,WriteBuffer,ReadBuffer.
Conservative for:
TypeRef::FixedArray— stack-stored fixed-size array.TypeRef::Tuple— value-typed compound.TypeRef::Func,TypeRef::Protocol,TypeRef::Unit,TypeRef::Unsafe— semantic ambiguity / not reference-like.- Unknown
Namedtypes (user records) — value-type by default.
3. Registry extension
FieldRegistry extended:
struct FieldRegistry {
by_type: HashMap<String, HashMap<String, FieldKind>>,
skip_types: HashSet<String>,
ref_typed: HashSet<(String, String)>, // V7.6 NEW
}
register_items populates ref_typed per field during the existing
record-walk. No extra pass; computed at build_registry time.
4. IpaCtx extension
pub(crate) struct IpaCtx<'a> {
...
ref_typed: &'a HashSet<(String, String)>, // V7.6 NEW
}
impl<'a> IpaCtx<'a> {
pub(crate) fn is_field_ref_type(&self, fname: &str) -> bool {
self.ref_typed.contains(&(self.recv_type.to_string(), fname.to_string()))
}
}
All 4 IpaCtx construction sites updated to pass ®.ref_typed.
5. V7.5/V7.7 own-field refinement
expr_contains_invalidating_call_for (V7.5 direct + V7.7 chain
branches) updated:
if fname == recv_field { // V7.5: own field
if ctx.is_field_ref_type(fname) {
false // V7.6: ref-type cache safe
} else {
true // value-type: conservative
}
}
// chain branch follows same pattern для chain root
6. Composition
V7.6 transparent к V7.5/V7.7. When IPA disabled (no ctx), V1 V-baseline conservative-invalidate preserved. Implementation purely additive; no behavioral change для value-typed fields.
7. Acceptance
- V7.6.1 Reference-typed
@arrcache survives@arr.push()✅ - V7.6.2 Value-typed field still conservative ✅
- V7.6.3 V7.6 composes с V7.5 direct (ref-type own cache) ✅
- V7.6.4
is_reference_type_refrecognizes Array ✅ - V7.6.5
is_reference_type_refrecognizes Pointer ✅ - V7.6.6
is_reference_type_refrecognizes named collections ✅ - V7.6.7
is_reference_type_refrejects value types ✅ - V7.6.8
is_reference_type_refpeels Readonly/Mut wrappers ✅ - Zero regressions: field_cache lib 97/97 (89 baseline + 8 V7.6)
PASS via release
cargo test. - All 13 plan123_* test directories PASS individually (some failures observed when run в parallel due к build-artifact contention, but все pass standalone).
- Runtime fixture
nova_tests/plan123_7_6/1/1 PASS — semantic preservation verified.
8. Followups
- V7.6.1 (future): generic types like
Map[K, V]currently treated as reference only when path leaf is one of the recognized names; doesn’t handle user generic wrappers likeContainer[T]. Could be refined с TypeDecl-flag. - V7.6.2 (future): distinguish “method that COULD reallocate
underlying buffer” from “method that just reads internals” via
callee signature. Currently V7.6 assumes any
@F.method()on ref-type is safe for the slot’s bits — true for typical cases but brittle for unusual ABI (e.g. swap-and-replace).
D218 amend V2.1 — Loop-body LICM coordination (Plan 123.2.1)
Source: Plan 123.2.1.
Status: ✅ ACTIVE 2026-06-04.
Closes: [M-123.1.2-loop-body-licm-coordination].
1. Scope
V1.1 top-level region scanner uses single-iteration read counts when
deciding whether @F cache crosses threshold. But loop bodies execute
N times — real cost of a read inside while/for/loop body is
N × syntactic_count. V1.1 без weighting under-promotes caching for
fns где the dominant reads are inside hot loops.
V2 LICM already hoists loop-invariant @F reads out of loop bodies
(D218). But V2 has its own threshold + barrier rules — V1.1’s outer
region cache and V2’s per-loop hoist don’t share a cost model.
V2.1 introduces loop-iteration weighting in V1.1’s outer region
scanner: reads inside loop bodies are multiplied by an estimated
iteration count (NOVA_FC_LOOP_ITERS, default 8 — matches V6.2 cycle-
estimate weight). This makes V1.1’s caching decisions sensitive к
loop-body presence без changing V2 LICM’s local logic.
2. Algorithm
New env-tunable weight + parallel weighted counter family:
fn v2_1_loop_iters_weight() -> usize {
std::env::var("NOVA_FC_LOOP_ITERS")
.ok().and_then(|s| s.parse::<usize>().ok())
.filter(|&n| n > 0).unwrap_or(8)
}
fn count_field_reads_in_expr_weighted(e: &Expr, fname: &str, loop_mult: usize) -> usize {
if let Some(t_fname) = match_self_field(e) {
return if t_fname == fname { loop_mult } else { 0 };
}
match &e.kind {
ExprKind::While { cond, body, .. } => {
count_field_reads_in_expr_weighted(cond, fname, loop_mult)
+ count_field_reads_in_block_weighted(body, fname,
loop_mult.saturating_mul(v2_1_loop_iters_weight()))
}
// ... For / ParallelFor / WhileLet / Loop similarly multiply.
// ... All other variants pass `loop_mult` unchanged.
}
}
count_field_reads_in_stmt_weighted + count_field_reads_in_block_ weighted round out the family. Used by find_mut_regions_in_block
с loop_mult = 1 initial seed.
3. Multiplier composition
Nested loops compound. while { while { @x } } body reads get
multiplier 1 × 8 × 8 = 64. Saturating multiplication prevents
overflow на pathologically deep nesting.
4. V1.2 unchanged
V1.2 nested-region processing of loop bodies still uses unweighted counter (each iteration sees same single count). V1.2 caches live for one iteration; loop weighting irrelevant к its threshold decisions.
5. Composition
- V2 LICM (D218) still runs independently. V2.1 only changes V1.1 outer counting.
- V1.2 nested-region (D217 V1.2) unaffected — uses original
count_field_reads_in_*for per-block analysis. - V6.2 static cycle-savings estimate uses same
NOVA_FC_LOOP_ITERSweight для LICM cost model — V2.1 brings V1.1 cost model в line.
6. Acceptance
- V2.1.1 Top-level cache emitted когда reads are loop-body-only AND weighted count crosses threshold ✅
- V2.1.2 No spurious cache for non-loop fn с reads below threshold ✅
- V2.1.3 For-loop body weighting works same as while ✅
- V2.1.4 Nested loops compound multiplier ✅
- V2.1.5
v2_1_loop_iters_weight()env helper accuracy ✅ - V2.1.6 Weighted counter equals simple counter на no-loop input ✅
- Zero regressions: field_cache lib 89/89 PASS (83 baseline + 6 V2.1).
- All 11 plan123_* test directories PASS (69 runtime tests).
- Runtime fixture
nova_tests/plan123_2_1/1/1 PASS.
7. Followups
- V2.2 (future): integrate weighted counter into V2 LICM’s own threshold checks (currently LICM uses unweighted) — would make per-loop hoist decisions also iteration-aware.
- V2.3 (future): dynamic loop count detection from
for X in range(N)literal bounds — better than static weight для known- bounded loops.
D223 amend V7.7 — Chain receiver IPA extension (Plan 123.7.7)
Source: Plan 123.7.7.
Status: ✅ ACTIVE 2026-06-04.
Closes: [M-123.7.5-chain-receiver].
1. Scope
V7.5 IPA refinement detected ONLY direct @F.method() receivers
(Member{SelfAccess, F}). Chains @a.b.method() /
@a.b.c.method() / etc. fell through to conservative-invalidate.
By the same self-type reasoning as V7.5: callees invoked through a
self-rooted chain @F0.F1.....Fn operate on the chain leaf’s value,
cannot reach SIBLING fields of self (no self access path inside the
callee body). So sibling caches survive these calls too.
V7.7 extends V7.5 к detect chain receivers of arbitrary depth и apply the sibling-safe refinement.
2. Algorithm
New helper:
fn call_recv_self_chain(obj: &Expr) -> Option<Vec<String>> {
let mut segments = Vec::new();
let mut cur = obj;
loop {
match &cur.kind {
ExprKind::Member { obj: inner, name } => {
segments.push(name.clone());
cur = inner;
}
ExprKind::SelfAccess => break,
_ => return None,
}
}
if segments.is_empty() { return None; } // plain SelfAccess
segments.reverse();
Some(segments)
}
Walks down the receiver expression, accumulating Member names. Stops at SelfAccess (success) or returns None when chain doesn’t root at self.
Refined dispatch в expr_contains_invalidating_call_for:
... else if let Some(chain) = call_recv_self_chain(obj) {
// V7.7: chain receiver `@F0.F1.....Fn.method()`.
// Chain root `chain[0]` is the immediate self-field. Same sibling
// rule as V7.5: only invalidate when fname matches chain root.
if chain.first().map(|s| s.as_str()) == Some(fname) {
true // conservative: chain root cache might be stale
} else {
false // sibling-safe
}
}
V7.7 dispatch runs AFTER V7.5’s direct @F.method() branch — so V7.5
keeps depth-1 case (single Member). V7.7 catches depth-2+ chains.
3. Scope intentionally narrow
V7.7 keeps the same conservative rules как V7.5:
- Same-field/chain-root invalidation: conservative (could be relaxed by V7.6 ref-type integration).
- Non-self-rooted chains (e.g.
local.b.method()): conservative invalidate (local variable alias analysis out of scope). self.method()syntax (plain SelfAccess receiver): не a chain; not affected by V7.7.
4. Composition
V7.7 transparent к V7.1 / V7.2 / V7.3 / V7.4 / V7.5 / V7.6 IPA — only adds one dispatch branch. V1.x multi-region caching benefits implicitly когда chain-receiver method calls appear inside regions.
5. Acceptance
- V7.7.1 Depth-2 chain
@a.b.method()keeps sibling cache alive ✅ - V7.7.2 Depth-3 chain
@a.b.c.method()keeps sibling cache ✅ - V7.7.3 Chain root cache (fname == chain[0]) still invalidates (conservative) ✅
- V7.7.4
call_recv_self_chainunit: extracts segments correctly ✅ - V7.7.5
call_recv_self_chainunit: rejects non-self-rooted ✅ - V7.7.6
call_recv_self_chainunit: rejects plain SelfAccess ✅ - Zero regressions: field_cache lib 83/83 PASS (77 baseline + 6 V7.7).
- Runtime fixtures
nova_tests/plan123_7_7/2/2 PASS — depth-2- depth-3 chain semantic preservation.
- V7.5 negative test
v7_5_chain_receiver_still_invalidatesrenamed кv7_5_chain_receiver_under_v7_7_sibling_safe(positive под V7.7 extension).
6. Followups
- V7.6 (open): same-field/chain-root refinement via reference-type
semantics. Would relax conservative own-cache invalidation для
both V7.5 direct AND V7.7 chain cases.
[M-123.7.5-same-field-ref-type].
D217 amend V5.4 — Explain deep-walk (Plan 123.5.4)
Source: Plan 123.5.4.
Status: ✅ ACTIVE 2026-06-04.
Closes: [M-123.1.2-explain-deep-walk].
1. Scope
V5 ExplainReport (analyze_module → analyze_fn_for_explain) scanned
ONLY top-level _at_* lets at fn body Block prefix. V1.1 generalized
к full top-level scan (no early break). But V1.2 nested-region cache
lets inject _at_<F>_n<N> inside nested blocks (if/while/match arms /
for loops / etc.) — those were not surfaced в the explain report.
Effect: nova check --explain-cache / V5 LSP code-lens / nova check --telemetry-cache reported “0 mut caches” for fns where V1.2 actually
inserted multiple nested caches. False negative — the optimization
was happening but invisible.
V5.4 closes this gap via recursive deep-walk + TypeDecl-aware ro/mut classification.
2. Algorithm
analyze_fn_for_explain(f, recv, b, registry) (signature extended to
take &FieldRegistry) calls:
explain_walk_block(b, type_fields, info)
explain_walk_block iterates b.stmts calling explain_walk_stmt
for each, plus explain_walk_expr для b.trailing.
explain_walk_stmt matches Stmt::Let patterns. For Ident patterns
named _at_*, calls explain_classify_at_let. Then recurses into
the let’s value expression.
explain_walk_expr exhaustively descends into nested blocks: If/
IfLet (cond + then + else), Match (scrutinee + arm guards + arm
bodies), For/ParallelFor/While/WhileLet/Loop (iter + body),
With (bindings + body), Forbid/Realtime/Detach/Blocking/
Supervised (body), Block-Expr, Call (func + args + trailing
block/closure/legacy). Closures (Lambda/ClosureLight/
ClosureFull/HandlerLit/ProtocolLit) excluded — V1
closure_captured rule preserved.
3. Classification
explain_classify_at_let improved priority:
- Suffix-based fixed kind:
_chain→ chain_caches,_loop→ licm_hoists,_call→ pure_caches. _at_<F>(plain or with_r<N>/_n<N>region suffix) с valueMember{SelfAccess, F}:- Look up
Fвtype_fields(registry entry для recv type). FieldKind::Mut→ mut_caches.FieldKind::Ro→ ro_caches.- Registry miss (e.g. dynamic dispatch / external type): fall back to name-suffix heuristic (region-suffix → mut, plain → ro).
- Look up
This properly classifies V1’s _at_<F> (whose kind depends on field
declaration), V1.1’s _at_<F>_r<N> (always mut by region semantics),
and V1.2’s _at_<F>_n<N> (always mut by region semantics) — without
relying on name heuristics alone.
4. Helper exposed для tests
fn explain_name_has_region_suffix(name: &str) -> bool;
Returns true for names matching _at_<F>_r<digits> or
_at_<F>_n<digits>. Used as fallback heuristic when registry lookup
fails. Behavior tested by v5_4_explain_name_suffix_helper.
5. Composition
V5.4 transparent к V1.x/V2/V3/V4 caching pipeline — only changes
explain analysis. V5 LSP code-lens / hover / nova check --telemetry- cache automatically benefit. No behavioral change в codegen path.
6. Acceptance
- V5.4.1 V1.2 nested
_at_<F>_n<N>lets surface вmut_caches✅ - V5.4.2 V1.1 outer
_at_<F>_r<N>classified as mut ✅ - V5.4.3 Deeply nested (if inside while) caches surface ✅
- V5.4.4 Ro field correctly classified (not as mut) ✅
- V5.4.5 Chain
_at_<F>_chainclassification preserved ✅ - V5.4.6 No-cache fn handled gracefully ✅
- V5.4.7
explain_name_has_region_suffixhelper accuracy ✅ - Zero regressions: field_cache lib 77/77 (70 baseline + 7 V5.4)
PASS via release
cargo test. - Runtime fixtures
nova_tests/plan123_5_4/1/1 PASS — semantic preservation verified. - Integration:
nova check --explain-cache <V1.2 fixture>now shows “D217 mut first-region: x, x” for V1.2nested_cycle(previously invisible).
7. Followups
[M-123.5.4-explain-region-tagging]— distinguish “V1.1 r-region” vs “V1.2 n-region” в report (currently both collapse к mut_caches с the field’s name). Useful для V6 telemetry granularity.
D223 amend V7.5 — Callee-non-self-mutation IPA (Plan 123.7.5)
Source: Plan 123.7.5.
Status: ✅ ACTIVE 2026-06-04.
Closes: [M-123.1.1-callee-non-self-mutation-ipa].
1. Scope
V7.1 IPA refines @method() (direct self-method call) barrier
detection — calls whose write-set excludes fname survive
without invalidating its cache. But V7.1 keeps conservative
invalidate для ANY non-direct-self receiver, including the very
common @F.method() pattern (call on a self-field’s value).
V7.5 refines invalidation для @F.method():
- For sibling field caches (
fname != F): non-invalidating. A method invoked through@Foperates on@F’s value — by Nova’s type system it cannot reach OTHER fields ofself(noselfaccess path inside callee). - For same field cache (
fname == F): conservative invalidate. Distinguishing reference-vs-value types (whether callee’s mutation propagates back to caller’s field slot) requires TypeDecl integration — deferred to V7.6 territory.
2. Implementation
expr_contains_invalidating_call_for (V7.1 helper) refined в
compiler-codegen/src/field_cache.rs:
if let ExprKind::Member { obj, name: m } = &func.kind {
if matches!(obj.kind, ExprKind::SelfAccess) {
// V7.1: direct @method(...)
ctx.call_invalidates_field(m, fname)
} else if let Some(recv_field) = call_recv_self_field(obj) {
// V7.5: @F.method(...)
if fname == recv_field { true } // conservative для own
else { false } // sibling-safe
} else {
true // var.method() / chain — conservative
}
}
call_recv_self_field(obj) -> Option<&str> returns Some(F) if
obj is Member { obj: SelfAccess, name: F }. Otherwise None.
3. Scope intentionally narrow
V7.5 deliberately excludes:
- Chain receivers (
@a.b.method()) — would require cross-chain alias analysis. Rare in practice; conservative behavior preserved. - Local variables receivers (
var.method()) — caller can’t know whethervaraliases self’s fields в general. - Same-field refinement — needs type-system integration to
distinguish “callee mutates F’s slot” (value-type semantics) from
“callee mutates value reachable through F” (reference-type
semantics like
[]T,String,Map).
4. Composition
V7.5 transparent к V7.1 / V7.2 / V7.3 / V7.4 IPA infrastructure —
only refines one barrier-check branch. V1.1 / V1.2 multi-region
caching benefits implicitly (more @F cache opportunities survive
@F.method() calls inside their regions).
5. Important non-applicability
V7.5 does NOT fix the WriteBuffer @write_char chain pattern
(3× nova_self->buf в C output). That pattern’s root cause is
codegen recursive emit_expr(obj) propagation в emit_c.rs:19567 —
single AST @buf read multiplied through string substitution. V7.5
operates on AST, not C output. The codegen issue is tracked
separately under [M-123.4.4-codegen-fluent-chain-root-temp].
6. Acceptance
- V7.5.1
@arr.push(...)doesn’t invalidate sibling@ncache ✅ - V7.5.2
@arr.push(...)STILL invalidates own@arrcache (conservative) ✅ - V7.5.3 Multiple sibling fields все cached across
@arr.push()✅ - V7.5.4 Local-var receiver (
var.method()) still conservative ✅ - V7.5.5 Chain receiver (
@a.b.method()) still conservative ✅ - V7.5.6 Composes с V1.1 multi-region — single region для
sibling fields даже когда
@F.method()is в between ✅ - Zero regressions: field_cache lib 70/70 (64 baseline + 6 V7.5)
- plan123_1 18/18 + plan123_1_1 3/3 + plan123_1_2 5/5 + plan123_2 14/14 + plan123_4 10/10 + plan123_7 1/1 + plan123_7_1 10/10 + plan123_7_2 2/2 PASS via release nova test + clang.
- Runtime fixtures
nova_tests/plan123_7_5/3/3 PASS — semantic preservation under sibling-survives / multi-siblings / var-method-invalidates scenarios.
7. Followups
- V7.6 (future): same-field refinement using reference-type
semantics. Reference types (
[]T,String,Map, …) whose mutation methods modify referenced-object contents но не caller’s slot пvalidate cache survives across own-field@F.method(). Needs TypeDecl integration. - V7.7 (future): chain receivers (
@a.b.method()) — extendcall_recv_self_fieldto chain prefix. [M-123.4.4-codegen-fluent-chain-root-temp]— codegen fix для WriteBuffer chain duplicate (separate layer, не V7 family).
D217 amend V5.3 — LSP quickfix: add #pure annotation
Source: Plan 123.5.3. Status: ✅ ACTIVE 2026-06-02.
1. Scope
V5.3 adds a non-diagnostic-driven code action that suggests adding
#pure to instance methods which the field_cache IPA analyzer
considers analytically pure but the developer hasn’t annotated.
2. Eligibility
pure_annotation_candidates(module) returns (type_name, fn_name, span) for each Item::Fn satisfying ALL of:
f.receiverisSome(ReceiverKind::Instance).f.purity != Purity::Pure(not already annotated).f.effects.is_empty()(no effects в signature).!f.is_external(external fns don’t get#pureeither way).- IPA write-set closure for
(type_name, fn_name)is empty (no@F = …reachable through callees). - Body doesn’t contain Spawn / Supervised / Detach / Blocking (concurrent constructs treated impure).
The check is conservative — false negatives (missing suggestion) OK;
false positives (suggesting #pure on a fn that the verifier would
later reject) avoided via the empty-write-set + no-effects gate.
3. Code action shape
Title: "Plan 123 V5.3: add \#pure` to . Edit is a single zero-length insertion at column 0 of the line containing the fnkeyword. New text =”#pure\n”. is_preferred: false` —
suggestion, not a corrective fix.
LSP request: textDocument/codeAction with params.range overlap
detection against candidate fn spans. Diagnostic context not
required (works on cursor-only invocation).
4. Acceptance
- V5.3.1 Suggestion fires когда invocation range overlaps analytically-pure fn span ✅
- V5.3.2 Suggestion НЕ fires для already-
#purefns ✅ - V5.3.3 Suggestion НЕ fires вне fn span ✅
- V5.3.4 Insertion is at column 0 of fn-line ✅
- V5.3.5 LSP tests 10/10 PASS
D217 amend V5.2 — LSP semantic tokens for cached @field reads
Source: Plan 123.5.2. Status: ✅ ACTIVE 2026-06-02.
1. Scope
V5 added code-lens + hover providers (per-fn summary,
per-@field info). V5.2 adds semantic tokens so editors can
visually mark @<field> reads the analyzer would CSE / cache. The
developer gets passive feedback that an optimization is in effect
without invoking any command.
2. Legend
Token type: property (standard LSP type — no client setup
required). Modifiers: readonly (standard) + cached (custom; bit
0 = readonly, bit 1 = cached). Editors that only honor standard
modifiers fall back to “readonly” styling; editors с custom
modifier support (VS Code, Helix) get richer theming.
3. Eligibility
A read @<field> at byte offset i is tagged when:
ilies within anFnCacheInfo::spanfromanalyze_module, AND<field>∈info.ro_caches ∪ info.mut_caches ∪ info.licm_hoists ∪ {root of each chain in info.chain_caches}.
Pure-call caches (info.pure_caches) are method-keyed, not field-
keyed, so they don’t contribute. Behavior matches what codegen
would actually fold.
4. Delta encoding
Per LSP spec — tokens sorted by (line, char) then encoded as
(deltaLine, deltaStart, length, tokenType, tokenModifiers).
tokenType=0 (legend’s PROPERTY index); tokenModifiers = the
fixed readonly|cached bitmask. Length covers @ + name.
5. Capability registration
semantic_tokens_provider returned at initialize-time with full=
true, range=false. Client requests textDocument/semanticTokens /full and receives the encoded data array.
6. Acceptance
- V5.2.1 Legend stable (
propertytype;readonly + cachedmodifiers) ✅ - V5.2.2 Cached
@<field>reads emit PROPERTY token w/ correct bitset ✅ - V5.2.3 Non-cached modules emit empty token list ✅
- V5.2.4 Delta encoding sane (monotonic, sortable) ✅
- V5.2.5 Compute panics-free under best-effort pipeline (None on parse failure, not error) ✅
7. Followups
- V5.5: ✅ DELIVERED 2026-06-03 — incremental delta protocol; см. D217 amend V5.5 ниже.
D217 amend V5.5 — Incremental LSP semantic-tokens delta (Plan 123.5.5)
Source: Plan 123.5.5. Status: ✅ ACTIVE 2026-06-03.
1. Scope
V5.2 ships textDocument/semanticTokens/full — server recomputes
entire token set on every request. Realistic LSP edit sessions
(typing within a function body) produce hundreds of identical or
near-identical token sets между requests. V5.5 adds protocol
support для incremental deltas через
textDocument/semanticTokens/full/delta: client provides
previous_result_id; server validates against cached snapshot and
returns a minimal edit script transforming previous tokens →
new tokens. Saves wire bandwidth + reduces editor token-array
mutation cost.
2. Protocol surface
- Capability:
semantic_tokens_provider.full = SemanticTokensFullOptions::Delta { delta: Some(true) }. Tells client server acceptsfull/deltaafter the firstfullrequest. - Handler signature (tower-lsp 0.20):
async fn semantic_tokens_full_delta( &self, params: SemanticTokensDeltaParams, ) -> Result<Option<SemanticTokensFullDeltaResult>> - Response variants (
SemanticTokensFullDeltaResult):TokensDelta { result_id, edits }— when client’sprevious_result_idmatches server’s cached snapshot.Tokens { result_id, data }— fallback when client’s id is stale OR no cache entry exists (cold start, server restart, eviction). Client re-syncs against the returned full snapshot.
3. State
WorkspaceState extended with:
pub semantic_tokens_cache: DashMap<Url, SemanticTokensSnapshot>,
pub semantic_tokens_counter: AtomicU64,
pub struct SemanticTokensSnapshot {
pub result_id: String, // format "st-<N>"
pub tokens: Vec<SemanticToken>,
}
next_semantic_tokens_result_id() — monotonic allocator. Format
st-<N> gives clients a stable prefix to identify nova-lsp ids and
a unique integer guaranteeing no reuse across server lifetime.
Snapshot updated on:
- Every successful
semantic_tokens_fullresponse. - Every
semantic_tokens_full_deltaresponse (delta OR fallback).
4. Edit algorithm
Pure function compute_semantic_token_edits(old, new) -> Vec<SemanticTokensEdit> в nova-lsp::semantic_tokens_delta.
Single-edit prefix-suffix reduction:
- Find longest common token-prefix length
P(token equality на all 5 fields: deltaLine/deltaStart/length/tokenType/modifiers). - Find longest common token-suffix length
S(bounded bymin(old.len() − P, new.len() − P)to prevent overlap). - Emit ONE
SemanticTokensEdit { start: P*5, delete_count: (old.len() − P − S) * 5, data: Some(new[P..new.len() − S].to_vec()) }.
Invariants:
start % 5 == 0,delete_count % 5 == 0(eachSemanticToken= 5 u32s в wire format).old == new→ zero edits, не one no-op edit (bandwidth optimum).- Worst case (no shared prefix/suffix) → one full-replacement edit — wire-equivalent к full fallback, never worse.
Not minimum-edit: true LCS would split unrelated changes into N edits. For typical LSP scenarios (single localized edit per request) single-edit dominates LCS в both compute time и wire bytes.
5. Decision helper
Pure build_delta_response(prev_snapshot, prev_result_id, new_tokens, new_result_id) -> (response, updated_snapshot) —
encapsulates the cache-match-or-fallback decision. Used by the
server handler so что state-update и response-construction stay
in one tested function.
6. Composition
V5.5 is purely additive к V5.2 — same token-computation pipeline
(compute_field_cache_semantic_tokens), same legend, same eligibility
rules. Snapshot caching is opt-in для clients (they can keep sending
plain full if they prefer).
7. Acceptance
- V5.5.1 Identical input → zero edits (no-op delta) ✅
- V5.5.2 Append at end → single tail edit ✅
- V5.5.3 Prepend at start → single head edit ✅
- V5.5.4 Middle change → single middle edit ✅
- V5.5.5 Tail deletion → single edit с
data: None✅ - V5.5.6 Total replacement → single full edit ✅
- V5.5.7 Empty old → pure insertion edit ✅
- V5.5.8 Empty new → full deletion edit ✅
- V5.5.9 Modifier-bitset difference detected как change ✅
- V5.5.10 All emitted indices 5-aligned (invariant) ✅
- V5.5.11 Matching
previous_result_id→TokensDeltavariant ✅ - V5.5.12 Mismatched
previous_result_id→Tokensfallback ✅ - V5.5.13 No cached snapshot →
Tokensfallback ✅ - V5.5.14 Matching id + identical tokens →
TokensDelta { edits: [] }✅ - Zero regressions on V5.2 baseline + LSP full suite (115/115 PASS).
8. Followups
- V5.5.1 (future): LCS-based multi-edit script для interleaved changes (current single-edit always wire-equivalent or better).
- V5.5.2 (future): Snapshot eviction policy (LRU / size cap) — current DashMap grows monotonically, OK для typical workspace sizes но требует attention для very-long sessions.
D223 amend V7.2 — Explicit IpaCtx parameter threading (Plan 123 V*.2)
Source: Plan 123 V*.2 followups. Status: ✅ ACTIVE 2026-06-02 — replaces V7.1 §4 thread-local plumbing.
1. Motivation
V7.1 §4 used three thread_local!{} RefCell<Option<...>> slots
(LICM_WRITE_SETS / PURE_IPA_CTX / CHAIN_IPA_CTX) set by
*_with_ipa wrappers и snapshotted by inner barrier helpers.
Trade-off documented в V7.1 §4: simpler patch but data flow opaque +
incompatible с future multi-threaded compilation.
V7.2 replaces this с explicit Option<IpaCtx<'_>> parameter
threading. Eight functions in field_cache.rs gained the param:
licm_fn_impl / licm_block / licm_stmt / licm_expr /
process_loop / collect_loop_eligible_fields /
pure_cache_fn_impl / chain_cache_fn_impl. The thread-local block
is deleted.
2. Wrapper restructure
*_with_ipa wrappers now own a local recv_type: String (cloned
once from f.receiver via recv_type_for_ipa) and construct an
IpaCtx<'_> borrowing into write_sets/read_sets + that local. The
local outlives the call to *_impl(f: &mut FnDecl, ..., ipa) so the
&mut f borrow does not alias &f.receiver.
fn licm_fn_with_ipa(f: &mut FnDecl, ..., write_sets, read_sets) {
let recv_type = match recv_type_for_ipa(f, cfg, write_sets) {
Some(rt) => rt,
None => { licm_fn_impl(f, reg, cfg, None); return; }
};
let ipa = IpaCtx { write_sets, recv_type: recv_type.as_str(), read_sets };
licm_fn_impl(f, reg, cfg, Some(ipa))
}
3. Backward-compat
licm_fn / pure_cache_fn / chain_cache_fn (public-ish entry
points without _impl suffix) preserved: each delegates to the
_impl variant with ipa = None, matching V1-V6 conservative
behavior. All existing call sites untouched (unit tests, doc
examples).
4. Eligibility examples — unchanged
Behavior identical к V7.1 §5 matrix. The migration is a refactor, not a semantic change. Verified:
- 14/14
field_cache::testslib tests PASS unchanged. - New plan123_7_2 fixtures (
v72_explicit_ipa_threading_ok.nv+v72_no_recv_skips_ipa_ok.nv) PASS — exercising all three passes in one method и validating receiver-less skip-path.
5. Future-proofing
Multi-threaded compilation: with thread_local plumbing, parallel module compilation would risk races (RefCell panics on cross-thread access; even Send-safe ThreadLocal would lose isolation). Explicit parameter threading is race-free by construction.
6. Risks
recv_type String clone per _with_ipa call (one heap allocation
per method). For typical modules (~50 fns) this adds ≤50 short
allocations — negligible vs total compilation. Profiled OK.
7. Acceptance
- V7.2.1 thread_local!{} block removed from field_cache.rs ✅
- V7.2.2 14/14 field_cache unit tests PASS without modification ✅
- V7.2.3 Behavior identical к V7.1 (LICM/pure/chain matrix) ✅
- V7.2.4 New fixtures plan123_7_2 PASS ✅
- V7.2.5
NOVA_FIELD_CACHE_IPA=0escape hatch still works ✅
D217 amend V4.2 — Chain prefix sharing (Plan 123.4.2)
Source: Plan 123.4.2. Status: ✅ ACTIVE 2026-06-02.
1. Scope
V4 V4.1 emitted одну _at_<a>_<b>_<c>_chain = @<a>.<b>.<c> let на
chain. Когда multiple chains share a length-2 prefix (e.g. @a.b.c
@a.b.d), the same@<a>.<b>walk is repeated for каждой. V4.2 extracts shared prefix into a single intermediate let, then per- chain lets reference it.
2. Algorithm
- After per-chain
to_cachefinalized, group entries by theirpath[..2]slice (length-2 prefix). - For groups с ≥ 2 chains, emit
_at_<a>_<b>_pre = @<a>.<b>once. - Emit per-chain lets — те, что покрыты prefix, формируют access
как
<prefix-local>.<tail-segments>черезExprKind::Ident + ExprKind::Member. Остальные unchanged (@chain). cfg.max_per_fnbudget covers both: prefix lets count toward it, so deeper sharing inmax_per_fn-bounded methods may not all materialize.
3. Edge cases
- Single chain in a prefix group (orphan) → skip; no savings.
- Chain length < 3 (root + one segment) → ineligible (no shared prefix possible).
- Existing local collision → suffix increment
_at_a_b_pre_1,_at_a_b_pre_2, …
4. Composition
V4.2 runs inside existing chain pass (chain_cache_fn). LICM /
pure / D217 V1 unaffected. IPA per-root invalidation still applies
к to_cache before prefix sharing — invalidated chains never reach
the sharing pass.
5. Acceptance
- V4.2.1 Shared prefix let emitted for ≥2 chains with shared length-2 prefix ✅
- V4.2.2 Per-chain lets reference shared prefix via
ident.tailchain ✅ - V4.2.3 Single-chain prefix groups don’t emit prefix let ✅
- V4.2.4 Existing behavior preserved for chains too short ✅
- V4.2.5 Runtime fixture
v42_chain_prefix_sharing_ok.nvPASS
6. Followups
- V4.3: ✅ DELIVERED 2026-06-03 — см. D217 amend V4.3 ниже.
D217 amend V1.1 — Multi-region mut cache (Plan 123.1.1)
Source: Plan 123.1.1.
Status: ✅ ACTIVE 2026-06-03.
Closes: [M-123.1-mut-region-recache].
1. Motivation
V1 mut-cache stops at the FIRST barrier (write OR call). Realistic Nova
code often has writes/calls in the middle of a method, with read-heavy
sections both before and after — V1 caches только pre-barrier reads,
losing optimization potential на second+ regions. Example: Counter @cycle() reads @x 2× before write, 2× after — V1 caches only first 2.
V1.1 generalizes mut caching к multi-region: split body’s top-level stmts on every write/call barrier, emit a fresh cache local for each region with reads ≥ threshold.
2. Algorithm
find_mut_regions_with_ipa(body, fname, ipa, body_span) -> Vec<MutRegion>:
regions: Vec<MutRegion> = []
region_start = 0
region_reads = 0
region_first_span = None
for (i, stmt) in body.stmts.enumerate():
if stmt_is_barrier_for_with_ipa(stmt, fname, ipa):
if i > region_start:
regions.push(MutRegion {
start: region_start, end: i,
reads: region_reads, first_span: region_first_span,
trailing_included: false,
})
region_start = i + 1
region_reads = 0; region_first_span = None
continue
reads_in_stmt = count_field_reads_in_stmt(stmt, fname)
region_reads += reads_in_stmt
if reads_in_stmt > 0 && region_first_span.is_none():
region_first_span = Some(stmt.span)
handle trailing similar to V1 (extends current region OR closes без trailing)
Each MutRegion carries (start, end, reads, first_span, trailing_included).
The trailing_included flag tells rewriter whether Block.trailing is
part of this region (true когда no barrier on trailing boundary).
3. Per-region target allocation
cache_fn_with_ipa iterates find_mut_regions_with_ipa(...), keeps
regions where reads >= cfg.threshold. For each kept region:
- Allocate fresh
MutRegionTarget { fname, region, region_idx, local_name }. region_idx= 0 for first kept region per field, 1 для second, etc.local_name=_at_<F>ifregion_idx == 0(V1 backwards-compat), иначе_at_<F>_r<N>(N = region_idx).- Collision suffix
_<K>applied if base name conflicts с existing local.
cfg.max_per_fn budget covers ALL regions (плюс ro lets + chain lets +
LICM lets + pure lets) — first-come-first-served в body order.
4. Rewrite + injection
rewrite_fn_body_split_with_ipa:
- Per-region read rewrite: для каждого
MutRegionTarget, walkstmts[start..end)plus optional trailing, rewrite reads of@<F>→<local_name>. - Ro full-body rewrite: standard V1 path (unchanged).
- Let insertion: group targets by
region.start. Process non-prefix groups (start > 0) в descending order ofstart— insert region’s lets BEFOREstmts[start]. Descending order keeps unprocessed indices valid. - Prefix bucket: ro lets + first-region mut lets (start = 0) prepended together (V1-shape preserved).
Deterministic ordering: per-group sort by (fname, region_idx).
5. ExplainReport compatibility
analyze_fn_for_explain updated к scan ALL top-level _at_* lets, not
just the prefix run — V1.1 secondary-region let’ы live in body interior.
Non-let / non-_at_* stmts simply skipped instead of breaking.
Backwards compat: V1 single-region case produces identical AST к pre-V1.1
behavior — _at_<F> at body prefix, no region suffix.
6. Codegen impact
V1.1 cache locals follow same lowering as V1: each let-binding becomes
a C local variable. Region splits don’t introduce new lifetimes —
each _at_<F>_r<N> lives until end of containing block.
7. Acceptance
- V1.1.1 Two regions split by direct write
@F = N→ emits both_at_F(first region) +_at_F_r1(second region) ✅ - V1.1.2 Two regions split by self-mutating call (IPA-detected real barrier) → emits both cache lets ✅
- V1.1.3 Three regions от mixed barriers (write + self-mutating
call) → three cache lets
_at_F,_at_F_r1,_at_F_r2✅ - V1.1.4 V1 single-region case preserves
_at_Fnaming (no suffix) — backwards compat ✅ - V1.1.5 Region с reads < threshold skipped — partial-coverage не generates spurious cache let ✅
- V1.1.6 No reads anywhere → no cache (sanity) ✅
- V1.1.7 Ro field unaffected by mut barriers — still cached once at prefix ✅
- V1.1.8 Budget
cfg.max_per_fncaps total regions (FIFO в body order) ✅ - Zero regressions: field_cache lib 55/55 (47 baseline + 8 V1.1) + plan123_1 18/18 + plan123_2 14/14 + plan123_4 10/10 PASS via release nova test + clang.
- Runtime fixtures
nova_tests/plan123_1_1/3/3 PASS — semantic preservation under 2-region, 3-region, partial-coverage scenarios.
8. Followups
- V1.2: ✅ DELIVERED 2026-06-04 — см. D217 amend V1.2 ниже.
- V7.5 (future): callee-non-self-mutation IPA — пометить методы
вроде
[]u8.push(self mut)как НЕ пишущие в outer self’s sibling fields. Полезно для caching других fields в методах, которые вызывают@F.method(). NOT fixes the WriteBuffer@write_charchain duplicate (см. Plan 123.4.4 codegen marker).
D217 amend V1.2 — Nested-region mut cache (Plan 123.1.2)
Source: Plan 123.1.2.
Status: ✅ ACTIVE 2026-06-04.
Closes: [M-123.1.1-nested-regions].
1. Motivation
V1.1 splits FN body’s top-level stmts on barrier boundaries. When
a top-level stmt itself contains a barrier (e.g. nested if с write
inside its then-block), V1.1 treats the whole stmt as a barrier и
skips it entirely. Reads inside nested then/else/while/match-arm
bodies are NOT cached, даже когда они formed a clean ≥threshold region
по своему own (e.g. 2 reads pre-write + 2 reads post-write inside
nested if).
V1.2 closes the gap via recursive nested-region analysis: after
V1.1 outer pass finishes, descend into every nested Block reachable
from the fn body and apply per-block multi-region caching.
2. Algorithm
walk_nested_blocks_for_mut_field(top_block, fname, cfg, ipa,
local_names, seq, budget_left):
for stmt in top_block.stmts:
descend_stmt_for_nested(stmt, ...)
if top_block.trailing:
descend_expr_for_nested(trailing, ...)
descend_stmt_for_nested / descend_expr_for_nested exhaustively
traverse the AST. Each Block encountered (in If/IfLet/While/
WhileLet/For/ParallelFor/Loop/Match arm/With/Forbid/Realtime/Detach/
Blocking/Supervised/Block-Expr/Trailing block) invokes:
process_nested_block_for_mut_field(block, fname, ...):
# Phase A: bottom-up — descend into nested children FIRST.
for stmt in block.stmts: descend_stmt_for_nested(stmt, ...)
if block.trailing: descend_expr_for_nested(trailing, ...)
# Phase B: process THIS block — region split + targets + rewrite + injection.
regions = find_mut_regions_in_block(block, fname, ipa)
targets = filter(reads >= threshold).map(allocate_unique_local).collect()
rewrite reads per-target
insert lets per-target в block.stmts at region.start positions
Bottom-up order is critical: inner caches landed BEFORE outer
rewrites might descend over them, avoiding double-rewrite. By the
time outer rewriter walks, inner @F reads have already become
_at_<F>_n<N> idents — not matched by outer’s Member{SelfAccess,F}
pattern.
3. Naming convention
Nested cache locals use _at_<F>_n<N> где N — session-monotonic
counter (increments per allocated nested cache). Distinct namespace
от V1.1 outer caches (_at_<F> / _at_<F>_r<N>) — collision-safe by
construction. Standard _<K> suffix added on user-local conflict.
4. Closure handling
V1.1 already excludes mut fields referenced inside closures
(closure_captured set). V1.2 inherits the exclusion:
descend_expr_for_nested matches Lambda/ClosureLight/ClosureFull/ HandlerLit/ProtocolLit and returns без descending.
5. Budget
V1.2 shares cfg.max_per_fn budget с V1.1. After V1.1 allocates
outer targets (consuming total_caches slots), V1.2 has
max_per_fn − total_caches remaining. Per-field-then-per-nested-
region FIFO в discovery order. Once budget == 0, V1.2 stops cleanly.
6. Composition
V1.2 runs AFTER rewrite_fn_body_split_with_ipa (V1.1 Phase 2).
Already-rewritten outer regions show 0 @F reads when V1.2 visits
their nested blocks → no spurious nested cache. Only untouched
nested blocks (those inside V1.1 barrier stmts) produce V1.2 targets.
ExplainReport analyze_fn_for_explain (Plan 123.1.1) already scans
ALL top-level _at_* lets — V1.2 nested lets live deeper, currently
not surfaced in V5 telemetry. Future enhancement: deep-walk
analyze_fn_for_explain для V1.2 visibility. Marker
[M-123.1.2-explain-deep-walk] ✅ RESOLVED (Plan 123.5.4, 2026-06-04): deep-walk landed в V5.4 amend, см. **Closes:** выше.
7. Acceptance
- V1.2.1 Nested then-block with internal write (2 pre + 2 post
reads) → 2 cache lets
_at_<F>_n*injected ✅ - V1.2.2 Else-branch caches independently когда if’s then-branch contains write ✅
- V1.2.3 While-loop body caches pre-write reads ✅
- V1.2.4 Match-arm body caches its own multi-region pattern ✅
- V1.2.5 Ro field unaffected — no nested
_at_x_n*even with mut field writes elsewhere ✅ - V1.2.6 Nested region < threshold reads skipped ✅
- V1.2.7 V1.1 outer + V1.2 nested compose (
_at_<F>AND_at_<F>_n*in same fn) ✅ - V1.2.8 Budget
cfg.max_per_fncaps total (outer + nested) ✅ - V1.2.9 Deeply nested (if inside while) gets caching ✅
- Zero regressions: field_cache lib 64/64 (55 baseline + 9 V1.2)
- plan123_1 18/18 + plan123_1_1 3/3 + plan123_2 14/14
- plan123_4 10/10 PASS via release nova test + clang.
- Runtime fixtures
nova_tests/plan123_1_2/5/5 PASS — semantic preservation under then-block / else-branch / while-body / match-arm / compose-outer-and-nested scenarios.
8. Followups
[M-123.1.2-explain-deep-walk]✅ RESOLVED (Plan 123.5.4, 2026-06-04) — V5 telemetry doesn’t surface V1.2 nested lets; deep-walkanalyze_fn_for_explainto count them inmut_caches. Реализовано в V5.4 amend (см. секцию выше,**Closes:**).[M-123.1.2-loop-body-licm-coordination]— V1.2 can cache the pre-write region inside loop body, but loop-iteration weighting for read count is still unidirectional (counts each read once). Compose с V2 LICM для better loop-body cost model.
D217 amend V4.3 — Deep chain prefix sharing (Plan 123.4.3)
Source: Plan 123.4.3. Status: ✅ ACTIVE 2026-06-03.
1. Scope
V4.2 эмитит ОДИН length-2 prefix let на группу ≥2 chains. Однако в
реалистичном Nova-коде с deep record-вложенностью (@a.b.c.x +
@a.b.c.y + @a.b.c.z) length-3 prefix @a.b.c также shared, но
V4.2 пропускает эту глубину — per-chain lets выписывают .c повторно.
V4.3 распространяет sharing на length 3+ через iterative deepening: последовательно проверяет prefix-длины 2, 3, …, max_chain_depth − 1, и для каждой группы ≥2 chains alloc’ит cache local с reference на shallower parent prefix если такой существует.
2. Algorithm (iterative deepening + parent chaining)
out: HashMap<Vec<String>, PrefixInfo { name, span, parent }>
for prefix_len in 2..max_path_len:
if emitted >= max_per_fn: break
groups: HashMap<Vec<String>, Vec<entry>> = group_by(path[..prefix_len])
for prefix in sorted(groups.keys()):
entries = groups[prefix]
if entries.len() < 2: continue # no sharing
if emitted >= max_per_fn: break # budget
parent = find_longest_existing_parent(out, prefix) # walks (2..L).rev()
name = alloc_collision_safe("_at_<segs>_pre")
out[prefix] = PrefixInfo { name, span: earliest, parent }
emitted += 1
Eligibility per prefix length L:
path.len() > L(нужен ≥1 tail segment beyond prefix).- Группа должна иметь ≥2 distinct chains.
3. Emission order
Prefix lets emit’ятся в shorter-first order — sort key (len, lex)
— так что deeper prefix может ссылаться на shallower parent через
<parent_local>.<remaining_segments> chain. Per-chain lets всегда
ссылаются на longest covering prefix через find_chain_shared_prefix
(walks (2..path.len()).rev()).
Example для chains @a.b.c.x, @a.b.c.y, @a.b.c.x, @a.b.c.y:
let _at_a_b_pre = @a.b # length-2 prefix
let _at_a_b_c_pre = _at_a_b_pre.c # length-3 prefix — refs parent
let _at_a_b_c_x_chain = _at_a_b_c_pre.x # per-chain — longest cover
let _at_a_b_c_y_chain = _at_a_b_c_pre.y
vs V4.2 baseline:
let _at_a_b_pre = @a.b
let _at_a_b_c_x_chain = _at_a_b_pre.c.x # пишет .c повторно для каждого
let _at_a_b_c_y_chain = _at_a_b_pre.c.y
V4.3 economy: one .c field read хhoisted в length-3 prefix, two .c
reads устранены из per-chain lets. Linear extension к O(depth)
maximum sharing depth.
4. Edge cases
- Path length ≤ prefix_len: chain skipped (нужен tail).
- Single chain в группе: prefix не allocated (no net savings).
- Budget exhaust: iterative deepening stops at current length, не пытается deeper.
- Local-name collision: suffix increment
_at_X_pre_1,_at_X_pre_2, …, как V4.2. - No length-N+1 sharing когда N has sharing: V4.3 не выдумывает
fake length-3 prefix только потому что length-2 был emitted.
Иллюстрация — три chains через различные middle segments
@a.b.c.x,@a.b.d.y,@a.b.e.zдают только length-2 prefix_at_a_b_pre; length-3 группы singleton, skipped.
5. Composition
Pure additive в chain_cache_fn_impl — никаких изменений в LICM,
pure-call, ro/mut, IPA passes. Reuses V4 chain-extraction, V4.2
collision-safe naming. V4.2 case = V4.3 с parent=None для length-2.
6. Acceptance
- V4.3.1 Length-3 prefix let emitted ≥2 chains sharing
path[..3]✅ - V4.3.2 Length-3 prefix references length-2 parent через
_at_a_b_pre.c(NOT@a.b.c) ✅ - V4.3.3 Per-chain let picks LONGEST covering prefix (
_at_a_b_c_pre.xinstead of_at_a_b_pre.c.x) ✅ - V4.3.4 Length-4+ prefixes chain transitively через intermediate
parent prefixes (
_at_a_b_c_d_pre = _at_a_b_c_pre.d) ✅ - V4.3.5 No spurious deep prefix когда group singleton ✅
- V4.3.6 Emission order shorter-first (length-2 BEFORE length-3 BEFORE length-4) ✅
- V4.3.7 Runtime semantic preservation 3/3 fixtures PASS via release nova test + clang ✅
- V4.3.8 Zero regressions — plan123_4 10/10 + plan123_4_2 1/1 + field_cache::tests 37/37 PASS ✅
7. Followups
- V4.4 (future): cross-fn prefix sharing — module-level CSE of
@a.b.cесли N методов в same type используют — out of scope V4 family, see Plan 123.8 territory. - V4.5 (future): mut-prefix sharing — current V4.3 inherits V4 V4.1 constraint (skip when root in body_writes).
D219 amend V3.2 — Pure-call tuple/record literal args (Plan 123.3.2)
Source: Plan 123.3.2. Status: ✅ ACTIVE 2026-06-02.
1. Scope extension
V3.1 limited literal-args caching to scalar literals (Int / Float / Str / Bool / Char / Unit / NullPtr / Unary-neg-of-literal). V3.2 extends к tuple- and record-literal arguments whose components are themselves recursively literal-pure.
2. Canonical encoding
| Expr | repr |
|---|---|
(a, b, c) | T<N>{<a>;<b>;<c>} (N = arity) |
Type { f1: v1, f2: v2 } | R<Type>{f1:<v1>;f2:<v2>;...} (fields sorted by name) |
{ f1: v1 } (anonymous) | R{...} (no type prefix) |
Format rules:
- Tuple: positional — encoded in source order.
- Record: fields sorted alphabetically by name для canonical ordering across syntactic reorderings.
- Type prefix for record uses
path.join(".")(std.foo.Bar→Rstd.foo.Bar{...}). - Recursion: each component runs through
canonical_literal_reprrecursively; first failure short-circuits кNone.
3. Rejected patterns
- Spread fields (
{ ...other }) → reject. Spread points to a runtime value; folded literal would mis-represent semantics. - Shorthand-pun without explicit value (
{ name }) → reject. Equivalent toIdent(name)which is not a literal. - D55
inferred_map_v.is_some()→ reject. Map-coercion-marked record literal is semantically a HashMap insert sequence, not a literal record. - Any non-literal arg anywhere in the tree → bubbles up к None.
4. Eligibility — interaction с PureCallKey
Args_key is the concatenation _<repr1>_<repr2>... exactly as in
V3.1. Tuples and records inflate args_key length but remain stable
across reorderings (sorted) и nesting (recursion).
5. Acceptance
- V3.2.1
canonical_literal_repr(TupleLit)returns canonical string когда all elements literal ✅ - V3.2.2 Nested tuples (e.g.
((1,), 2)) encoded asT2{T1{1i};2i}✅ - V3.2.3 RecordLit fields sorted alphabetically before encoding ✅
- V3.2.4 Spread / shorthand / D55-marker rejected (None) ✅
- V3.2.5 Non-literal nested arg → None ✅
- V3.2.6 Field-cache unit tests 14/14 PASS + 8 new V3.2 tests (incl. sanitizer test) ✅
- V3.2.7 args_key sanitized through
sanitize_args_key_for_identbefore composition into pure-cache local name — produces valid C identifier from{,},;,:,.✅ - V3.2.8 Runtime fixtures (release nova-cli + clang):
v32_tuple_literal_args_ok/v32_record_literal_args_ok/v32_neg_non_literal_arg_not_cached_ok3/3 PASS ✅
6. Codegen sanitizer (2026-06-02 fix)
The encoded args_key (T2{1i;2i}, RPoint{x:1i;y:2i}) is fine as a
hash key but illegal as a C identifier suffix. pure_cache_fn runs
the args_key through sanitize_args_key_for_ident before forming
the local name:
| char | replacement |
|---|---|
{ | _o_ |
} | _c_ |
; | _s_ |
: | _k_ |
. | _d_ |
| other punctuation | _<hex>_ |
_at_sum_pair_with_T2_o_1i_s_2i_c__100i_call is a valid C
identifier. Distinct keys map к distinct sanitized strings
(verified by unit test v32_sanitize_args_key_for_c_ident).
D219 amend V3.1 — Pure-call literal args extension (Plan 123.3.1)
Source: Plan 123.3.1.
1. V3.1 scope extension
V3 cached only args-less @<method>(). V3.1 extends to args-with-
literal-arguments: @<method>(literal1, literal2, ...).
Eligible literal types:
IntLit/FloatLit/StrLit/BoolLit/CharLit/UnitLit/NullPtrLit.Unary{Neg, literal}(negated literals like-5).
Non-literal args (variables, expressions) → V3 fallback (not cached).
2. Canonical key
PureCallKey { method: String, args_key: String } where args_key
encodes literal values:
IntLit(5)→_5iBoolLit(true)→_TStrLit(...)→_s<hash>(24-bit truncated SipHash).Unary{Neg, IntLit(3)}→_m3i.
Two pure calls share cache iff canonical keys match (same method name AND same args sequence).
3. Naming
Cache local = _at_<method><args_key>_call:
@scaled(2)× N →_at_scaled_2i_call.@value(true)→_at_value_T_call.
Collision avoidance via numeric suffix.
4. Implementation
match_self_pure_callreturnsOption<PureCallKey>(V3 returnedOption<&str>).canonical_literal_repr(expr)returns compact String для literal expressions.count_pure_calls_in_bodyusesHashMap<PureCallKey, usize>.capture_sample_args_in_bodysaves first sample args per key для prefix-let reconstruction.rewrite_pure_calls_in_*_v31matches by canonical key, replaces call sites с cache Ident.
5. Composition с V3.1 frame-based (V7.1)
V3.1 literal-args extension orthogonal к V3.1 frame-based invalidation (delivered as part of V7.1). Both apply independently:
- Frame-based: cache survives writes к fields outside method’s read-set.
- Literal-args: cache emit’ится для (method, literal_args) keys.
6. Acceptance
A3.1.1-A3.1.5 met:
- A3.1.1 ✅
@<method>(literal)× N → cached. - A3.1.2 ✅ Different literals → separate caches.
- A3.1.3 ✅ Non-literal arg → not cached (semantic preserved).
- A3.1.4 ✅ Regression: plan123_3 12/0 PASS.
- A3.1.5 ✅ plan123_3_1: 4/4 PASS.
7. Escape hatch
NOVA_FIELD_CACHE_PURE=0 → entire V3 + V3.1 disabled.
D247. str-методы — миграция external-C → Nova-body + Vec cross-type мост (Plan 139.2)
Status: ACTIVE (Plan 139.2, 2026-06-12). Зависит от:
D26 (str lang-item value-record),
D232 (Vec[T]),
D216
(typed-pointer арифметика), D246
(3 оси мутабельности, *T ≡ *ro T). Дом: Plan 139.2 (закрывает E4 PARTIAL из
Plan 139.1). Этот блок — umbrella над per-фазовыми amend’ами в
02-types.md (D246, «Amend Plan 139.2 Ф.0+Ф.2/Ф.3»)
и D26 «Методы — Nova-body».
Решение
После того как str стал объявленным value-record lang-item’ом
(type str value priv { ptr *u8, len int }, Plan 139.1), его методы можно писать
на чистой Nova в std/runtime/string.nv — больше нет нужды в external-C
nova_str_* обёртках для логики, оперирующей байтами. Plan 139.2 мигрировал
9 из 10 str-методов из external-C в Nova-body; только @hash остаётся C
(обоснованное security-исключение, см. ниже).
AMEND Plan 152.0 (D-R2, 2026-06-13): str-методы переехали из одного файла
std/runtime/string.nvв папку-модульstd/runtime/string/{core,search,transform, parse,chars}.nv(co-equal файлы, всеmodule runtime.string, D29/07-modules.md). И вестигиальные Nova-body str-записи удалены изruntime_registry.rs(24 записи, −370 строк): резолв str-методов идёт из распарсенного.nv, не из реестра (доказано — методgetбез записи в реестре резолвится без импорта; единственный консумер реестра для strtypes/mod.rs:12233читает толькоis_consume/is_mut, которых у str нет). В реестре остались только C-dispatch записиeq/lt/le/gt/ge(бэкают operator-loweringemit_c.rs:17302) +@hash. Декомиссия operator-lowering (→ только@hash) — Plan 152.5a (D-R4,[M-139.1-operator-lowered-methods]). Stub-gen из реестра НЕ запускать (перезатрёт hand-written.nv). См. Plan 152 findings F2/D-R2.
Ключевая переоценка privacy (исправляет пессимизм Plan 139.1 Ф.B)
- Privacy у Nova — type-based, не module-based.
priv_access_allowed_base(mod.rs) проверяет толькоcurrent_recv_type == tname, БЕЗ проверки модуля.current_recv_typeставится из receiver для ЛЮБОЙ функции с receiver. Значитfn str @method(вstd/runtime/string.nv, модульruntime.string) имеет receiverstr⇒current_recv_type == "str"⇒ видит priv-поля@ptr/@lenи конструируетstr { ptr: …, len: … }в своём модуле. Прошлый вывод «string.nv не может конструировать str» — ошибка. - View-машинерия уже была (D144/D238):
Vec[T] @index(r Range) -> Selfстроит value-record sub-view черезunsafe-арифметику над raw-ptr. «Конструкция view из (ptr,len)» — не новый примитив.
Единственный cross-type мост — Vec
str-метод НЕ видит priv-поля Vec (другой тип) — поэтому для @as_bytes/
from_bytes_*/@split (которые передают (ptr,len,cap) между str и Vec)
введены публичные Vec-методы (vec_owned.nv, прямой аналог @index(Range)):
| Метод | Сигнатура | Роль |
|---|---|---|
from_raw_parts | Vec[T].from_raw_parts(ptr *T, len int, cap int) -> Self | строит Vec из сырого (ptr,len,cap) триплета; единственный sanctioned вход (priv-поля закрыты для чужих модулей) |
as_ptr | Vec[T] @as_ptr() -> *T / mut @as_ptr() -> *mut T | публичный data-ptr геттер (recv-mut overload отдаёт writable *mut T) |
into_raw | Vec[T] consume @into_raw() -> *mut T | инверс from_raw_parts: потребляет Vec-обёртку (D133), отдаёт writable raw-буфер для zero-copy reuse |
Контракт from_raw_parts/into_raw — unsafe-обязательство на call-site:
caller гарантирует liveness/init буфера. Входной *T (canonical ro-pointee,
*T ≡ *ro T, D246) реинтерпретируется в *mut T поля через unsafe { ptr as *mut T } (только re-label L3 pointee-mut, без dereference; writable-ность —
инвариант caller’а, ro-view биндится ro для enforce на L2).
Что разблокировало producer-формы
str { ptr, len }record-lit codegen. str ∈RUNTIME_DEFINED_TYPESskip-list (нетNovaValue_strschema) → generic record-lit падал в emit-null-stub (void*→nova_strCC-FAIL). Спец-кейсstruct_name == "str"вemit_record_lit→ C compound-literal(nova_str){.ptr=(const uint8_t*)(…), .len=(int64_t)(…)}. Только str type-методы достигают (E_PRIV_FIELD_INIT гейтит внешних в checker’е).- consume-обязательство
@from_bytes_unchecked_steal(consume bytes []u8)— закрыто новымVec[T] consume @into_raw()(Vec не consume-тип сам по себе).
Перечень (9/10 Nova-body)
| Метод | Фаза | C-источник (retired) |
|---|---|---|
@as_bytes | Ф.0 | nova_str_as_bytes |
@len | Ф.1 | nova_str_byte_len (теперь bare priv-field read => @len, D117 carve-out) |
@byte_at | Ф.1 | nova_str_byte_at (bounds-check + unsafe { @ptr[i] }) |
@split | Ф.2 | nova_str_split (zero-copy sub-views str{ptr:@ptr+off,len}) |
from_bytes_unchecked | Ф.2 | C-перехват (alloc+copy+NUL D26 §3 через priv str.alloc_copy) |
from_bytes_lossy | Ф.2 | C-перехват (UTF-8-валидатор + U+FFFD) |
from_bytes_unchecked_steal | Ф.2 | C-перехват (zero-copy reuse при cap>len, иначе copy) |
@concat | Ф.3 | nova_str_concat ([]u8.with_capacity+push+from_bytes_unchecked) |
@compare | Ф.3 | nova_str_compare (byte-loop, length-aware tiebreak) |
@hash | Ф.4 — остаётся C | nova_str_hash (SipHash-1-3 + per-process random crypto-seed; DoS-resistance: перенос в Nova экспонировал бы seed = hash-flooding регрессия HashMap. Постоянная обоснованная C-граница, security) |
Помимо Ф.0-Ф.4 в Nova-body уже были (Plan 139 Ф.1/Ф.2):
starts_with/ends_with/contains/find/rfind/char_at/char_len/trim/
to_lower/to_upper/to_bytes/to_chars.
Operator-lowering — option (b) (намеренный design-выбор, НЕ упрощение)
Операторы +/</<=/>/>=/==/!= над nova_str лоуэрятся ОТДЕЛЬНО,
напрямую в C (nova_str_concat/nova_str_lt/…/nova_str_eq, BinOp-arm
lty == "nova_str"), НЕ через method-dispatch. Прямые method-вызовы
(s.concat(t)/s.compare(t), Compare-протокол, @plus/@replace-body) идут в
Nova-body. Причины: (1) perf — operator-формы горячие (string building, sort), C =
один alloc+2×memcpy / один memcmp vs Nova push-loop с per-byte bounds-check;
(2) ортогональность — BinOp codegen и method-dispatch независимы, чистое
retirement требовало бы СОВМЕСТНОЙ миграции + perf-харнесс. Дубль (Nova-метод +
малая inline C-fn оператора) приемлем. См. reframed
[M-139.1-operator-lowered-methods].
Acceptance (Plan 139.2 overall)
- ✅ 9/10 str-методов Nova-body;
@hash— обоснованно C (security). - ✅
Vec[T].from_raw_parts+ data-ptr геттер@as_ptr()+consume @into_raw()landed (cross-type мост). - ✅ pos+neg тесты зелёные через релизный nova:
plan139_212/0; 0 регрессий (str 13/0, plan139 37/0, plan139_1 4/0, plan90 9/0, plan90_1 21/0; HashMap для @hash зелёный). - ✅ spec/D/Q/doc обновлены; маркеры закрыты/reframed (
[M-139-f2-ptr-field-producers]закрыт;[M-139.1-operator-lowered-methods]/[M-139.1-hash-irreducible-crypto-seed]/[M-139.1-len-d117-method-only]reframed/подтверждены).
Cross-refs
- D26 — str value-record + «Методы — Nova-body».
- D232 — Vec[T] +
from_raw_parts/as_ptr/into_rawbridge-методы (в Key methods table). - D246 — 3 оси мутабельности + per-фазовые amend’ы Plan 139.2 Ф.0+Ф.2/Ф.3.
- D117 — size-accessor field-ban + Plan 139.2 Ф.1 self-field carve-out.
std/runtime/string.nv+std/collections/vec_owned.nv— реализация.- Plan 139.2 — дом-план.
D233 (NEW) — Configurable fiber arena (env + nova.toml runtime tuning) (Plan 149)
Status: ACTIVE (Plan 149, 2026-06-12). Supersedes marker [M-fiber-arena-raise-cap].
Contract. Per-fiber stack slot size and max-concurrent-fibers-per-worker are RUNTIME-tunable properties of the finished program (GOMAXPROCS-style), NOT compiler parameters.
1. Knobs & defaults
| Knob | Env | nova.toml [runtime] | Builtin default | Range | Round |
|---|---|---|---|---|---|
| fiber stack | NOVA_FIBER_STACK | fiber_stack | 4MB | [256KB, 256MB] | UP to page-align |
| max fibers/worker | NOVA_MAX_FIBERS | max_fibers | 16384 | [64, SLOT_COUNT_MAX] | UP to ×64 |
Human-friendly sizes accepted ("4MB" | "4194304"; KB/MB/GB binary — KB=1024,
MB=1024², GB=1024³). The builtin stack default was lowered 8MB→4MB (2× fiber
density out of the box).
2. Precedence
env > nova.toml (compile-time -D default) > builtin #define. nova.toml bakes
-DNOVA_FIBER_STACK_DEFAULT=<bytes> / -DNOVA_MAX_FIBERS_DEFAULT=<count> (raw
integers, human-size parsed in Rust); the env var is read in
nova_fiber_arena_init and overrides the compile-time default at runtime.
nova build and nova bench (single-file, dir, and --profile paths) now
resolve [runtime] (and Plan 115 D214 [ffi]) from the package nova.toml via
find_manifest, identically to the test runner; the precedence chain
env > nova.toml(-D) > builtin is UNCHANGED — only the set of front-ends that
honor the manifest expanded (Plan 149 followup, 2026-06-13).
3. Auto-round-UP + clamp (UX rule)
Any user value is rounded UP to the nearest valid (stack→page, slots→×64) then clamped into range. Out-of-range → clamp + stderr warning (stack below floor → 256KB; max above MAX → MAX). Unparseable/garbage env or toml → stderr warning + builtin default (which itself goes through round+clamp). NEVER crash on config.
4. Per-worker semantics
One arena per worker thread; total process fiber capacity = slot_count × NOVA_MAXPROCS. NOVA_MAXPROCS is unchanged (not a new knob); total is an
emergent property.
5. Compile-time MAX vs runtime active split
The bitmap (free_bits POSIX / used_bits+dirty_bits Windows) is sized by the
compile-time NOVA_FIBER_SLOT_COUNT_MAX (default 262144 ⇒ 4096 uint64_t = 32KB
per arena, ×workers — копейки) so env may raise slot_count above the runtime
default. Runtime a->slot_count = clamp(round64(resolved), 64, SLOT_COUNT_MAX). Because round-UP makes slot_count a multiple of 64, the
common case marks only whole trailing bitmap words USED at init; a partial-tail
loop is implemented belt-and-suspenders for a defensive non-×64 boundary. The
allocator never returns a phantom slot beyond slot_count.
6. Per-fiber stack actually scales (review must_fix #1/#2)
The minicoro stack size is derived from the RUNTIME a->slot_size via
nova_fiber_arena_slot_size() in fibers.h::_nova_mco_desc_init_arena, NOT the
compile-time NOVA_FIBER_STACK_SIZE macro. This makes NOVA_FIBER_STACK env
actually change the usable per-fiber stack (a larger env stack deepens the
stack; the 256KB floor stays usable because minicoro’s coro_size ≤
slot_usable, so nova_fiber_alloc’s size > usable guard passes).
NOVA_FIBER_STACK_SIZE remains the build-time builtin feeding
NOVA_FIBER_STACK_DEFAULT.
7. Guard page preserved
16KB guard (NOVA_FIBER_GUARD_SIZE, PROT_NONE POSIX / PAGE_GUARD window
Windows) at slot base; usable = slot_size − guard; overflow → clean crash with a
hint referencing NOVA_FIBER_STACK. With the 256KB floor, usable = 240KB > 0.
8. Non-interference
Arena config (set once at nova_fiber_arena_init, before the arena is published)
touches only slot_size/slot_count/virtual_size/bitmap-size + tail bits — no
scheduler or GC invariant (grow-vs-wake, iso-cancel, active-range roots remain
as-is). 32-bit branch kept tiny (SLOT_COUNT_MAX=1024, runtime default 64 =
256MB; the source NOVA_FIBER_SLOT_COUNT_DEFAULT literal was corrected 16→64
since round-UP-to-×64 + MIN=64 already forced 64 at runtime — the old 16 was
dead and its ‘64MB’ comment false; a _Static_assert now pins the
×64 ∧ ≥MIN invariant. Plan 149 followup, 2026-06-13).
9. Cross-platform
POSIX (mmap MAP_NORESERVE) + Windows (VirtualAlloc MEM_RESERVE + lazy commit)
honor an identical contract; Windows VirtualAlloc downsize-retry may yield
slot_count ≤ requested (re-marks tail bits after the final count).
10. Virtual-reservation budget note
Worst case NOVA_MAX_FIBERS=262144 × NOVA_FIBER_STACK=256MB × NOVA_MAXPROCS
reserves an absurd virtual range. MAP_NORESERVE / MEM_RESERVE keep physical
commit lazy, but this can exhaust user VA or hit a commit limit. POSIX aborts on
mmap failure; Windows downsize-retries. Operators tuning to extremes should size
the product against available virtual address space.
D276 (NEW) — Generated C must be MSVC-portable (no GNU extensions) (Plan 145)
Решение: Codegen эмитит C, компилируемый всеми тремя toolchain’ами Nova (Clang, GCC, MSVC cl.exe). Раннер компилит под MSVC в permissive C-режиме БЕЗ
/std:c11(та опция ломает MS-extension struct-cast’ы(StructTy)(v), которые эмитит codegen — C2440). Отсюда жёсткое правило: в генерируемом C и в рантайм-заголовкахnova_rt/запрещены GNU/Clang-расширения, которых нет у cl.exe в этом режиме. Контекст: при попытке снять MSVC-baseline (Plan 83) MSVC оказался сломан широко — регрессия после Plan 82 (был 1049/16), накопленная из 4 независимых причин, всплывающих каскадомC2059 → C2143 → LNK2019 → C2440.
Правила (что нельзя и чем заменять):
-
GNU statement-expression
({ … })+__typeof__— cl.exe → C2059. Запрещены в генерируемом C. Значение-возвращающие многошаговые конструкции выносятся вstatic inline-хелперы вnova_rt/array.h. Для индексации/слайсов/heap-box/ bitcast — generic-хелперы черезvoid*(тип-лаундеринг черезvoid*снимает strict-aliasing: компилятор теряет исходный тип → доступ черезNovaArrHdr*безопасен) + общий headerNovaArrHdr { void* data; int64_t len; int64_t cap }(layout NovaArray ≡ Vec ≡ slice). Сайт восстанавливает элемент-тип(ELEM*)-кастомsizeof(ELEM); результат — lvalue (*(T*)…), оба подвыражения single-eval (аргументы функции). Хелперы Plan 145:nova_idx_chk/nova_idx_nochk,nova_vec_slice_chk/nochk,nova_str_slice_chk/nochk(+*_to_end_*для open-ended single-eval, с UTF-8 codepoint-boundary guard),nova_box_value(memcpy heap-box адресуемого источника),nova_bits_i2f(union-pun в функции)._nochk-варианты обслуживают элизию bounds-check (Plan 140.2 D257).
-
C11 keyword’ы и GCC/Clang builtin’ы (
_Static_assert,_Alignas,__atomic_*,__builtin_*) — шимятся вnova_rt/nova_msvc_compat.h, force-included (/FI) в каждый TU под_MSC_VER && !__clang__. Любой новый builtin/keyword в рантайме → добавить шим туда (иначе C2143/C2065/LNK2019). Plan 145 дошимил_Static_assert(→ negative-array-size трюк) и полный набор__atomic_*(fetch_and/or/xor/nand, add_fetch/sub_fetch, fetch_max, non-_nload/store; bitwise →_Interlocked*, nand/max → CAS-loop). Барьеры over-strong (full_Interlocked*) — sound для любого запрошенного порядка на x64 TSO.
Остаток → РАЗРЕШЕНО (Plan 145.1 поверх 145.2-детерминизма, 2026-06-15): три из
четырёх узких stmt-expr-сайтов устранены портируемо — struct-element write по индексу
(vec_of_struct[i] = val → теперь memcpy-в-слот вместо присваивания struct-значения
через *(struct*)void_ptr-lvalue, снимает C2440), heap-box примитива в throw-как-
выражении (→ nova_box_value + scalar compound literal), Option-get composite repack
(→ nova_npo_from_tagged_int + compound literal). Каждый сайт закрыт co-located
регресс-фикстурой (nova_tests/plan145/t2_index_write_pos,
nova_tests/plan145_2/throw_primitive_expr_pos, …/composite_get_option_pos), которая
сверяет наличие портируемого хелпера в .c и PASS на clang+MSVC. Остаточный P3
([M-145-msvc-remaining-stmt-expr]): только value-record throw-как-выражение
(x ?? throw SomeRecord{}) — multi-field compound-literal не годится для member-init,
rvalue в expression-контексте небезопасно хоистить; stmt-expr оставлен сознательно.
Разблокировку обеспечил Plan 145.2 (детерминизм эмиссии — см. D279).
Прецедент: D-блок Plan 82 (compat-слой
nova_msvc_compat.h). D276 обобщает правило на ВЕСЬ генерируемый C, а не только runtime builtins. Конвенция для разработчиков — также в compiler-codegen/README.md §«MSVC-портируемость генерируемого C».
D279 (NEW) — Codegen emission must be deterministic (stable declaration order) (Plan 145.2)
Решение: Порядок эмиссии C-кода (деклараций типов/функций, NovaOpt-typedef’ов, enqueue в mono-worklist) обязан быть детерминированным — стабильным между сборками и прогонами на одной и той же Nova-программе. Эмиссия НЕ должна зависеть от порядка итерации структур с недетерминированным обходом. Конкретно: коллекции
emit_c.rs, по которым итерируется генерация (method_overloads,embed_fields), используютBTreeMap(отсортированный обход по ключу), а неHashMap(у RustRandomState— seed per-process, порядок рандомизирован).
Контекст. Недетерминированный порядок обычно безвреден, но он будил латентные
order-зависимые баги prelude, всплывавшие нерегулярно «через раз»: (A) init-order OOB
(array: index 0 out of bounds for length 0 — статик-инициализация читала ещё не
заполненный массив), (B) var_boxed cross-function leak (CC-FAIL: _box_<v> для
mut-capture замыкания просачивался между функциями → undeclared identifier). Та же
программа давала разный .c между сборками → диагностика «bisect-by-rebuild» становилась
ненадёжной (main 12/12 PASS, тот же source на другом бинаре 5/5 FAIL). Детерминизм —
предусловие воспроизводимости (надёжный bisect/diff .c, кэш сборки, отладка
codegen) и страховка от того, что эти классы багов снова начнут «будиться».
Правило для разработчиков. Любая новая map/set в emit_c.rs, по которой
итерируется эмиссия (а не только point-lookup), должна иметь детерминированный обход
(BTreeMap/BTreeSet либо явная сортировка ключей перед итерацией). HashMap/HashSet
допустимы только для чистого lookup, результат которого не влияет на порядок вывода.
Остаток (benign, P3
[M-codegen-emission-nondeterminism]): порядок объявленияNovaOpt_<T>-typedef’ов всё ещё зависит от первого касания (косметика — независимые typedef’ы, на сборку не влияет); robustvar_boxed-flush и топологический порядок статик-инициализации prelude — отдельный hardening. Триггер-часть (то, что будило баги) закрыта. Связь: D279 — предусловие, снявшее блокер с остатка D276 (Plan 145.1).
D274 — Tree-walking interpreter currently UNSUPPORTED; C-codegen only (Plan 157)
Решение. Древесный интерпретатор (nova run, модуль compiler-codegen/src/interp/)
временно НЕ поддерживается. Nova собирается, тестируется и поставляется только
через компиляцию в C (nova build, nova test, nova test-build).
nova runостаётся видимой подкомандой CLI (discoverability), но при вызове немедленно завершается с ошибкой и подсказкойnova build <file>/nova test(exit ≠ 0; help помечен[UNSUPPORTED]). Сознательная громкая граница, не тихий no-op.- Внутренний dev-бинарник
nova-codegenтоже застаблен (2026-06-14): его командыrunиtest-interpбольше не конструируютinterp::Interpreter, а немедленно ошибаются (exit ≠ 0) с указанием на C-codegen (nova-codegen compile,nova test); doc-строки clap помечены[UNSUPPORTED]. Прочие команды (compile,check,test-build, …) работают. То же громкое поведение, что и уnova run. - Модуль
interp/сохранён «для справки» (помечен//!-нотой), но из пайплайна исключён. Мёртвые interpreter-тесты (integration.rs,spec_nova.rs,run_interp_named.rs,common/mod.rs) удалены — они ссылались на изъятый библиотечный крейтnova. - Регресс-защита контракта —
nova-cli/tests/interp_unsupported.rs(negative:nova runошибается + указывает на C-codegen; positive:nova checkработает) иcompiler-codegen/tests/interp_tool_unsupported.rs(negative:nova-codegen run/test-interpошибаются; positive:nova-codegen compileработает), прогон через релизные бинарники.
Почему. Интерпретатор расходился с C-семантикой и тормозил разработку; единый
C-codegen-путь — единственный поддерживаемый и тестируемый. «пока» намеренно: возможна
полная вырезка ЛИБО восстановление — см. Q-interpreter-future.
Связь: Plan 157,
open-questions Q-interpreter-future, маркер [M-interp-unsupported].
D275 (NEW). Codegen: if/match-выражение коэрсится в unit, когда одна ветка unit (паритет if↔match)
Source: ветка plan-cgfix-fluent-tail-if (2026-06-14).
Status: ✅ ACTIVE 2026-06-14.
Closes: [M-codegen-fluent-tail-if-unify].
Связь: D132 (-> @ fluent-return), D55 (match с unit-веткой → стиль), D217 amend Plan 123.4.4, Q-match-unit-arms-in-expr.
1. Что фиксируется (codegen-семантика)
Когда if/match-выражение стоит в statement/discard-позиции и одна из
его ветвей даёт реальное value-T (например fluent-хвост -> @: v.push(cp)
типа Nova_Vec*), а другая, не-расходящаяся ветка даёт nova_unit, типы
ветвей C-несовместимы. В этом случае всё if/match-выражение коэрсится в
unit — значения отбрасываются (_nv_if = NOVA_UNIT; (void)(<push>);), а не
эмитится tmp(Vec*) = NOVA_UNIT; (несовпадение типов C → CC-FAIL).
Это паритет двух конструкций: emit_match уже имел unit-доминирование
([M-91.13] — unit-арм «доминирует» над value-армами в discard-позиции).
emit_if_expr этой симметрии не имел → if { out.push(..) } else { match {…} }
не компилировался. D275 распространяет правило на if и выравнивает
inference с emit.
2. Правила
- Симметрия ветвей
if. Тип + расходимость считаются одинаково для then- и else-ветки (дляElseBranch::BlockиElseBranch::If). Fluent-хвост может быть в любой из двух — обе стороны проверяются. - Unit-доминирование (gated). Если выбранный (divergence-aware) тип
chosen != nova_unit, но другая не-расходящаяся ветка даётnova_unit— всё выражение →nova_unit. Гейтchosen != nova_unitоставляет обычный value-typedif c {1} else {2}нетронутым. - Расходимость приоритетнее unit (Plan 125 сохранён). Расходящийся сосед
(
return/throw/panic) НЕ форсит unit — это поведение divergence-aware выбора ветки из Plan 125 и оно сохраняется (unit-доминирование смотрит только на не-расходящиеся ветки). - infer↔emit симметрия для
match.infer_expr_c_type(Match)применяет то же правило «есть не-расходящаяся unit-арм → весь matchnova_unit», что иemit_match. Раньше inference возвращал тип не-unit арм’ы (Vec*), пока emit эмитилnova_unit→ enclosingif/assign объявлял tempVec*и присваивал в негоnova_unit→ CC-FAIL. Теперь inference и emission согласованы.
3. Почему это корректно
В discard-позиции значение if/match отбрасывается — выбор «common type»
между несовместимыми ветвями семантически не наблюдаем. Эффекты обеих ветвей
(мутации fluent-хвоста, side-effecty unit-statements) выполняются как и прежде;
коэрсится только тип результата. Downstream emit (emit_block_into/
emit_assign_typed) уже обрабатывает ty == "nova_unit" — новых путей не
требуется.
4. Где в коде
compiler-codegen/src/codegen/emit_c.rs:
emit_if_expr(объявлена ~line 25853; fallback ~25905-25923) — symmetric(else_diverges, else_ty)+ gated unit-доминирование.infer_expr_c_type(Match)(~line 34643) —any_unit_arm→nova_unit, зеркалоemit_match[M-91.13](~27249-27259).
5. Эффект на std
Снят workaround в std/unicode/case.nv: per-codepoint мэппинги больше не
обязаны прятать push в for-циклах через Vec[int]-возвращающие helper’ы.
Восстановлен прямой fluent-стиль: single → Vec[int].with_capacity(1).push(cp);
fold_case/to_uppercase/to_lowercase — match/if с fluent out.push(cp)
в одной ветке и unit-сиблингом в другой (в т.ч. Final_Sigma-путь
if { out.push(..) } else { match {…} }).
6. Acceptance (G0 «без упрощений» — реальный prod-стиль, не воркэраунд)
- CFI-1 Fluent
Vec.push-хвост в then, unit else → компилится,if=unit ✅ - CFI-2 Зеркало — fluent-хвост в else, unit then ✅
- CFI-3 No-else (
if c { v.push(1) }) ✅ - CFI-4 Nested
ifс fluent-хвостами внутри + unit outer-сиблинг ✅ - Контроли (не должны меняться):
if c {1} else {2}остаётсяint; str-ifостаётся str; nested value-ifостаётся str ✅ - Фикстура
nova_tests/cgfix_fluent_tail_if/repro.nv1/1 PASS. - Directly-touched suite
plan152_4(std.unicode) 13/13 PASS с прямым fluent-стилем вcase.nv(workaround убран — критерий приёмки выполнен). - 0 новых регрессий (baseline сверен против merge-base
22aa4944; наборы FAIL идентичны — все pre-existing).
7. Граница / followups
Покрыт discard-позиционный случай (значение отбрасывается). Случай, когда несовместимые fluent-vs-unit ветви стоят в value-position (результат действительно нужен с не-unit типом) — это пользовательская type-ошибка, не codegen-баг; type-checker должен её ловить (родственно Q-match-unit-arms-in-expr). D275 чинит именно codegen-mismatch в statement-позиции.
D375 (ex-D277, renumber 2026-07-03) — Per-type GC pointer-offset bitmaps (Plan 144.1, аналитическая половина Ф.1; emit-nothing)
Создан: 2026-06-15 (Plan 144.1, ветка
plan-144.1-heap-bitmaps). Compile-time, EMIT-NOTHING анализ: вычисляет для каждого именованного пользовательского типа (record / sum / named-tuple / newtype) и релевантных встроенных value-типов (str) набор байт-смещений внутри эмитимого C-объекта, по которым лежат GC-управляемые указатели — per-type pointer-offset «bitmap» (аналог Gogcdata). Это heap-сторона точного GC из Plan 144 §7 («Heap-сторона достижима»): Nova знает layout типов → точная карта смещений указателей. Sibling-прецедент D273 (may-GC, та же emit-nothing-форма, дыра H4/Q15). Ничего не эмитит в генерируемый C; layout-id в заголовок объекта НЕ пишется — потребляется точным mark-sweep’ом в Ф.5 (Plan 144.5), отдельно и под гейтом.
1. Что фиксируется
Per-type карта { type-name → {pointer_offsets, variants, size, align, unresolved} }:
- record / named-tuple / newtype / value-record /
str:pointer_offsets— байт-смещения GC-указательных слотов в declaration-order layout;variantsпуст. - sum-тип:
pointer_offsetsпуст (тег @0 — скаляр),variantsдержит отдельный bitmap НА КАЖДЫЙ вариант (keyed по имени + tag-индексу), перечисляя GC-смещения, активные когда выбран этот тег.
2. Классификация слота: что есть GC-указатель
Поле — GC-указательный слот ⟺ его C-представление — указатель В GC-heap:
boxed record / boxed sum / Vec·Map·heap-контейнер / str.ptr / closure-env-указатель /
dyn·protocol-объект / boxed Option·Result. НЕ-указатель (skip): скаляры
(int/i32/i64/f32/f64/bool/char/unit, C-enum-теги), raw FFI-указатели
(*ro u8 и т.п. — указывают ВНЕ GC-heap), value-встроенные скалярные поля.
str = {ptr: *u8, len: int}:ptr(@0) ЕСТЬ указательный слот (object-start lookup на mark толерантен к interior / не-GC буферу — §7.6 H1);len— скаляр. Помечается смещениеptr.- Вложенные value-records / value-типы: РЕКУРСИЯ — смещения указателей вложенного типа встраиваются на смещении поля (offset поля + относительные offset’ы вложенного).
Option[T]/Result[T,E]: учитывается NPO — когда inner лоуэрится в одиночный указатель (type_ref_to_cдаёт*-суффикс),Option= один heap-указатель @0; иначе — tagged-inline форма (рекурсия в payload, тег-скаляр).
3. Layout-точность (суть)
Смещения/размеры/выравнивание ОБЯЗАНЫ совпадать с тем, что codegen реально эмитит для C-struct,
иначе будущий tracer читает мусор (неверный offset = unsound tracer). Карта ПЕРЕИСПОЛЬЗУЕТ
канонический layout-калькулятор const_fn_eval::type_size_or_align_resolved (тот же field-walk, с
которым согласован emit_c::type_decl_size_or_align) для математики рекурсии и сворачивает
per-field-offset’ы вдоль идентичного pad-then-place цикла. Три расхождения math↔emit примирены
сайзингом поля по его эмитимому C-представлению: [N]T FIELD → один heap-указатель
(NovaArray_T*), не N inline-элементов; []T FIELD → один 8-байт Nova_Vec*, не 16-байт slice;
char → nova_char = typedef uint32_t (4 байт, скаляр; D128 AMEND Plan 152.8 — было int64_t 8 байт).
4. Консервативное направление (soundness-инвариант)
При ЛЮБОМ сомнении — НИКОГДА не пропустить реальный GC-указатель (пропуск → freed-while-reachable
→ UAF). Когда GC-ность / layout поля неизвестны / не обработаны (неразрешённый generic-слот, erased
nova_int-boxed элемент, opaque/protocol-тип без layout, любой C-тип, который классификатор не может
доказать скаляром) — ПОМЕТИТЬ КАК УКАЗАТЕЛЬ (over-approximate). Ложное удержание — меньшее зло;
пропуск указателя фатален (зеркало дефолта-в-MayGC у D273). Тип, чей layout
не разрешается, репортится как unresolved=true (потребитель ОБЯЗАН сканировать каждое слово),
НИКОГДА как all-scalar. Не-указательная классификация эмитится только когда поле
доказуемо скаляр / raw-FFI-указатель с не-GC pointee / value-встроенный скаляр.
5. SUM: per-variant, НЕ union
Выбран per-variant bitmap (keyed по тегу), а НЕ union-over-variants: union сканировал бы скаляр
неактивного варианта как указатель = ложное удержание, что губит точность. Тег-поле само — скаляр.
Per-variant — точный дизайн (прецедент Go gcdata на вариант / .NET).
6. Реализация и introspection
compiler-codegen/src/codegen/gc_layout.rs — compute_gc_layout / GcLayoutMap / LayoutInfo /
VariantLayout / classify_field. Introspection-CLI nova gc-layout-analyze <path> [--format text|json] (зеркало gc-effect-analyze / consume-analyze; в бинарь ничего не эмитит).
emit_c.rs НЕ зовёт модуль во время эмиссии — emit-nothing-инвариант (проверено grep’ом: ссылки
на gc_layout только в mod.rs / самом модуле / main.rs-CLI).
7. Acceptance
plan144_1: 5 фикстур через релизный nova gc-layout-analyze (record со смешанными ptr/scalar полями;
sum с per-variant bitmap’ами; nested value-record рекурсия; scalar-only пустой bitmap; heap-тип с
str+Vec-полями); 21/21 gc_layout unit-тестов PASS (включая raw-*ro u8-поле НЕ помечается, а
соседний Vec помечается); release-сборка чистая, генерируемый C байт-в-байт не изменён
(emit-nothing). Без упрощений как для прода — layout-точно, soundness-first, без stubbed-offset’ов
и маскировки.
8. Open / long-term
Точность closures env-bitmap (per-capture; word0 fn-ptr = не-GC code-pointer), generic/erased
инстанциации, FFI-pointer edge-cases — Q-gc-layout-precision.
Все residual’ы консервативны (over-approximate, остаются соундны). Runtime-потребление
(layout-id в заголовке объекта + точный tracer) — Plan 144.5 Ф.5,
маркер [M-144.1-heap-bitmaps].
D280 (NEW) — Ссылка на heap-тип BY VALUE = pointer-size (8 байт), не inline object-size
Создан: 2026-06-15 (branch
fix-checker-recursive-type-overflow, commits219be59a+1245ef68, маркер[M-checker-recursive-type-overflow]). Фиксирует layout-семантику, которуюemit_cуже эмитит, но которую type-size-калькулятор раньше игнорировал → stack-overflow на рекурсивных типах. Уточняет/амендит layout-инвариант, на который опирается D277 §3 (канонический size-walkconst_fn_eval::type_size_or_align_resolved); пойнтер-инвариант — D216 §1.
1. Правило
emit_c лоуэрит heap-аллоцируемый пользовательский тип (AllocKind::Heap record / любой sum)
в указатель Nova_X* везде, где он встречается как значение / поле / переменная (поле, аргумент,
локал, элемент). Поэтому ссылка на такой тип занимает pointer-size = 8 байт (align 8, x64 ABI),
а НЕ inline-размер объекта. Inline object-size (байты ВНУТРИ heap-аллокации) релевантен лишь как
top-level subject layout’а (что и считает GC-bitmap [D375]).
size_of[heapT]() / align_of[heapT]() теперь возвращают 8 / 8 — это корректное
reference-semantics-значение (переменная heap-типа есть Nova_X*), emit-accurate. Для value-типов
(type T value { … } record / named-tuple / newtype / alias) ничего не меняется — они инлайнятся, и
size остаётся inline object-size.
2. Почему это и эмиссия, и устранение overflow
Раз ссылка на heap-тип — указательный лист (8 байт), рекурсивный heap-тип конечен:
type Tree | Leaf | Node(int, Tree, Tree) + type H { t Tree } — поле t есть Nova_Tree*, рекурсии
по layout нет. Boxing-неосознающий size-walk раньше инлайнил object-size поля Tree → бесконечный
спуск Tree → Node-payload → Tree → … → stack-overflow в nova check / build / каждом
introspection-инструменте (gc-effect-analyze, gc-layout-analyze). Boxing-aware short-circuit на
pointer-size одновременно emit-точен и убирает overflow на ВАЛИДНЫХ рекурсивных heap-типах.
3. Soundness / blast radius
type_size_or_align_resolved бэкает size_of/align_of; после фикса size_of[heapT] = 8. Это
reference-semantics-корректно (никакого ослабления). Genuinely-infinite value-self-cycle
(type N value { next N } — без указательной индирекции, инлайнится) обрабатывается depth-budget’ом
(MAX_TYPE_SIZE_DEPTH = 128) в внутреннем хелпере: walk возвращает None вместо overflow; каждый
вызывающий деградирует мягко (никакого краша). Public-сигнатура type_size_or_align_resolved
сохранена. Реализация — compiler-codegen/src/const_fn_eval.rs (boxing-aware Named-арм + depth-helper) +
visited-set guard в types::type_is_consume. Без упрощений как для прода: layout-точно (совпадает с
emit_c), soundness-first (no crash; size_of корректен), без маскировки.
4. Остаток
Dedicated E_INFINITE_TYPE-диагностика для genuinely-infinite value-типов НЕ реализована — None
сёрфейсится как generic E_CONST_FN_GENERIC_NEEDS_T_REFLECTION (no-crash гарантирован) —
Q-infinite-value-type.
D282 (NEW) — extern "nova" fn / extern "C" fn — двух-ABI синтаксис для FFI (Plan 91.12 Ф.-1)
Source: Plan 91.12 Ф.-1, 2026-06-15. Status: ✅ ACTIVE. Связь: D82, D126, Plan 91.12.
Мотивация
external fn (D82) всегда добавляет nova_fn_ prefix к C-имени — это правильно для
runtime-функций (они живут в nova_rt/*.h под именем nova_fn_<name>), но неприемлемо для
функций из сторонних C-библиотек, где нужен вызов foo() напрямую без оберток.
Чтобы подключить любую C-библиотеку без дополнительных C-шимов, вводится extern "C" fn.
Синтаксис
ExternFnDecl ::= ['export'] ('extern' AbiStr | 'external') ['unsafe'] 'fn' FnSig
AbiStr ::= '"nova"' | '"C"'
extern "nova" fn foo()— runtime fn; codegen генерирует вызовnova_fn_foo. Семантика идентична старомуexternal fn. Предназначен для функций вnova_rt/*.h.extern "C" fn bar()— C library fn; codegen генерирует вызовbar(литеральное имя). Предназначен для подключения функций из внешних C-библиотек без оберток.external fn— legacy alias дляextern "nova" fn, остаётся валидным.
Правила
- Обе формы — bodyless declaration (как
external fn). Тело запрещено. - Типы параметров И возврата
extern "C" fnдолжны быть C-ABI-совместимы (Plan 174.6 M0 amend, 2026-07-04 — прежний список был занижен и местами неверен:str, туплы и value-records уже используетstd/net/ffi.nv, аstrошибочно числился ABI-mismatch). Формальное рекурсивное определение:C_ABI ::= Scalar | RawPtr | FnPtr | Option[RawPtr] | Tuple[C_ABI…] | ValueRecord{ C_ABI… } Scalar ::= int | uint | i8..i64 | u8..u64 | f32 | f64 | bool | char RawPtr ::= *T | *() | CStr FnPtr ::= *extern "C" fn(C_ABI…) -> C_ABI // C-ABI fn-указатель, D353- Scalars:
int,uint,i8–i64,u8–u64,f32,f64,bool,char.uint= address-sized unsigned, C-типnova_uint(= uintptr_t, size_t-аналог, D130) — неuint64_t; отделён отu64так же, какint(address-sized signed, C-типnova_int) отделён отi64. - Raw-указатели:
*T(любой pointee),*(),CStr(=*u8). - C-ABI fn-указатели:
*extern "C" fn(C_ABI…) -> C_ABI— базовый C-ABI случай (для передачи Nova-функции как настоящего C-callback:qsort-компаратор, libuv-хендлер и т.п.). Позволяет fn-указателю быть параметром/полем другогоextern "C" fn/ value-record. Типы сигнатуры сами рекурсивно C-ABI; ABI-тег, коэрция и обоснование — D353. Nova-ABI*fn(без тегаextern "C") — НЕ C-ABI (Nova-типы в сигнатуре; передаётся Nova-ABI’ем). - value-records и туплы (анонимные + именованные) — C-ABI iff ВСЕ поля C-ABI; передаются
by-value как C-struct.
str= value-record{ptr,len}(D139) → C-ABI. Циклический value-record (type Node value { val int, next *Node }) — C-ABI: поле*Node= raw-ptr (базовый случай, рекурсия обрывается на указателе). Ключевое словоvalueобязательно — без него (type Node { … }) это heap GC-record (Nova_Node*, by-reference, 02-types.md), исключённый negative-list ниже как non-C-ABI. Option[X]— C-ABI iffX— RawPtr (любой указательный тип:*T/*()/CStr; NPO применим к любому указателю:None=0,Some(p)=p).Option[non-ptr]иOption[Option[*T]]— НЕ C-ABI (нет NPO).- Края:
()/ unit / zero-field tuple как тип параметра/элемента — запрещено (в C нет unit). Top-level-> ()=voidв C-сигнатуре — допустимо. - НЕ C-ABI (M1 —
E_FFI_NON_C_ABI_TYPE): GC-типы (Vec, heap-record-ссылки), closures-with-env, generic tagged unions (Option[non-ptr],Result, прочие sum — теговый layout не C-ABI). - Примеры ✅:
(int, CSocketAddr)·str·Node value {val int, next *Node}·Option[*u8]·uint/f64/char·*extern "C" fn(*u8, int) -> int(C-callback-параметр). ❌:Vec[int]·Result[int,str]·Option[int]·()/zero-field tuple · fn-с-env · Nova-ABI*fn(нетегированный) как C-callback. - Layout-допущение (S8): value-record/тупл by-value предполагает Nova-layout == C-layout
(порядок полей/padding). Несовпадение → follow-up
[M-174.6-ffi-struct-layout](вне scope M0). - Проверка — в ЧЕКЕРЕ на сигнатуре
extern "C" fn(не в codegen/C), диагностикаE_FFI_NON_C_ABI_TYPE— Plan 174.6 M1 (rule 3 — fn-ptr ABI-тег, D353 — тоже M1). M0 (это изменение) = только спека тип-листа.
- Scalars:
extern "C" fnНЕ регистрируется вExternalRegistry(не добавляетnova_fn_prefix даже при module-mangling). Регистрируется вCEmitter.c_literal_extern_fns.export extern "C" fn— допустимо (экспортирует C-fn в Nova-модуль). Безexport— приватна для модуля (подходит дляffi.nv-слоя std/net).unsafemodifier валиден для обеих форм:extern "C" unsafe fn memmove(...).
Реализация (Ф.-1)
| Компонент | Изменение |
|---|---|
lexer/token.rs | Добавлен KwExtern |
lexer/mod.rs | "extern" => KwExtern |
ast/mod.rs | FnDecl.extern_abi: Option<String> |
parser/mod.rs | extern STRING fn → (is_external=true, extern_abi=Some(abi)) |
codegen/external_registry.rs | Pass 1+2: skip extern_abi == Some("C") |
codegen/emit_c.rs | c_literal_extern_fns: HashSet<String>, проверяется первым в free_fn_c_name |
editors/*/ | extern добавлен в грамматики и syntax highlight |
tests/syntax_highlight_conformance.rs | extern добавлен в ACTIVE |
std/**/*.nv (136 файлов) | external fn → extern "nova" fn |
D353 (NEW) — ABI-тег fn-указательного типа: *extern "C" fn (Plan 174.6 M0)
Source: Plan 174.6 M0, 2026-07-04. Status: ✅ ACTIVE (spec); парсер/чекер — Plan 174.6 M1.
Связь: D282 (C-ABI тип-лист + extern "C" fn), D216 §10 (fn-ptr типы
*fn/*unsafe fn), Plan 174.6.
Мотивация
У fn-указательного типа (*fn(...), D216 §10) не было ABI-тега — по типу неясно, Nova-ABI это
(Nova-типы в сигнатуре допустимы) или C-ABI (для настоящего C-callback нужны строго C-ABI типы). Для
передачи Nova-функции как C-callback (qsort-компаратор, libuv-хендлеры и т.п.) нужен различимый
C-ABI fn-ptr тип.
Решение
*fn(...)/*unsafe fn(...)(без тега) — Nova-ABI captureless fn-ptr: Nova-типы в сигнатуре допустимы (Nova ABI их передаёт). «Captureless» — про отсутствие env, не про типы.*extern "C" fn(...)— C-ABI captureless fn-ptr; синтаксически параллельно объявлениюextern "C" fn(D282). Тег живёт на уровне типа. Типы сигнатуры (параметры + возврат) — C-ABI по D282 rule 2 (рекурсивный тип-лист). Nova-ABI (*fn, без тега) и C-ABI (*extern "C" fn) визуально различимы.*extern "C" unsafe fn(...)— комбинируется сunsafe(постфиксный pointee-модификатор, D216 §10).
Коэрция fn → *extern "C" fn (чекер — M1)
Nova fn[A]->R коэрцится в *extern "C" fn[A]->R iff: (1) каждый A/R — C-ABI (D282 rule 2),
(2) функция captureless (нет env), и (3) функция effect-free / total — не объявляет никакого
эффекта (пустая effect-row: ни Fail, ни IO/Async/Time, ни custom algebraic). Нарушения → соответственно
E_FFI_NON_C_ABI_TYPE (Nova-only тип в C-callback) / E_CLOSURE_HAS_ENV / эффект-гейт (ниже).
Nova-ABI *fn (с Nova-типами), переданный как C-callback, → E_FFI_NON_C_ABI_TYPE.
Почему (3) — soundness (не только Fail): C зовёт callback как обычный C-указатель — на стеке нет
Nova-handler-фрейма. Любая effect-операция внутри callback’а (perform Fail, IO, custom algebraic effect)
ищет handler вверх по стеку → его там нет → unsound (UB / паника без хендлера). Поэтому коэрция требует
полного отсутствия эффектов, а не только no-Fail. Это обобщает Fail-специфичный гейт
D216 §10/§20 (C ABI не пробрасывает Nova-исключения): частный случай Fail-эффекта
диагностируется существующим E_CALLBACK_THROWS_OVER_C_ABI; любой иной объявленный эффект отвергается по
тому же правилу «no effect over C ABI» (конкретная диагностика non-Fail-эффектов — чекер M1, см.
error-index-долг в Scope).
Cast/коэрция-матрица fn / *fn / *extern "C" fn (Plan 174.6 M2)
Три fn-указательных мира различимы на уровне типа: Nova-closure fn(A)->R (fat, может нести env),
Nova-ABI указатель *fn(A)->R (captureless, Nova-типы + Nova handler-стек), C-ABI указатель
*extern "C" fn(A)->R (captureless, строго C-ABI типы, без эффектов). Матрица коэрции источника
expr as <target> (поведение чекера M1; исходник — свободная fn по имени, если не указано иначе):
Источник (expr) | as *fn (Nova-ABI) | as *extern "C" fn (C-ABI) |
|---|---|---|
| free fn: captureless, effect-free, C-ABI сигнатура | ✅ | ✅ |
free fn: captureless, effect-free, сигнатура с Nova-типом (Vec/Result/…) | ✅ (Nova ABI несёт Nova-типы) | ❌ E_FFI_NON_C_ABI_TYPE |
free fn с эффектом Fail | ❌ E_CALLBACK_THROWS_OVER_C_ABI (§20 / D216 §10) | ❌ E_CALLBACK_THROWS_OVER_C_ABI |
free fn с non-Fail эффектом (IO/Async/custom) | ✅ (Nova handler-стек на месте) | ❌ E_CALLBACK_THROWS_OVER_C_ABI (clause 3) |
closure-литерал с env / bound-method obj.@m | ❌ E_CLOSURE_HAS_ENV | ❌ E_CLOSURE_HAS_ENV |
Тип↔тип (не coercion). *fn и *extern "C" fn — разные типы (различный ABI-тег): нет
неявной конверсии ни в одну сторону — *fn-значение в позиции, ожидающей *extern "C" fn (и
наоборот), = type-mismatch. Явный reinterpret между ABI-тегами не поддержан M1 (design: возможен лишь
как unsafe-hatch — вне scope; маркер [M-174.6-ffi-abi]). Композиция с unsafe — *extern "C" unsafe fn
(зеркалит *unsafe fn, D216 §10a); covariance *fn → *unsafe fn действует ортогонально ABI-тегу.
Позиция тега. *extern "C" fn — легальный тип в любой type-position, не только как параметр
extern "C" fn: сигнатура C-callback валидируется как C-ABI и когда тег стоит на параметре/возврате
Nova-функции-обёртки или поле value-record (libuv-handler-в-handle) — Plan 174.6 M2
(ffi_validate_c_fnptr_occurrences). Аннотации локальных переменных внутри тела fn пока не обходятся
(остаток §10 плана).
Runtime-materialization. Матрица описывает чекер/тип-слой. Фактическая эмиссия значения
fn → fn-ptr (для любого тега) упирается в pre-existing codegen-gap P67-LEGACY (Plan 118 Ф.6 follow-on) —
acceptance проверяется на checker-слое; runtime-materialization — вне 174.6 M2/M3.
Scope
M0 (это изменение) = только спека: тип *extern "C" fn + правила коэрции + C-ABI тип-лист (D282 rule 2).
Парсер (*extern "C" fn синтаксис), чекер (E_FFI_NON_C_ABI_TYPE, E_CLOSURE_HAS_ENV, эффект-гейт коэрции,
тег на типе, проверка C-ABI сигнатуры и коэрции) и тесты — Plan 174.6 M1–M3 (D282 rule 3).
Followup-маркер: [M-174.6-ffi-abi].
Error-index-долг (deferred → M1) — ✅ DISCHARGED Plan 174.6 M1 (2026-07-04): нормативные коды
E_FFI_NON_C_ABI_TYPE (новый), E_CALLBACK_THROWS_OVER_C_ABI и E_CLOSURE_HAS_ENV, на которые ссылаются
D282/D353, занесены в error-index 09-tooling.md (D296 §4, подсекция 104.5.10, guidance-note) вместе с
чекером Plan 174.6 M1 (check_ffi_c_abi_signatures + коэрция-гейт), который их эмитит — message-text из
Plan 174.6 §4. Парсер *extern "C" fn (поле TypeRef::Func.extern_abi),
рекурсивный C-ABI-классификатор (D282 rule 2) и коэрция-гейт (D353, вкл. clause 3 — любой эффект) реализованы.
D294 (NEW) — str @as_ptr() -> *u8 — bytes-FFI bridge (Plan 91.12 Ф.9, 2026-06-16)
Source: Plan 91.12 Ф.9, 2026-06-16. Status: ✅ ACTIVE. Связь: D26, D247, Plan 91.12.
Мотивация
extern "C" fn FFI-функции, принимающие строки, ожидают пару (const uint8_t*, int64_t):
первый байт + длина. До D294 Nova-код должен был хранить указатель вручную (доступ к приватному
@ptr снаружи модуля невозможен).
Спецификация
// std/runtime/string/core.nv — модуль std.runtime.string
export fn str @as_ptr() -> *u8 => @ptr
Семантика:
- Возвращает указатель на первый байт строковых данных (идентичен
@ptrвнутри модуля). - Тип возврата
*u8=*ro u8(read-only pointer; запись через него — UB). - Для пустой строки возвращает implementation-defined non-null pointer (статический sentinel).
- Пара
(s.as_ptr(), s.byte_len())полностью описывает сырой буфер строки без выделений. - Прямой аналог Rust
str::as_ptr() -> *const u8.
Типичное использование
// В ffi.nv (std.net):
extern "C" fn dns_lookup(host_ptr *u8, host_len int, port u16) -> int
// В dns.nv:
ro count = dns_lookup(host.as_ptr(), host.byte_len(), port)
Ограничения
- Указатель действителен пока строка жива (scope строки не должен завершаться раньше FFI-вызова).
- GC не перемещает строки (intern table — статический, heap-allocated строки неперемещаемы).
- Арифметика по указателю в Nova не поддерживается; как сырой адрес используется через
as int.
D381 — Collision-aware module-qualified nominal-type C-mangling (Plan 178/179 merge, 2026-07-06)
Проблема. Пользовательские sum/record-типы манглятся в C по ПРОСТОМУ имени:
Nova_<Name> (struct/typedef), NOVA_TAG_<Name>_<V> (tag-enum), nova_make_<Name>_<V>
(ctor), плюс ключи schema/registry. Два РАЗНЫХ типа с одним простым именем из РАЗНЫХ
модулей в одном CU коллидируют: одна C-структура/tag-enum/registry-entry на всех →
redefinition / потеря вариантов (find_variant возвращает выжившего) / ICE
(Ident 'X' not a sum-variant). Триггер: merge 178/179 свёл в один positive-CU
spec_tests.conformance ТРИ разных ErrorKind (std.io / std.http /
std.encoding.compress) с общими именами вариантов (Other, InvalidData) РАЗНОЙ арности.
Решение — collision-aware, а НЕ always-qualify. На входе emit_module строится карта
простое-имя → {объявляющие модули} (из peer_files, каждый несёт module_name +
items_here). Простое имя, объявленное в ≥2 РАЗНЫХ модулях, считается коллидирующим
и получает module-qualified базу Nova_<modpath>_<Name> (соответственно
NOVA_TAG_<base>_<V>, nova_make_<base>_<V>, schema/registry-ключи по <base>). Все
НЕ-коллидирующие имена остаются байт-идентичны (Nova_<Name>): множество коллизий пусто
в любом CU без реальной cross-module коллизии → вся квалификация — no-op, и .c побайтно
не меняется. Это минимизирует baseline-churn (в отличие от always-qualify, ломающего весь
корпус и extern-контракты Nova_str_*-класса) и чинит коллизию по построению.
Область. Квалифицируются ТОЛЬКО concrete (non-generic) типы с pointer-identity
Nova_<Name>*: plain-Sum и heap-Record. Newtype/alias (type_aliases-indirection),
value-record/named-tuple (NovaValue_/NovaTuple_), effect/protocol/type-set, opaque
(nova_rt-хедеры) и generic-шаблоны (mono-путь именует по-своему) — ИСКЛЮЧЕНЫ (отдельная ось;
followup). Prelude/builtin (RUNTIME_DEFINED_TYPES) — никогда.
Резолюция модуля. DEF-модуль типа = module_name файла, где он объявлен
(TypeDecl.span.file_id → module). REF-резолюция (в какой модуль резолвится ссылка на
коллидирующее имя из данного файла) = модуль этого файла, если он его объявляет (Rule C /
D281 — peers folder-модуля делят объявления БЕЗ import), иначе — единственный selectively-
импортированный модуль (инвариант чекера: bare-имя типа однозначно внутри файла). Match
пути импорта с module_name — по суффиксу (import несёт полный package-path
std.encoding.compress, а module-декларация может опускать package-root: encoding.compress).
Дизамбигуация bare-конструктора варианта. Общий вариант (Other во всех трёх
ErrorKind; InvalidData в io-unit vs compress-payload) резолвится: (1) по арности
payload на call-сайте (InvalidData(msg) — 1 arg — не io-unit InvalidData); (2) внутри
одной арности — по сумме из типа возврата текущей функции (Other(code) в io
kind_from_errno); (3) registry-fallback по уникальному имени варианта, когда file-context
недоступен (erased/mono тела). Дизамбигуация среди plain (не-mono) кандидатов — mono-
инстансы владеет generic-путь (иначе call без queuing инстанса → undefined symbol).
Единая точка. Одна пара хелперов def_type_base(name, file_id) (DEF) / ref_type_base(name)
(REF) + qualify_type_base; каждый — no-op (identity) для не-коллидирующего имени. Все
mint-сайты (struct/tag/ctor def; resolved_named_to_c ref; pattern-tag; variant-ctor
call; schema/registry ключи; mono-тела через per-fn file-context) маршрутизируются через них.
Гейт. nova test spec_tests/conformance (io+http+compress ErrorKind в одном CU) =
PASS N/0 (фикстуры d322/d358/d333-336 возвращены в conformance из workaround-локаций
spec_tests/http, spec_tests/compress). Zero-regression: не-коллидирующий корпус
byte-identical по КОНТЕНТУ (расхождения = pre-existing typedef/variant-order
nondeterminism, [M-codegen-emission-nondeterminism], тот же multiset — baseline флюктуирует
идентично).
НЕ покрыто (отдельная ось, followup): variant-имя ↔ type-имя clash в резолюции
emit_call (NetError.IoError(msg) мис-роутится в static-method Nova_NetError_static_IoError
когда одноимённый ТИП IoError co-present) — [M-codegen-cross-module-ctor-emission], НЕ
type-name-collision (репро одинаково на baseline). Квалификация newtype/value-record/generic
одноимённых типов — тоже followup.
Закрывает [M-sync-crossmodule-samename-type-collision] + [M-codegen-nominal-type-name-collision];
разблокирует [M-178-autodecompress-needs-179] (codegen-часть).