← Все решения

Runtime — режимы запуска, panic, prelude, статическое состояние

Решения этой группы определяют, как программа Nova исполняется: поддерживаемые режимы компиляции, что считается panic’ом и как он обрабатывается, что предоставляет prelude и почему в языке нет static-состояния.

#Решение
D7Один язык — три режима компиляции
D13Panic vs эффекты: что НЕ является эффектом
D26Базовая stdlib и prelude
D41Static-функции есть, static-состояния нет
D70⚠️ REPLACED → D73 (migration map only)
D73From / Into protocol-пара с авто-выводом
D74Математические операции на числовых типах — instance-методы
D77TryFrom / TryInto — расширение D73 для fallible-конверсий
D76Mem эффект — runtime introspection для leak/growth тестов
D81assert(cond) vs debug_assert(cond) — build-mode семантика
D141Примитивы доступа к памяти — byte_at / bulk slice-операции
D177str Nova-body dispatch — Plan 54 Ф.2 extension
D178str API cleanup и расширения — Plan 91 Ф.2.6
D178 amend V2str @try_parse_int + ParseIntError sum type — Plan 91 Ф.2
D179StringBuilder — 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_assert failure = 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_assert failure = 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: сохраняется (слово assertionassert); (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’а
Как создаётсяthrowpanic(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)текущий fiberruntime’ом на границе 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), Go os.Exit(code), Rust std::process::exit(code), Python sys.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), антипаттерн.

Связь


D26. Базовая stdlib и prelude

MAJOR AMEND (Plan 152.1 / D249-D250, 2026-06-13): разворот «школы B» на координатную модель линз + инвариант R-UTF8. Прежняя D26-формулировка («все public-операции str codepoint-indexed, O(n)») РАЗВЁРНУТА:

  • Координаты str — байтовые. s[i] (int) запрещён (E_STR_NO_INT_INDEX); единственный str[..] — byte-range slice s[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_bytes checked → 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/print/println/panic) реализована hardcoded в type-checker’е и codegen’е. Параллельно compiler-codegen::imports auto-импортирует std/prelude.nv если файл существует — это opt-in mechanism для расширения prelude из пользовательского кода (или для миграции hardcoded items в file-based form). Bootstrap MVP: std/prelude.nv содержит placeholder PRELUDE_VERSION = 1.

Plan 62 (закрыт 2026-05-18, PRELUDE_VERSION = 3): большая часть prelude мигрирована в file-based декларации std/prelude/*.nv:

  • std/prelude/core.nvOption/Result/Some/None/Ok/Err/ Error/Ordering. Bottom-тип never — строчный встроенный примитив (Plan 76), в prelude не объявляется (как int/bool).
  • std/prelude/runtime.nvpanic/exit/assert/debug_assert (print/println migrated в Plan 62.B.bis — PRELUDE_VERSION = 7, 2026-05-18).
  • std/prelude/errors.nvRuntimeError (6 variants) + ReadBufferError (RuntimeNoneError deferred — bootstrap parser не поддерживает empty-body sum syntax).
  • std/prelude/collections.nvIter[T] formal protocol declaration.
  • std/prelude/protocols.nvFrom/Into/Hash/Equal/ Compare/Display (6 formal protocols; TryFrom/TryInto deferred — Plan 56 Ф.2.7 effect-row enforcement).
  • std/prelude/effects.nvFail[E] formal effect declaration.

Plan 62.D bis-1 (закрыт 2026-05-18, PRELUDE_VERSION = 4): Range / RangeIter re-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’s edition = "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_shadow clause. См. D125.
  • Time/Mem formal effect declarations добавлены в std/prelude/effects.nv (codegen dispatch неизменен через pre-registered effect_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): print/println formally declared в std/prelude/runtime.nv через D69 variadic + []any (canonical D26 signature fn print(...items []any) Io -> ()). Plan 67 hotfix (silent-wrong-output bug в infer_print_helper для println(str.from(int)) паттерна) absorbed как Ф.0 — refactor через unified infer_expr_c_type dispatch. Codegen special-case (emit_c.rs:11270) fires ДО variadic routing (Ф.1 reorder) — preserves per-arg type info, synthesized []any array никогда не строится; per-arg nova_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 (SumSchemaRegistrycompiler-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) получают приоритет и маршрутизируют вызовы через MethodRouting registry (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 выводит generic T, codegen возвращает nova_int, == после вызова ломается (Plan 62.B+). Option.or — trampoline в nova_rt/array.h отсутствует (Plan 62.B+). Phase 4 (удаление legacy sum_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 — typed Fail[E] effect). Декомпозирован на 4 sub-plan’а: Plan 99.1 (foundation — method-level generic в DeclaredBody: extract resolve_method_level_subst helper, 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 — bare None использует current_fn_return_ty; Ok(v)/Err(e) берут (T,E) из rt; bare Some(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 — паритет Rust FnOnce-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 entry Nova_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 emit Result.err() в codegen (Plan 59 Ф.7.5 D3 — теперь Nova-body эмитит boxed payload сам через mono’d register_novaopt_decl path). Result DeclaredBody-dispatch доработан: mono-имя всегда суффиксированный (Nova_Result_method_<m>_<n>), даже для legacy Nova_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-existing MethodRouting::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 trampoline Nova_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_ii int-layout → calling-convention mismatch).

Bootstrap-ограничения:

  • Result[T, E] зашит на (nova_int Ok, nova_str Err). Generic monomorphization для произвольных T/E — отдельная задача (Q-result-monomorphization). ✅ ЗАКРЫТО (Plan 59 Ф.7.5 increment 2, 2026-05-21): 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 infalliblestring concat в hot loop
WriteBuffer@write_*@into() -> []u8binary serialize
ReadBuffer@read_* / @try_read_*view, no intobinary 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() -> strinfallible (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»; public len() — 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;}). На x64 const 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) мигрированы из external nova_str_X C-функций в 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-keyed HashMap (hash через nova_str_hash SipHash-1-3 по байтам) content-keyed автоматически. @clone = 16-байт handle-copy над общим иммутабельным буфером (*u8 ro-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): str value — 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 @ptr field-доступ требуют новой lang-item checker-инфры. Гейтит: [M-139-f1-trim-view] (zero-copy trim-view), [M-139-f2-ptr-field-producers] (as_bytes/split/from_bytes_* остаются тонкими C-примитивами — C as_bytes уже zero-copy, контракт сохранён). [M-139-f0-rt-header-ptr-sign-casts] — 59 -Wpointer-sign warnings в рантайм-хедерах (source-compatible, подавлены -w).

Q139-блоки (resolved / extracted):

  • Q139-gc-stack-scanRESOLVED: conservative stack-scan достаточен; str-value на стеке — 16-байт {ptr,len}, ptr-поле сканируется как обычный указатель; буферы static-rodata (литералы/interned, не собираются) либо RawMem/nova_alloc (GC-tracked).
  • Q139-literal-buffer-lifetimeRESOLVED: литералы — static rodata (program lifetime, никогда не GC’ятся); built-строки — heap, GC-tracked.
  • Q139-utf8-cursor-primitiveRESOLVED: один helper cp_to_char (codepoint→char) + byte-курсор по @as_bytes(); единственный decode-helper.
  • Q139-str-eq-overrideRESOLVED: 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-terminationRESOLVED (§3 выше, Ф.4): alloc-fallback через nova_str_terminated_ptr.
  • Q139-intern-scopeRESOLVED (§interning ниже, Ф.6): per-CU.
  • Q139-as-bytes-aliasingEXTRACTED в [M-139-f2-ptr-field-producers]: pure-Nova zero-copy as_bytes через @ptr-поле gated на [M-139-f0-lang-item-decl]; C as_bytes уже zero-copy (ro Vec view над *u8 ro-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-slice s[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) была выбрана:

  1. AI-friendly. LLM генерирует код где s.len интуитивно «количество символов». Byte-уровень (Rust/Go) — источник bug’ов у новичков и AI: "Привет".len == 12 нелогично.
  2. Безопасность boundary. Невозможно попасть в середину UTF-8 sequence — все индексы codepoint-выровнены.
  3. Consistency. find / s[a..b] / s[i] — все codepoint-уровень, не нужно мысленно переключаться между byte и codepoint.
  4. Прецеденты: 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), а []u8 mutable — без копии mutate испортил бы immutability str. Стоимость 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:

  1. Full str (literal, runtime-allocated through concat/from_X/etc):

    • Backing buffer is len + 1 bytes
    • ptr[len] == '\0' ALWAYS
    • ptr can be passed directly to C functions expecting const char*
    • Implementation: все Nova allocator paths (nova_str_concat, nova_str_to_upper/lower, string_builder.h, conv.h, literals в .rodata) allocate len + 1 + explicit buf[len] = '\0'.
  2. 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])
    • Parent’s '\0' exists at parent.ptr[parent.len], beyond view’s window
    • Memory access at view.ptr[view.len] ALWAYS safe (within parent buffer которое parent.len + 1 bytes, и view.len <= parent.len).
  3. 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 ptr directly)
      • FALSE → allocate len + 1, copy + NUL (fallback к @to_cstr semantics)
    • Plan 139 Ф.4: реализовано через C-примитив nova_str_terminated_ptr (nova_rt.h) — s.ptr[s.len] peek (всегда safe per §2: parent buffer parent.len+1, view.len <= parent.len) + conditional nova_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. str value-record ABI ({const uint8_t* ptr; int64_t len;}, Plan 139 Ф.0) проходит через const char* FFI boundary без правок (const char*const uint8_t*).
    • SAFE primitive (no #unsafe attribute):
      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_unchecked skips validation for perf-critical paths).
  4. CStr type definition (Plan 118.1 — std/ffi/cstr.nv):

    type CStr(*u8)                   // tuple newtype, zero-overhead
    

    Implements D73 From/Into для str (через try_from per 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 reporterwith Fail[E] = |e| ...
Iostdout/stderrmock-stdout
Netсеть (HTTP/socket)recorded responses
Dbсоединение к БДin-memory db
Fsфайловая системаvirtual-fs
Timeclockfixed_ms(ms u64) / mut_clock(start_ms u64)
RandomRNGseeded(seed u64)
Logloggercapture-log
Ask[T]контекстный read (Reader)fixed value
Alloc[R]region аллокация(для real-time, D6)
Detachbackground schedulerSyncDetach
BlockingOS-thread poolmock

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.

ЭффектКатегория
Meminstrumental, ambient
Traceinstrumental, ambient

Зачем разделять:

  1. Сигнатуры остаются чистыми. Если бы Trace был semantic, то почти каждая функция бы содержала его — observability обычно pervasive. Шум в типах.
  2. AI-friendly. LLM не должна писать Mem в сигнатуре — instrumental detail имплементации.
  3. Интуитивно. Time в сигнатуре говорит “функция зависит от времени, тестируй с fixed clock”. Trace в сигнатуре ничего полезного не говорит.

Не существуют как эффекты

ИмяПочему
Asyncruntime mechanic (suspension, D14 (REVISED))
Parruntime 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/printlnvariadic (D69), принимают любое число аргументов любого типа (anyD54). Каждый аргумент конвертируется в строку через 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)

neverbottom-тип языка: строчный встроенный примитив, в одном ряду с 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 expr
  • panic(...), 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-true cond и без break в body (Plan 125.2 [M-125-while-true-divergence]). Распознаётся только literal true/(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_type returns Some(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.

Связь

Открытые вопросы

  • Полный API Option/Resultчастично закрыт (2026-05-07): базовые методы (is_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).
  • Семантика ? для Option — закрыто D67: ранний return None из текущей функции.
  • Error как универсальный тип — что в нём (поддержка str.from(e), цепочка причин)? Похоже на Rust std::error::Error.

Цена

  1. Список prelude нужно поддерживать. Любое добавление в prelude — breaking change после v1.0 (имя становится «зарезервированным» в модулях). Поэтому prelude минимален.
  2. Импорт-конфликты. Если программист объявит свой type Option, будет конфликт с prelude — компилятор предупредит.

Runtime stdlib проекция (Plan 13)

Все методы str / f64 / f32 которые знает компилятор объявлены в std/runtime/string.nv и std/runtime/math.nvauto-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 для:

  1. Code-review: разработчик видит формальные сигнатуры всех runtime-функций в одном месте.
  2. Type-check без полной компиляции: nova-codegen check загружает декларации и валидирует user-код против них.
  3. 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()             // сброс счётчиков

Без importgc — встроенный 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:

APImallocboehm
heap_size()0 (honest «не поддерживается»)GC_get_heap_size()
live_count()alloc - freealloc_count (upper bound)
alloc_count()countercounter
collect()no-opGC_gcollect()
reset_stats()zero counterszero 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, не keyword effect.
  • 03-syntax.md → D33const — единственный способ объявить immutable «глобальную» константу.

Цена

  1. Привычка из Java/C#/Python ломается. Нет Account.MAX_BALANCE как поля, есть MAX_BALANCE как const в модуле. Чуть длиннее, но единообразнее.
  2. Singleton’ы переписываются как handler. Это не цена, а фича — но мигрирующий код придётся переделать.
  3. 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 → «ToStr protocol: 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] (вместе с D77 TryFrom/TryInto — все четыре) упраздняются. Мотив: их драйверы — растовские, в Nova мертвы. (1) В Rust конверсионные баунды — костыль отсутствия перегрузок; в Nova перегрузки есть (D84), и как generic-баунд From/Into в живом std не используется НИ РАЗУ. (2) ? у нас не делает From-конверсию ошибок (D325: один XError на домен, конверсия явная). (3) Все 103 вызова .into() исполняли роль «представление в строку» — это ось to_str() (D410), а не передача владения; v.into() был единственным вызовом языка, чей смысл не виден без вывода типа цели. (4) Вслед уходит вся обслуга: blanket identity From (синтез per mono), auto-derive From→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) конверсии значения между типами:

  1. From[T] — protocol со static-методом from(v T) -> Self. «Целевой тип знает, как сделать себя из источника. Всегда успешно.»
  2. Into[T] — protocol с instance-методом @into() -> T. «Источник знает, как превратиться в целевой. Всегда успешно.»
  3. Blanket-вывод From → Into — компилятор знает: если тип X имеет T.from(v X), то X автоматически удовлетворяет Into[T]. Программист пишет только FromInto выводится.

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).
  • Любой случай где входные данные могут быть невалидными.

Тонкости:

  1. From и TryFrom независимы — один тип может иметь ОБА: Fahrenheit.from(c Celsius) (инфаллибельный unit-conv) и Fahrenheit.try_from(s str) -> Result[Self, E] (парсинг). Нет ambiguity — разные receiver/параметры.
  2. From НЕ синтезируется из TryFrom — нарушило бы инфаллибельность From. Если нужен blanket TryFrom через инфаллибельный 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)

D73explicit конверсия через 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 — может провалиться

Почему

  1. Нетривиальные конверсии — частая нужда. Единицы измерения (CelsiusFahrenheit), парсинг (strUserId), формат-обмен (JsonUser). Без From каждый тип придумывает своё имя (Celsius.to_fahrenheit, User.parse_json). Единый protocol даёт общий контракт.

  2. Замещает старый ToStr (D70 REPLACED → D73). D70 использовал ту же форму (protocol с одним методом + free function в prelude), но только для конверсии в str. D73 обобщает паттерн на любые конверсии: From + into. Конверсия в str — частный случай D73, не отдельный механизм.

  3. Self универсален (D66). Self в protocol-методе делает объявление коротким — не нужно повторять имя типа. До D66 From[T] потребовал бы typeclass-механизм; с D66 это обычный protocol.

  4. Bounds (D72) разблокируют generic-функции. fn parse[U From[str]] до D72 было невозможно. Теперь — естественно.

  5. Прецедент Rust. From/Into — самый используемый паттерн в Rust ecosystem. Nova берёт идею (явные конверсии через protocol), адаптирует под свою систему (структурная типизация, без orphan rule, free function вместо blanket-impl).

  6. AI-friendly. LLM генерирует Fahrenheit.from(celsius) без обдумывания имени метода. Структурный bound [U From[T]] проверяется compile-time с понятной ошибкой («Bar не реализует From[Foo]: missing static method from(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). Без Into method-form c.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) -> Self instance-метод вместо static. from это фабрика — у неё нет существующего инстанса для @. По D35 fn Type.method для конструкторов / static, что соответствует семантике.
  • as для нетривиальных конверсий (celsius as Fahrenheit). D54 явно ограничивает as — compile-time numeric/newtype/sum. Расширять — теряется граница между cheap-cast и expensive-conversion.
  • Отдельный ToStr protocol для конверсии в строку (старая D70). Конверсия в str — частный случай From[X]-механизма. Иметь два механизма для одной задачи нарушает D9. См. D70 v3 «REPLACED → D73» про переход.

Цена

  1. Без context требуется явный целевой тип. v.into() на bare-line-position не компилируется — нужно либо let x T = v.into(), либо T.from(v) с явным типом-prefix’ом.
  2. Multiple From[X] через overloading по типу параметра (D84) — четыре оси перегрузки и правила ambiguity описаны в D84.
  3. From от типа из чужого модуля. Без orphan rule — добавляешь fn MyType.from(v ForeignType) где угодно, но реализация живёт в модуле, владеющем MyType (по D47 visibility). Если ни один из типов не «твой» — добавить From нельзя без обёртки (newtype). Это сознательное ограничение: предотвращает duplicate conflicting implementations.

Связь

Открытые вопросы

  • From для базовых типов. Stdlib pre-registers str.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 u64UserId.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 конверсий — только D77 TryFrom.
  • Unified 4-way синтез (try_from → auto from + обе 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).

Почему

  1. Согласовано с D35 (03-syntax.md → D35). @-методы — основной механизм для type-bound функций. Числовые операции — type-bound по определению (зависят от типа: i32.abs()f64.abs() в реализации). Использовать static-стиль для одних операций и @ для других — нарушение D40 «один способ».

  2. 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)
    

    Вложенность растёт справа налево, читать тяжелее.

  3. Прецедент Rust / Kotlin / Swift. Все три используют instance- методы для математики ((2.0_f64).sqrt(), theta.cos()). Java/JS/Python со static-стилем (Math.sin(x)) — наследие старой эпохи без object-методов на примитивах.

  4. Free functions конфликтуют с user-кодом. sin(x) как глобальная функция занимает имя sin — пользователь не может назвать так свою функцию без shadowing prelude. @sin() живёт в namespace типа, не глобально.

  5. AI-friendly. LLM пишет theta.cos() без раздумий «math.cos или Math.cos или просто cos». Один паттерн — один способ вызова.

Что отвергнуто

  • Static Math.sin(x) (Java, JavaScript). Менее читаемо для длинных формул, не chain-friendly, и в Nova нет объекта-namespace Math (нет static-namespace объектов как в Java).
  • Free function sin(x) (C, Python). Захватывает короткие имена в глобальном scope, конфликтует с пользовательскими функциями.
  • Trait-style Float protocol с sin/cos/... (Haskell Floating, Rust num_traits::Float). Лишняя indirection, generics с bounds для каждой математической функции усложняют сигнатуры. В Nova f64/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), не конфликт.

Цена

  1. Дублирование методов между f32/f64, потенциально int. Реализация — обычно одна (через builtin / FFI к libm), но объявления повторяются. Это цена отсутствия Float-protocol; терпимо для prelude, который пишется один раз.

  2. x.sqrt() для x < 0 возвращает NaN (IEEE 754) — runtime- surprise. Strict-режим (Fail[NaN]) — отдельная функция @try_sqrt() если понадобится; в base — IEEE без проверок.

  3. Нет 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):

  1. TryFrom[T, E] — protocol со static-методом try_from(v T) -> Result[Self, E]. «Попытка создать Self из T; возможна ошибка типа E».
  2. TryInto[T, E] — protocol с instance-методом @try_into() -> Result[T, E]. «Попытка превратить себя в T».
  3. 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>. Один универсальный путь, не требует отдельного именования.

Почему

  1. Параллельная структура с D73. TryFrom → TryInto blanket зеркалит From → Into (D73). Программист видит один и тот же паттерн для обоих иерархий. Разница только в signature: T vs Result[T, E].

  2. Две отдельные иерархии — чёткая семантика. From = «всегда работает», TryFrom = «может не работать». Кросс-вывод стёр бы эту границу: если From можно получить из TryFrom, то From уже не гарантирует успех (ведь TryFrom может вернуть Err).

  3. Стандартизованное имя try_from. Без D77 разные библиотеки использовали try_parse, parse_or_err, validate и т.д. С D77 — единое имя, как from стандартно для инфаллибельных.

  4. Прецедент Rust: From/Into и TryFrom/TryInto — два отдельных trait иерархии в std. From не выводится из TryFrom. Nova повторяет эту модель.

  5. Option получается бесплатно через Result.ok(). Не нужны _or_null-suffix имена (Kotlin), init? (Swift). Один try_from — доступны ?, ok(), unwrap_or().

  6. 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 строго инфаллибелен.

Цена

  1. Две иерархии вместо одной. Программист выбирает явно: fallible (TryFrom) или infallible (From). Нет «умного» синтеза. Это цена чёткости — зато семантика каждой иерархии однозначна.

  2. From для типа с TryFrom — явно. Если тип хочет оба: char.from(b byte) -> Self (infallible, byte всегда < 128 ASCII) И char.try_from(cp int) -> Result[Self, str] (fallible, range-check) — пишет оба явно. Нет magic.

  3. Overloading по параметру работает как обычно. Если у u64 есть try_from(str) и try_from(f64) — резолвится по типу аргумента D84.

  4. Self в Result корректен по D66 в method-контексте.

Связь

  • D73 — инфаллибельная пара From/Into, отдельная иерархия. D77 — независимая, не расширение.
  • D67?-оператор; применим к Result (try_from(s)?); From не возвращает Result, ? к нему не применяется.
  • D72 — bounds: [U TryFrom[T, E]] для generic-функций fallible-конверсии.
  • D26TryFrom, 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-варианта парсинга. При обсуждении выявилось три проблемы:

  1. Ad-hoc имя — каждая stdlib-либа могла использовать своё (try_parse, parse_opt, from_str_or_null).
  2. Дублирование с fromtry_parse это «from минус throw, плюс Option». Семантически избыточно.
  3. Прецедент RustTryFrom парный к 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 → auto from + все into-формы) — нарушал инвариант инфаллибельности From.
  • «Семантическое равенство from/try_from» — теперь это разные иерархии. fromtry_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”.

Семантика

  • Mem pre-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_alloc calls; free_count всегда 0 (release no-op). Достаточно для growth-rate тестов.
  • compiler-codegen/nova_rt/effects.hNova_Mem_* inline- обёртки.
  • compiler-codegen/src/codegen/emit_c.rseffect_schemas pre-populated с Mem schema; standard effect-call dispatch работает (Mem.live()Nova_Mem_live()).

Bootstrap-ограничения

  1. Plain-malloc backend (default): free_count всегда 0, live == alloc_count. Это значит leak-тесты могут только измерять growth rate, не “осталось ли что-то живое”. Когда подключим Boehm GC (alloc_boehm.c) или RC (alloc_rc.c) — free_count станет осмысленным, тесты можно расширить.
  2. Нет per-allocation type info. alloc_count — счётчик всех nova_alloc calls без разбивки по типам. Production-runtime возможно даст breakdown (records, arrays, fiber stacks).
  3. Не 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

FormCompile-time checkDebug runtimeRelease runtimeUse-case
assert(cond)нетcheckcheckproduction invariants
debug_assert(cond)нетcheckno-ophot-path / sanity
requires/ensures (D24)SMT где возможноcheck restno-opformal 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)

  1. AI-friendly: одна семантика. LLM генерирует assert(...) ожидая, что invariant держится. Если в release он silent — это тихий bug class (Java pre-1.4 classic).

  2. Безопасность. «Production runs without your invariants» — известная проблема C/Java/Python: программист в курсе своих asserts только в debug, в release они исчезают без следа.

  3. Прецедент Rust/Swift. assert! в Rust always runtime; debug_assert! для debug-only. Swift аналогично: assert debug-only, precondition always runtime — но Nova инвертирует defaults (более безопасный — короткое имя).

  4. Согласовано с D24. Если программист хочет zero-cost проверку с compile-time гарантией — пишет requires (D24 contract). Если просто debug-time hint — debug_assert. assert — strong invariant, всегда работает.

  5. D13 (panic vs effects). assert failure = panic = fiber dies. Это «hardware/math сбой» класс, не business error. По D13 такое не должно зависеть от build-mode.

Что отвергнуто

  • assert no-op в release (C/Java/Python style). Тихие bug’и в production — главная причина отказа.
  • assert как keyword без скобок (Rust macro / Java assert expression). Закрыто в spec sweep 2026-05-07: assert — обычная fn-call, со скобками. Один способ для одной задачи (D40).
  • Только один уровень (assert always runtime). Hot-path use-case реален; без debug_assert программисты пишут if (DEBUG) { ... } ручками. Лучше дать canonical-форму.
  • Только один уровень (assert debug-only). Невозможно выразить production invariant. Java pre-1.4 опыт показывает что это anti-pattern.

Связь

  • D7 — три режима компиляции; D81 уточняет, как build-mode влияет на assert-семантику.
  • D13 — assert failure = panic, не Fail-эффект.
  • D24requires/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 fnNova-runtime (nova_rt/)Nova_<Type>_<...> mangledнет (только в std.runtime.*)
extern("C") fn (TBD)сторонний C/libas-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-formC-name
T.method(...) staticNova_T_static_method(...)
t.method(...) instanceNova_T_method_method(t, ...)
t.method(...) mut instanceNova_T_method_method(t, ...) (тот же mangling)

Имена параметров в C-сигнатуре генерируются из позиций (arg0, arg1, …); типы маппятся по canonical Nova→C таблице (intnova_int, strnova_str, u8uint8_t, u32uint32_t, &TNova_T*, mut TNova_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-typeC-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:

  1. Компилятор парсит std/runtime/builtins.nv как обычный Nova- модуль. Каждая export external fn ...-декларация даёт AST-узел с полной сигнатурой (имя, receiver, params, return, effects).
  2. Codegen применяет mangling rules → C-name + C-prototype:
    void Nova_WriteBuffer_method_write_u32_be(Nova_WriteBuffer*, uint32_t);
    
  3. Codegen сверяет каждую декларацию со своим внутренним реестром реализованных runtime-функций (компилятор и runtime — один версионируемый артефакт, см. Diagnostics ниже).
  4. Если совпадает — codegen эмитит C-prototype в сгенерированный header для линковки с nova_rt/.
  5. Если не совпадает (нет реализации, расходится сигнатура) → 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-checkNova: no method 'unknown' on StringBuilder. Available: append, len, capacity, ...
external fn X.@y в builtins.nv ссылается на функцию, не реализованную в runtimeпри загрузке builtins.nv в codegenNova: 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.nvNova: signature mismatch for 'StringBuilder.@append': declared 'fn (s str) -> ()', runtime expects 'fn (s str) -> int'
Codegen эмитит вызов внешней функции, не объявленной в builtins.nvbug в компилятореinternal compile error: compiler bug: emitted call to undeclared external 'X.@y'. Не должно случаться у пользователя; если случилось — bug-report
User объявил auto-derived форму (@try_read_X рядом с @read_X)при загрузке builtins.nvNova: '@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

  1. Документация stdlib API. Программист (и AI) видя external fn StringBuilder.new() понимает: тело реализовано runtime’ом, не Nova. Не нужно искать в nova_rt/ где определён.
  2. Compile-time validation. Без external компилятор не знает, что функция без тела должна искаться в C-runtime — попытается эмитить empty body и упадёт. С external — явный contract.
  3. AI-friendly. LLM-генерируемый код для stdlib имеет canonical форму: export external fn .... Шаблонная подстановка тривиальна.
  4. Будущая совместимость с FFI. Когда появится extern("C") для сторонних libs, два keyword’а различаются однозначно.

Почему не intrinsic или builtin

  • intrinsic — занят понятием compile-time intrinsic (Rust-style intrinsics::transmute). Для Nova таких пока нет, но имя зарезервируем.
  • builtin — слишком общее. int/str тоже builtin (D26), но они типы, не функции.
  • external — точное слово: «реализация во внешнем (по отношению к Nova-source) контексте — runtime/C». Прецеденты: OCaml external, Dart external, Kotlin external.

Почему не 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 / D47export modifier; 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’а.
  • D54as/is для конверсий; не пересекается.
  • D73 — From/Into registry; D82 использует тот же dispatch-механизм для external-функций.
  • D126 — type-аналог D82 (external type для opaque-типов с runtime backing). Один keyword external, два валидных позиционирования.

Эволюция

До 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: KwExternal token — TBD (Plan 04 Этап 2).
  • ⏳ Parser: external modifier в parse_fn_decl — TBD.
  • ⏳ AST: is_external: bool flag — 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 APIstd/runtime/string_builder.nv
WriteBuffer APIstd/runtime/write_buffer.nv
ReadBuffer APIstd/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_int
  • nova_bool_hash(nova_bool) -> nova_int
  • nova_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-уровень: единый источник правды.
  • Ord protocol bound — структурный bound (lt/le/gt/ge методы) V2; для D109 достаточно auto-dispatch без формального Ord protocol.

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) -> boolmemberwise && chain
Hash@hash() -> u64XOR + rotate FxHash-style combine
Clone@clone() -> Self (D230 NEW)record literal с .clone() per field
Compare@compare(other) -> intlexicographic 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), либо имеет explicit fn 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):

CodeTrigger
E_AUTO_DERIVE_CYCLECyclic recursion через fields не терминируется
E_AUTO_DERIVE_FIELD_LACKS_PROTOCOLField type не implement требуемый protocol
E_AUTO_DERIVE_UNKNOWN_PROTOCOLProtocol не в built-in list (auto-derive только для built-in)
E_AUTO_DERIVE_UNSUPPORTED_KINDType 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
strnova_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 == = равны); бит-идентичный NaNmemcmp давал true (а IEEE == = не равны). Любой кортеж/sum с float-полем сравнивался неверно.
  • padding: mixed-size поля оставляют indeterminate padding-байты → два семантически равных значения могли дать memcmp != 0.
  • nested composite: вложенный record/sum сравнивался по pointer-битам (identity), а вложенный кортеж как struct → C-compile-error.

memcmp оставлен ТОЛЬКО для:

  1. []u8 byte-blob (@compare, D141 — byte-equality = намеренная семантика для байтовых буферов; a == b ⇔ a.compare(b) == 0);
  2. сравнение строковых литералов / имён (interrupt / bench-name match);
  3. 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 / D237Equal/@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 Copy analog) — опасно для семантических типов (Money, UserId); explicit opt-in forces awareness.

Cross-refs:

Связь

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.052026_05).
  • Digit-leading prefix → e (e.g. 2026_05e2026_05), потому что Nova-identifier должен начинаться с буквы / _.
  • Empty input → empty output (caller-side responsibility).

Examples:

  • edition = "2026.05"std/prelude/e2026_05.nv
  • edition = "nightly"std/prelude/nightly.nv
  • edition = "v1-beta"std/prelude/v1_beta.nv

Fallback chain (resolver-side):

  1. Edition pin: std/prelude/<sanitized>.nv — если файл существует, import path = ["std", "prelude", "<sanitized>"].
  2. 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", Go go 1.21, Swift package swift-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.

Связь

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_WARNING matching в nova test).
  • diag.message начинается с [W_PRELUDE_SHADOW] tag (для rendering через diag.renderrule поле не 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:

  1. Names declared directly в std/prelude/*.nv peer files (включая std/prelude.nv facade себя).
  2. 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_names vs merged_from_imports_names.
  • Per-name allowlist. allow_prelude_shadow = ["Option"] — слишком fine-grained, добавляет complexity без явного use-case. Module-level bool clause достаточен.

Связь


D141. Примитивы доступа к памяти — byte_at / bulk slice-операции

Plan 90. Принято 2026-05-22. Plan 90.1 amend. 2026-05-27 — extend-family API + copy_from hardening. 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-style copy_from_slice, теперь isolated имя). Migration: replace_all для всех call-sites. Lint W_VIEW_EXTEND_DETACH сохранён (детектит grow-методы append/insert/reserve). Plan 90 followup amend (2026-06-01). Два уточнения runtime: (1) fill(v) — memset fast-path для single-byte T (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 by n zero-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-assignment arr[i] = v). W_VIEW_EXTEND_DETACH lint срабатывает на 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.lenpanic «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-byte T (u8/i8) → memset fast-path; wider T → 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. Lint W_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 слотов; затем memmove src в образовавшуюся дыру (обрабатывает 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-byte T).
  • 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:
    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 u8
    
    До followup’а аналог был buf.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 срез, n zero-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 == ba.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 добавится позже, модель Go bytes.Equal).
  • Extend-family (Plan 90.1): паритет с Go append(dst, src...), Rust extend_from_slice / Vec::reserve, TS push(...arr) / splice, Kotlin addAll, Java ArrayList.addAll. Единственный grow-path до 90.1 — for x in src { dst.push(x) } (O(N) virtual calls); extend_from — bulk memmove, намного быстрее для primitive []T.
  • copy_from hardening (Plan 90.1): молчаливая truncation — silent bug factory. Ни один из 5 эталонных языков не имеет такой гибрид «panic на длинный + silent на короткий». Strict equal-only + memmove — лучший баланс корректности и overlap-safety.

Связь


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.nvIpAddr, SocketAddr
std/net/error.nvNetError — типизированные сетевые ошибки
std/net/tcp.nvTcpListener, TcpStream
std/net/udp.nvUdpSocket

Правила

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_EOFread_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)

  1. Caller fiber: nova_sched_register_pending(scope, slot)nova_sched_park(scope, slot)
  2. libuv callback (_tcp_connect_cb, _tcp_read_cb, _tcp_write_cb, …): устанавливает result поля → nova_sched_wake(scope, slot)
  3. 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 errorsNetError sum type vs stringly-typed (Go err.Error()) позволяет exhaustive match.

Связь


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:

  1. obj_ty = "nova_str"prim_nova_name = "str"
  2. Look up method_overloads[("str", method)]
  3. Фильтр !is_external — Nova-body методы получают is_external = false
  4. Генерируется вызов 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 methodC 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 imports std.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 методы).
  • D176str.as_bytes() -> readonly []u8 используется в parse_int_radix body.
  • 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:

  1. @bytes()@to_bytes() — allocating copy; @as_bytes() (D176, zero-copy readonly []u8) остаётся без изменений.
  2. @chars()@to_chars() — allocating codepoint slice.
  3. @split(sep str) -> []str-> readonly []str — возвращает zero-copy views в оригинальный буфер; тип сигнализирует об этом.
  4. @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.
  5. @compare(other str) -> int — новый C-примитив; возвращает отрицательное/ноль/положительное, как C strcmp. Реализован как nova_str_compare через __builtin_memcmp.
  6. readonly bytes parameter 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_* prefixto_bytes / to_chars семантически аналогичны Rust to_vec() / to_string(): allocating copy. Без to_-prefix неясно, zero-copy или нет. as_bytes() остаётся как zero-copy аналог Rust as_bytes().
  • readonly []str из split — zero-copy views в оригинальный буфер; тип это выражает явно. Изменять элементы результата нельзя.
  • Единый parse_int — вместо двух методов (parse_int() и parse_int_radix(r)) один с default-параметром. Упрощает API; radix=10 — наиболее частый случай.
  • compare как примитив — лексикографическое сравнение через memcmp; будущий PartialOrd auto-derive для str может опираться на него.

C codegen mapping

Nova methodC 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_bytesnova_str_to_bytes, nova_str_charsnova_str_to_chars.

Связь

  • D102 — keyword-only default params.
  • D176readonly type 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 StringBuildertype 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.nvexport import std.runtime.string_builder.{StringBuilder} (было external type StringBuilder).
  • compiler-codegen/src/codegen/runtime_registry.rsRUNTIME_DEFINED_TYPES includes "StringBuilder".
  • emit_c.rslhs_is_nova_ptr guard: sb + "str"@plus dispatch, не nova_str_concat.

Связь

  • D131 — consume types и -> @ fluent API.
  • D133 — consume static analysis.
  • D176readonly parameter modifier.
  • D178str.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 > 36Err(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_int API.
  • 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-throw parse_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_mut annotations refine в Plan 123.7.
  • 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 _ TypeD39) — auto-proxy methods. Both skipped from registry.

3.5 Static-method receivers + External fn skipped

Receiver.kind == ReceiverKind::Staticfn 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 renamed ro _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):

VarDefaultSemantics
NOVA_FIELD_CACHE(unset)0/off/false → pass disabled.
NOVA_FIELD_CACHE_THRESHOLD2Min reads to cache. 0 → disabled.
NOVA_FIELD_CACHE_MAX8Cap 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_F from 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 (объявление типов) — RecordField declaration source.
  • D120 (#pure views + 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 T modifier) — orthogonal к D217 (parameter- level), но D175 + D176 вместе формируют immutability semantics.
  • D215 (named tuple) — NamedTuple fields treated как ro.

10. Implementation milestones — Plan 123 umbrella

VersionSub-planD-blockStatus
V1123.1 (Core CSE)D217 (this)✅ V1 active
V2123.2 (LICM)D218 (planned)gate’нут на 123.1 ✅
V3123.3 (#pure cache)D219 (planned)gate’нут на 123.1 ✅ + D120
V4123.4 (chain)D217 amendgate’нут на 123.1 ✅
V5123.5 (LSP/diag)D217 §6 amendgate’нут на 123.1 + 104.x
V6123.6 (telemetry)(impl-only)gate’нут на 123.1 ✅
V7123.7 (IPA)D217 amendgate’нут на 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>_loop ident.
  • 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>_loop ident (don’t match @F pattern). 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:

  1. Read count: count_field_reads_in_block(body, F) ≥ licm_threshold (default 2).
  2. No mutation: !block_contains_write_to(body, F) — no Assign { target: Member{SelfAccess, F}, ... } anywhere in body. Includes compound assigns (+=, etc.) и nested control flow.
  3. No closure capture: !collect_closures_captures_in_block(body, F) — no closure body inside loop body references @F. (Closure body syntactic detection; conservative.)
  4. No spawn / supervised / detach / blocking / parallel-for: loop body must not contain concurrent constructs (aliasing safety with concurrent fibers).
  5. For mut fields: loop body must NOT contain any Call (V2 conservative — IPA refines в Plan 123.7).
  6. 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 / Select channel-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

VarDefaultSemantics
NOVA_FIELD_CACHE_LICM(unset)0/off/false → LICM disabled. D217 unaffected.
NOVA_FIELD_CACHE_LICM_THRESHOLD2Min reads inside loop body. 0 → LICM disabled.
NOVA_FIELD_CACHE_LICM_MAX4Cap 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-cacheD217 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):

  1. Body must NOT have any @F = ... write (conservative invalidation). Refined V3.1 с D24 f.reads frame info.
  2. Body must NOT contain concurrent constructs: Spawn, Supervised, Detach, Blocking, ParallelFor — skip whole pure- cache for safety.
  3. Method registry lookup: (receiver_type, method_name) must be в pure_methods registry. Registry includes only methods с purity == Purity::Pure (D24 — annotated #pure OR inferred через SCC) AND Instance receiver AND params.is_empty() (V3 scope; V3.1 — args-with-literals).
  4. Count occurrences: call count ≥ pure_threshold (default 2).
  5. 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 от:

  • D217: _at_<field> (per-fn field cache).
  • D218: _at_<field>_loop (LICM hoist).

Collision avoidance: numeric suffix _<N>.

6. Escape hatch + tunables

VarDefaultSemantics
NOVA_FIELD_CACHE_PURE(unset)0/off/false → V3 disabled. D217/D218 unaffected.
NOVA_FIELD_CACHE_PURE_THRESHOLD2Min 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 (#pure annotation + SCC inference). V3 leverages FnDecl.purity field.
  • 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 (#pure views + 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:

  1. D218 LICM phase.
  2. D217 V4 chain phase (NEW).
  3. D219 pure-call phase.
  4. D217 V1 per-fn cache phase.

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)

  1. 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).
  2. Occurrence count: identical canonical path ≥ chain_threshold.
  3. No top-level @F write anywhere в body (V4 conservative; future V4.1 may refine per-segment).
  4. No concurrent body: Spawn/Supervised/Detach/Blocking/ ParallelFor → skip.
  5. No closure capture: chain in closure body → excluded from caching.
  6. 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

VarDefaultSemantics
NOVA_FIELD_CACHE_CHAIN(unset)0/off/false → V4 disabled.
NOVA_FIELD_CACHE_CHAIN_THRESHOLD2Min chain occurrences.
NOVA_FIELD_CACHE_CHAIN_DEPTH4Max 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=8 cap 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 D24 f.reads frame info.
    • [M-123.4-chain-prefix-sharing] — cache shared prefixes (e.g. @a.b + @a.b.c share @a.b intermediate).
  • 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/codeLens handler emit “N caches” lens per fn header.
  • [M-123.5-lsp-hover] — textDocument/hover enhancement 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_info attribute 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):

  1. Parse module → run pipeline (skip if type-check fails).
  2. Call analyze_module (V5 API).
  3. For each FnCacheInfo в report, emit CodeLens с title:
    N cache(s): ro=X mut=Y licm=Z pure=W chain=V
    
    Range = first character of fn span. Command = nova-lsp.fieldCache.show (no-op stub; IDE extension can handle).

3. hover handler

textDocument/hover invokes compute_field_cache_hover(src, pos):

  1. Find @<name> token at hover position (look backward для @, forward для name chars).
  2. Run pipeline + analyze_module.
  3. Locate fn whose span covers position.
  4. If name ∈ info.ro_caches → “D217 ro cache: _at_<name>”.
  5. If name ∈ info.licm_hoists → “D218 LICM loop hoist: _at_<name>_loop”.
  6. Chain root → “D217 V4 chain cache (root)”.
  7. 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 #pure attribute 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:

  1. Baseline differential: run tests under both NOVA_FIELD_CACHE=0 и default ON; confirm identical results.
  2. Telemetry baseline: capture --telemetry-cache metrics before deploying.
  3. Gradual rollout (optional): enable layers one at a time — V1 only → +V2 → +V3 → +V4.
  4. Regression detection: if tests fail with default, diagnostic workflow disables layers individually to identify culprit, then --explain-cache shows 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:

FunctionEnv Var
Disable allNOVA_FIELD_CACHE=0
Disable LICMNOVA_FIELD_CACHE_LICM=0
Disable pureNOVA_FIELD_CACHE_PURE=0
Disable chainNOVA_FIELD_CACHE_CHAIN=0
ThresholdNOVA_FIELD_CACHE[_LICM/_PURE/_CHAIN]_THRESHOLD=N
Per-fn capNOVA_FIELD_CACHE_MAX=N
Per-loop capNOVA_FIELD_CACHE_LICM_MAX=N
Chain depthNOVA_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:

FlagEnv var (set on use)
--no-field-cacheNOVA_FIELD_CACHE=0
--no-field-cache-licmNOVA_FIELD_CACHE_LICM=0
--no-field-cache-pureNOVA_FIELD_CACHE_PURE=0
--no-field-cache-chainNOVA_FIELD_CACHE_CHAIN=0
--no-field-cache-ipaNOVA_FIELD_CACHE_IPA=0
--field-cache-threshold=NNOVA_FIELD_CACHE_THRESHOLD=N
--field-cache-licm-threshold=NNOVA_FIELD_CACHE_LICM_THRESHOLD=N
--field-cache-pure-threshold=NNOVA_FIELD_CACHE_PURE_THRESHOLD=N
--field-cache-chain-threshold=NNOVA_FIELD_CACHE_CHAIN_THRESHOLD=N
--field-cache-max=NNOVA_FIELD_CACHE_MAX=N
--field-cache-licm-max=NNOVA_FIELD_CACHE_LICM_MAX=N
--field-cache-chain-depth=NNOVA_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:

  1. Computes current telemetry (V6 flow).
  2. Parses baseline JSON (minimal hand-rolled extractor, no serde_json dep).
  3. Compares metrics:
    • methods_affected_pct: absolute drop > 5 percentage points → regression.
    • caches_total: relative drop > 10% → regression.
  4. 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_estimate returns non-zero for module с cached methods ✅
  • V6.2.2 Empty report → zero savings ✅
  • V6.2.3 nova check --telemetry-cache --telemetry-json emits 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:

  1. Builds each input .nv file twice через subprocess nova build с дополнительным env override:
    • ON variant: NOVA_FIELD_CACHE=1 (default cfg + cache pass)
    • OFF variant: NOVA_FIELD_CACHE=0 (pipeline skips cache_module)
  2. Runs each variant N samples (default 11) с warmup (default 2); measures process wallclock via std::time::Instant.
  3. 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 main automatically 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_pct from 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 --out writes 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-pp exit 1 when drop exceeds threshold; exit 0 otherwise ✅
  • V6.2.1.5 Static static_cycles field matches in-process cpu_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:

KnobEnv varCLI flagDefaultRange
IPA closure iteration capNOVA_FIELD_CACHE_IPA_ITER--field-cache-ipa-iter=N101..=1024
affected-% drop gate(n/a)--telemetry-gate-affected-drop=F5.0≥0.0
caches-total drop gate(n/a)--telemetry-gate-caches-drop=F10.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_limit field + env/CLI binding (1..=1024 clamp) ✅
  • V6.3.2 --telemetry-gate-affected-drop / -caches-drop CLI 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-cache future 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 where fname ∉ write_set[(T,M)] → NOT a barrier. Unknown → conservative.
  • rewrite_fn_body_split_with_ipa — barrier consistency.

Ф.2 V2 LICM:

  • collect_loop_eligible_fields: snapshots LICM_WRITE_SETS thread-local. Mut field eligibility uses IPA-aware block_contains_invalidating_call_for instead of V2 body_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 @F reads
    • transitive via method calls, iterative closure ≤10 iterations).
  • pure_cache_fn: snapshots PURE_IPA_CTX thread-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_set non-empty.
    • Methods with read_set ∩ writes = ∅ cache survives.
  • collect_body_writes helper: walks body, collects fields directly written via top-level Assign{Member{SelfAccess, F}}.

Ф.4 V4.1 chain per-segment invalidation:

  • chain_cache_fn: snapshots CHAIN_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.

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

ScenarioV7V7.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):

  1. Build node-index map for всех (type, method) keys.
  2. Build adjacency list adj[i] = indices of i's callees.
  3. Compute SCCs via tarjan_scc(&adj) — iterative Tarjan (work-stack vs recursion) returns SCCs в reverse-topological order.
  4. 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=1 falls 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:

  1. Canonicalize via BTreeMap / BTreeSet — sorts iteration order (HashMap иначе non-deterministic).
  2. Hash via DefaultHasher (SipHash-1-3 quality).
  3. Domain-separator string "scc_fingerprint_v1" prefixed so future format changes can be detected.
  4. Reserve 0 как sentinel “no entry”; bias к 1 to 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):

  1. If cache disabled → tail-call к propagate_via_scc (no-overhead).
  2. Compute fingerprint.
  3. Lock cache, check has_entry && last_fingerprint == fingerprint:
    • Hit: copy last_result к direct, bump hits, return.
  4. Drop lock, compute propagate_via_scc outside lock (avoid serialization когда concurrent threads miss).
  5. Lock cache again, store (fingerprint, result_clone), bump misses.

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-cache JSON 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_METHODS hard-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:292
  • compiler-codegen/src/test_runner.rs:2350
  • compiler-codegen/src/doc/test_runner.rs:190
  • nova-cli/src/bench/run.rs:106 + :367
  • nova-cli/src/bench/field_cache_wallclock.rs:324
  • nova-cli/src/main.rs:1369, :1477, :2152, :3891
  • nova-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, @F Member-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_method correctly 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.nv 1/1 PASS, two test assertions 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>_buf per push instead of three nova_self->buf references.

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(_, _)[]T slice handle (heap, mutation through push/extend doesn’t change handle bits).
  • TypeRef::Pointer(_, _)*T raw pointer (Plan 118).
  • TypeRef::Readonly(inner, _) / TypeRef::Mut(inner, _) — recurse into wrapped type.
  • TypeRef::Named whose 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 Named types (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 &reg.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 @arr cache 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_ref recognizes Array ✅
  • V7.6.5 is_reference_type_ref recognizes Pointer ✅
  • V7.6.6 is_reference_type_ref recognizes named collections ✅
  • V7.6.7 is_reference_type_ref rejects value types ✅
  • V7.6.8 is_reference_type_ref peels 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 like Container[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_ITERS weight для 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_chain unit: extracts segments correctly ✅
  • V7.7.5 call_recv_self_chain unit: rejects non-self-rooted ✅
  • V7.7.6 call_recv_self_chain unit: 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_invalidates renamed к 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:

  1. Suffix-based fixed kind: _chain → chain_caches, _loop → licm_hoists, _call → pure_caches.
  2. _at_<F> (plain or with _r<N>/_n<N> region suffix) с value Member{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).

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>_chain classification preserved ✅
  • V5.4.6 No-cache fn handled gracefully ✅
  • V5.4.7 explain_name_has_region_suffix helper 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.2 nested_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 @F operates on @F’s value — by Nova’s type system it cannot reach OTHER fields of self (no self access 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 whether var aliases 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 @n cache ✅
  • V7.5.2 @arr.push(...) STILL invalidates own @arr cache (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()) — extend call_recv_self_field to 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.receiver is Some(ReceiverKind::Instance).
  • f.purity != Purity::Pure (not already annotated).
  • f.effects.is_empty() (no effects в signature).
  • !f.is_external (external fns don’t get #pure either 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-#pure fns ✅
  • 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:

  • i lies within an FnCacheInfo::span from analyze_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 (property type; readonly + cached modifiers) ✅
  • 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 accepts full/delta after the first full request.
  • 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’s previous_result_id matches 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_full response.
  • Every semantic_tokens_full_delta response (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:

  1. Find longest common token-prefix length P (token equality на all 5 fields: deltaLine/deltaStart/length/tokenType/modifiers).
  2. Find longest common token-suffix length S (bounded by min(old.len() − P, new.len() − P) to prevent overlap).
  3. 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 (each SemanticToken = 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_idTokensDelta variant ✅
  • V5.5.12 Mismatched previous_result_idTokens fallback ✅
  • V5.5.13 No cached snapshot → Tokens fallback ✅
  • 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::tests lib 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=0 escape 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

  1. After per-chain to_cache finalized, group entries by their path[..2] slice (length-2 prefix).
  2. For groups с ≥ 2 chains, emit _at_<a>_<b>_pre = @<a>.<b> once.
  3. Emit per-chain lets — те, что покрыты prefix, формируют access как <prefix-local>.<tail-segments> через ExprKind::Ident + ExprKind::Member. Остальные unchanged (@chain).
  4. cfg.max_per_fn budget covers both: prefix lets count toward it, so deeper sharing in max_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.tail chain ✅
  • 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.nv PASS

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> if region_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:

  1. Per-region read rewrite: для каждого MutRegionTarget, walk stmts[start..end) plus optional trailing, rewrite reads of @<F><local_name>.
  2. Ro full-body rewrite: standard V1 path (unchanged).
  3. Let insertion: group targets by region.start. Process non-prefix groups (start > 0) в descending order of start — insert region’s lets BEFORE stmts[start]. Descending order keeps unprocessed indices valid.
  4. 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_F naming (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_fn caps 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_char chain 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_fn caps 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-walk analyze_fn_for_explain to count them in mut_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.x instead 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

Exprrepr
(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.BarRstd.foo.Bar{...}).
  • Recursion: each component runs through canonical_literal_repr recursively; 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 to Ident(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 as T2{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_ident before 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_ok 3/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:

charreplacement
{_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)_5i
  • BoolLit(true)_T
  • StrLit(...)_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_call returns Option<PureCallKey> (V3 returned Option<&str>).
  • canonical_literal_repr(expr) returns compact String для literal expressions.
  • count_pure_calls_in_body uses HashMap<PureCallKey, usize>.
  • capture_sample_args_in_body saves first sample args per key для prefix-let reconstruction.
  • rewrite_pure_calls_in_*_v31 matches 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 без записи в реестре резолвится без импорта; единственный консумер реестра для str types/mod.rs:12233 читает только is_consume/is_mut, которых у str нет). В реестре остались только C-dispatch записи eq/lt/le/gt/ge (бэкают operator-lowering emit_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)

  1. Privacy у Nova — type-based, не module-based. priv_access_allowed_base (mod.rs) проверяет только current_recv_type == tname, БЕЗ проверки модуля. current_recv_type ставится из receiver для ЛЮБОЙ функции с receiver. Значит fn str @methodstd/runtime/string.nv, модуль runtime.string) имеет receiver strcurrent_recv_type == "str"видит priv-поля @ptr/ @len и конструирует str { ptr: …, len: … } в своём модуле. Прошлый вывод «string.nv не может конструировать str» — ошибка.
  2. 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_partsVec[T].from_raw_parts(ptr *T, len int, cap int) -> Selfстроит Vec из сырого (ptr,len,cap) триплета; единственный sanctioned вход (priv-поля закрыты для чужих модулей)
as_ptrVec[T] @as_ptr() -> *T / mut @as_ptr() -> *mut Tпубличный data-ptr геттер (recv-mut overload отдаёт writable *mut T)
into_rawVec[T] consume @into_raw() -> *mut Tинверс from_raw_parts: потребляет Vec-обёртку (D133), отдаёт writable raw-буфер для zero-copy reuse

Контракт from_raw_parts/into_rawunsafe-обязательство на 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_TYPES skip-list (нет NovaValue_str schema) → generic record-lit падал в emit-null-stub (void*nova_str CC-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Ф.0nova_str_as_bytes
@lenФ.1nova_str_byte_len (теперь bare priv-field read => @len, D117 carve-out)
@byte_atФ.1nova_str_byte_at (bounds-check + unsafe { @ptr[i] })
@splitФ.2nova_str_split (zero-copy sub-views str{ptr:@ptr+off,len})
from_bytes_uncheckedФ.2C-перехват (alloc+copy+NUL D26 §3 через priv str.alloc_copy)
from_bytes_lossyФ.2C-перехват (UTF-8-валидатор + U+FFFD)
from_bytes_unchecked_stealФ.2C-перехват (zero-copy reuse при cap>len, иначе copy)
@concatФ.3nova_str_concat ([]u8.with_capacity+push+from_bytes_unchecked)
@compareФ.3nova_str_compare (byte-loop, length-aware tiebreak)
@hashФ.4 — остаётся Cnova_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_2 12/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_raw bridge-методы (в 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

KnobEnvnova.toml [runtime]Builtin defaultRangeRound
fiber stackNOVA_FIBER_STACKfiber_stack4MB[256KB, 256MB]UP to page-align
max fibers/workerNOVA_MAX_FIBERSmax_fibers16384[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_sizeslot_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.

Правила (что нельзя и чем заменять):

  1. GNU statement-expression ({ … }) + __typeof__ — cl.exe → C2059. Запрещены в генерируемом C. Значение-возвращающие многошаговые конструкции выносятся в static inline-хелперы в nova_rt/array.h. Для индексации/слайсов/heap-box/ bitcast — generic-хелперы через void* (тип-лаундеринг через void* снимает strict-aliasing: компилятор теряет исходный тип → доступ через NovaArrHdr* безопасен) + общий header NovaArrHdr { 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).
  2. 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-_n load/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 (у Rust RandomState — 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’ы, на сборку не влияет); robust var_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. Правила

  1. Симметрия ветвей if. Тип + расходимость считаются одинаково для then- и else-ветки (для ElseBranch::Block и ElseBranch::If). Fluent-хвост может быть в любой из двух — обе стороны проверяются.
  2. Unit-доминирование (gated). Если выбранный (divergence-aware) тип chosen != nova_unit, но другая не-расходящаяся ветка даёт nova_unit — всё выражение → nova_unit. Гейт chosen != nova_unit оставляет обычный value-typed if c {1} else {2} нетронутым.
  3. Расходимость приоритетнее unit (Plan 125 сохранён). Расходящийся сосед (return/throw/panic) НЕ форсит unit — это поведение divergence-aware выбора ветки из Plan 125 и оно сохраняется (unit-доминирование смотрит только на не-расходящиеся ветки).
  4. infer↔emit симметрия для match. infer_expr_c_type(Match) применяет то же правило «есть не-расходящаяся unit-арм → весь match nova_unit», что и emit_match. Раньше inference возвращал тип не-unit арм’ы (Vec*), пока emit эмитил nova_unit → enclosing if/assign объявлял temp Vec* и присваивал в него 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_armnova_unit, зеркало emit_match [M-91.13] (~27249-27259).

5. Эффект на std

Снят workaround в std/unicode/case.nv: per-codepoint мэппинги больше не обязаны прятать push в for-циклах через Vec[int]-возвращающие helper’ы. Восстановлен прямой fluent-стиль: singleVec[int].with_capacity(1).push(cp); fold_case/to_uppercase/to_lowercasematch/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.nv 1/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» (аналог Go gcdata). Это 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; charnova_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.rscompute_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, commits 219be59a+1245ef68, маркер [M-checker-recursive-type-overflow]). Фиксирует layout-семантику, которую emit_c уже эмитит, но которую type-size-калькулятор раньше игнорировал → stack-overflow на рекурсивных типах. Уточняет/амендит layout-инвариант, на который опирается D277 §3 (канонический size-walk const_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, остаётся валидным.

Правила

  1. Обе формы — bodyless declaration (как external fn). Тело запрещено.
  2. Типы параметров И возврата 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, i8i64, u8u64, 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 iff X — 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_TYPEPlan 174.6 M1 (rule 3 — fn-ptr ABI-тег, D353 — тоже M1). M0 (это изменение) = только спека тип-листа.
  3. extern "C" fn НЕ регистрируется в ExternalRegistry (не добавляет nova_fn_ prefix даже при module-mangling). Регистрируется в CEmitter.c_literal_extern_fns.
  4. export extern "C" fn — допустимо (экспортирует C-fn в Nova-модуль). Без export — приватна для модуля (подходит для ffi.nv-слоя std/net).
  5. unsafe modifier валиден для обеих форм: extern "C" unsafe fn memmove(...).

Реализация (Ф.-1)

КомпонентИзменение
lexer/token.rsДобавлен KwExtern
lexer/mod.rs"extern" => KwExtern
ast/mod.rsFnDecl.extern_abi: Option<String>
parser/mod.rsextern STRING fn(is_external=true, extern_abi=Some(abi))
codegen/external_registry.rsPass 1+2: skip extern_abi == Some("C")
codegen/emit_c.rsc_literal_extern_fns: HashSet<String>, проверяется первым в free_fn_c_name
editors/*/extern добавлен в грамматики и syntax highlight
tests/syntax_highlight_conformance.rsextern добавлен в ACTIVE
std/**/*.nv (136 файлов)external fnextern "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 с эффектом FailE_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.@mE_CLOSURE_HAS_ENVE_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-часть).