--- title: 🔁 Round-trip key verification — проверка нового ключа конвертера namespace: personal type: how-to created: '2026-09-17' updated: '2026-09-18' tags: - personal - how-to - testing - converters - reference related: - '[[personal/projects/zont-config-compiler]]' --- # 🔁 Round-trip key verification > Зеркало навыка `roundtrip-key-verification` (категория `software-development`, Hermes). > Появился из реального бага в ZONT-конвертере 2026-09-17 (коммит `87e315c`). > Разбор в проекте — [[personal/projects/zont-config-compiler]] §10.1. ### 6. 🔴 ПИТФОЛЛ 93 — переименовал ключ словаря → грепни ВСЕ обращения к нему Маски `objcmd` перешли с полных (`'1 %0'`) на базы до плейсхолдера (`'1 '`), но проверка `if _OC_OPS.get(_oc_mask) is None:` осталась смотреть на **старое** имя переменной. Ключа нет → `None` → **все 6 записей молча ушли в fallback `descr`+`args`**. Круг **зелёный**: fallback доносит байты верно, а новый ключ просто не появляется (ровно §6-сценарий выше). ```bash # переименовал переменную/ключ — найди КАЖДОЕ обращение, а не только определение grep -n "_oc_mask\|_oc_base" config-to-yml.py # словарь, обе проверки, обе сборки ``` **Симптом, который надо узнать:** артефакт показывает **старую** форму при зелёном круге. Это не «правка не сохранилась» — это **fallback**, который её перекрывает. ### 7. 🔴 ПИТФОЛЛ 94 — «объект есть в YAML» ≠ «энкодер его выпустит» Тела, **развёрнутые внутри** другого объекта (операнды `left`/`right`, `source`), не лежат отдельной секцией. Индекс тел, построенный по спискам верхнего уровня с ключом `raw`, их **не видит** → `_raw_of()` отдаёт `None` → объекты **теряются из выхода**. Разбор был последовательным и вскрыл **четыре** подловушки: | Симптом | Причина | Решение | |---|---|---| | `-5` объектов | индекс не обходил `scenarios` | рекурсивный обход вложенного | | всё ещё `-5` | у развёрнутых тел **нет `raw`** | `raw` клался в тело (костыль, потом снесён) | | `-2` | вложенные операнды `left`/`right` не регистрировались | рекурсия по `left`/`right` | | `-2` снова | фильтр `x in _known_ids` отсекал **нужные** id | фильтр убран, дедуп на `_register` | **Правила:** - Индекс тел собирать по признаку **`id` + `type`**, а не по наличию `raw`. - Строку собирать **хелпером по полям** — `raw` в промежуточном файле не нужен вовсе. - **`_known_ids` — НЕ «что уже выпущено»**, а «отличить литерал от ссылки». Для второго есть отдельное множество (`_emitted_ids`). Перепутать = самострел: пропустишь ровно то, что надо выпустить. ### 8. 🔴 ПИТФОЛЛ 95 — костыли в промежуточном формате видно глазом владельца Чтобы индекс нашёл развёрнутые тела, в них временно клался `raw`. Круг — зелёный, но артефакт зарос **88 блоками сырья**, и владелец это увидел сразу. ``` круг зелёный + артефакт зарос костылём = правка не принята ``` **Правило:** `raw`/`_`-хелперы в **выходном** файле — только на время отладки. Если костыль нужен индексу — значит индекс построен по неверному признаку, чинить индекс, а не засорять артефакт. ## Когда применять Любой двусторонний конвертер с тестом байтовой точности: `.txt ⇄ .yml`, сериализатор с decode + encode, dump/undump AST, DB ⇄ ORM. **Триггер:** добавил **новый ключ** в декодированный вывод и round-trip зелёный. ## 🔴 Ловушка Байтовый round-trip доказывает, что **вывод парсера == вход**. Про энкодер он не говорит ничего — энкодер получает ровно то, что парсер положил. Если энкодер **игнорирует** новый ключ, тест всё равно зелёный: ``` original.txt → parse → YAML(с новым ключом) → encode(ключ игнорируется) → original.txt ✅ diff = 0 ▲ ключ добавлен ▲ ключ потерян, старое поле доносит байты ``` Потеря невидима: старое поле (`args`) рядом и по-прежнему собирает байты. **Реальный случай:** ключ `set_var` добавлен в парсер типа 59, энкодер поправлен **только** в `emit_action`. Вторая точка входа — инлайн-ветка `emit_step` (`'descr' in step and 'args' in step`) — ключ игнорировала. Round-trip 🟢, правка пользователя молча терялась. ## Обязательный шаг: тест подменой (mutation) `read back` («записалось») — **не** проверка. Подменить новое значение и пересобрать; эффект обязан появиться в выходе. ```bash cd /tmp && rm -rf t && mkdir t && cd t cp <реальный-артефакт>.yml t.yml python3 - <<'EOF' s = open('t.yml', encoding='utf-8').read() old = "...блок со старым значением нового ключа..." new = "...тот же блок, значение заменено на маркер (напр. 7777)..." assert s.count(old) == 1, s.count(old) # assert уникальности — без случайных мультиправок open('t.yml', 'w', encoding='utf-8').write(s.replace(old, new)) EOF python3 <проект>/encode.py t.yml > out.txt diff <(iconv -f <вх> -t utf-8 <оригинал> | tr -d '\r' | sort) \ <(iconv -f <вых> -t utf-8 out.txt | tr -d '\r' | sort) # ✅ изменилась РОВНО одна строка, в ней маркер # ❌ ноль изменений = энкодер ключ проигнорировал → искать ДРУГУЮ ветку кода ``` Проверять **обе** половины: 1. **Парсер** — ключ есть в промежуточном файле, на ожидаемом месте. 2. **Энкодер** — подмена доезжает до выхода, `diff` = ровно 1 строка. ## Найти ВСЕ точки входа У конвертеров их обычно две и больше: рекурсивный обход и инлайн/ссылочная ветка, либо проход по реестру типов и явный цикл. - `grep -n "def emit_\|def dump_" *.py` — выписать **всех** вызывающих. - Не править одну и полагать, что покрыта другая. Дублированные ветки — норма. - Симптом пропущенной ветки: в тесте подменой остаётся старое значение. В **каждой** ветке ставить проверку типа, чтобы плохое значение падало громко (`exit 2`), а не тихо уходило сырым полем. ## Смежные питфоллы - **Проверять тип производного ключа.** Парсер, сохраняющий whole-float как float (`14.5`, `1.0`), даст не-int для ключа-идентификатора. Guard: `isinstance(v, int) and not isinstance(v, bool) and not isinstance(v, float) and v != 0`. `0` = «цели нет», а не валидный id — пропускать, не выдумывать. - **Один объект в двух секциях** → ключ править во **всех** местах отображения, иначе одна проекция показывает новую семантику, другая старую. Round-trip и тут зелёный. - **Считать вхождения по данным**, не по плану: «12 элементов» из плана оказались 10 в конфиге. - **Не резолвить имя из другого объекта.** Производный ключ несёт сырой id; `target_name` вместо `target` — запрещённая подстановка. ## Что записать после проверки 1. Новый ключ, где эмитится, какие точки входа поправлены. 2. Команду подмены и наблюдённый результат («1 строка изменилась, маркер на месте»). 3. Питфолл «две точки входа» — чтобы следующая сессия сразу проверяла обе. --- ## 🔴 Одно имя на два разных действия = схлопывание (2026-09-18, ZONT круг 46) **Симптом:** в YAML два **разных** действия читались под одним ключом, и одно затирало другое. **Случай:** `set_contour_temp` — имя действия у двух **разных** типов записи: - **тип 9** (команда, поле 4 = уставка **числом**): `#Z8817=9,'descr',10034,'2782'` → 5.2 °C - **тип 59 objcmd** (поле 3 = **источник**/литерал): `#Z10379=59,'objcmd 8669 ",,,%0";#h',9146,0,0` Ветка objcmd проверялась в коде **раньше**, перехватывала тело типа 9 и собирала `#Z8817=59,'objcmd 10034 ",,,%0";#h',5.2,0,1` вместо `#Z8817=9,…,10034,'2782'`. Плюс `_register_action9` перезаписывал запись секции → `Duplicate ID 8817`. **Правила:** - Два разных действия — **два разных имени**. Одно имя на два типа записи = гарантированный конфликт веток. Владелец формулирует разницу сам: *«установить целевую температуру x для контура y»* vs *«установить температуру контура x в значение y»*. - При каждом переименовании или вводе ключа **сверить пересечение словарей**: `grep -n "_ACTION9_KEYS\|_OBJCMD_MASKS" *.py` → общие имена = баг. - Один id в двух секциях (шаг сценария + объект секции) → **одна** строка на id: вторая точка уступает первой, а не регистрирует своё тело повторно. ## 🔴 Артефакт в репо ≠ свежий вывод (2026-09-18, ZONT круг 46) `zont_config/*.yml` в репозитории — это **выход прошлого прогона**. Пока файл не перегенерён, он показывает **старую** форму, и правка выглядит невнесённой. `test_roundtrip.py` пишет в `temp`, **целевой артефакт не обновляет**. ```bash # приёмка: сначала перегенерировать ЦЕЛЕВОЙ файл, потом смотреть глазами python3 config-to-yml.py zont_config/config_X.txt > zont_config/config_X.yml grep -n -A4 "<изменённый id>" zont_config/config_X.yml ``` **Симптом:** «всё ещё вижу старое» при зелёном круге. Порядок — круг, **потом** перегенерация, **потом** чтение артефакта. --- ## 🔴 Круг зелёный ≠ приёмка (2026-09-17, ZONT круг 39) Жёсткий урок той же сессии. Round-trip **байтово чистый** не означает, что правка верна. **Случай:** правка декодера + энкодера прошла круг 9/9, но владелец проекта посмотрел на артефакт и увидел **выдуманный ключ** и **обёртку-список на один элемент**. С точки зрения байтов всё правильно; с точки зрения человека — сломано. ``` круг зелёный + артефакт читается плохо = правка не принята ``` **Правило:** перед правкой формы назвать **целевую форму словами владельца** и только потом трогать код. Если форма не названа — **не угадывать**: спросить одним вопросом. **Правило трёх:** третья подряд правка «вслепую» (форма не названа, гипотеза за гипотезой) — стоп. Не править код. Назвать **допущение, которое может быть неверным**, и задать ОДИН вопрос. **Симптом «сломано, хотя круг зелёный»:** объект, у которого есть id **в строке конфига**, при развороте тела в YAML **теряет этот id** → энкодеру не из чего собрать строку, и объекты уезжают в `raw`-секции (в ZONT: орфаны с `46` выросли с 4 до 69). Проверять **не только** число объектов и строк, но и **состав служебных секций** до/после. **Проверка, которую стоит добавить в любой конвертер:** ```bash # число объектов в raw-фоллбэк-секциях ДО и ПОСЛЕ правки должно совпадать grep -c '^\- id:' out.yml.before # <секция-фоллбэк> grep -c '^\- id:' out.yml.after ``` Рост этой секции = форма не разобрана, а не «данных нет». --- ## 🔴 Авто-генерация id: обернуть сборку строки НЕДОСТАТОЧНО (2026-09-18, ZONT круг 50) **Контекст:** учим энкодер **выдумывать** id записям, у которых его нет в промежуточном файле. Пять ловушек, все найдены на живом коде. ### Ловушка 1 — `keep()` и `alloc()` пишут в РАЗНЫЕ хранилища занятости У аллокатора было два хранилища: `_used` (зарезервированные) и `renames` (старый → новый). `keep(id)` писал только в `_used`, а резолвер смотрел в `renames` → **свой** id выглядел незанятым → выдавался новый номер → строка уезжала под новым id, а **ссылки** в полях оставались на старом. ``` round-trip: 759 → 693, ровно −66 строк (все одного типа) симптом: тело выпущено с НОВЫМ id, ссылки несут СТАРЫЙ → объект молча осиротел ``` **Правило:** одно хранилище, одна функция. `keep(id)` регистрирует **тождественное переименование** `id -> id`. Любой помощник, помечающий id занятым, обязан писать и в карту переименований. Два хранилища = гарантированное расхождение. ### Ловушка 2 — барьер `if 'id' in obj` отсекает запись ДО аллокатора ```python for obj in obj_list: if isinstance(obj, dict) and 'id' in obj: # ← запись без id сюда не входит zid = resolve_id(obj['id']) ``` Запись исчезает **при `exit=0`** и не появляется **ни под старым, ни под новым** id. Такой же барьер обычно стоит в нескольких циклах — обход типов, обход орфанов/секций, индекс тел: ```bash grep -n "'id' in \|\"id\" in \|'id' not in \|get('id')" encoder.py ``` **Правило:** при внедрении авто-генерации обернуть `build_line`/`add_z_line` — только половина работы. Нужно снять **условия допуска** во **всех** циклах вывода, иначе запись не дойдёт до аллокатора. **Диагностический признак ловушки 2:** `exit=0`, в stderr пусто, строка отсутствует и под старым, и под новым id. Если новый id всё-таки выдан — это ловушка 1, а не 2. ### Ловушка 3 — «id есть в файле» ≠ «запись можно оставить без id» Замерено: снять id у одной записи каждой секции и пересобрать. | Класс записи | Можно без id? | |---|---| | **Лист** — на неё никто не ссылается (ни по id, ни по типу) | ✅ да | | На неё ссылаются **по типу** (`body_type(id)` для событий/параметров) | ❌ нет — тип ищется **по id** | | В секции, чей цикл требует `id` у каждой записи | ❌ нет без ослабления цикла | Безопасный первый заход — **только листья**. Записи, чей тип кто-то запрашивает, требуют, чтобы тип выводился **до** присвоения id — это отдельная правка. ### Ловушка 4 — ссылка в никуда НЕ проверяется (опасная половина) ```yaml - id: 8470 set_relay: target: 99999 # ← объекта с таким id нет ``` Сборка **прошла, `exit=0`**, и `99999` уехал в выходной конфиг. Контроллер получил бы команду в никуда. **Это опаснее потерянного объекта.** `validate_config` проверяет кодируемость и дубли id — **ссылки он не резолвит**. Вместе с авто-генерацией нужна валидация ссылок: каждое `target` / `output_id` / `object` / `left` / `right` / список-id обязано быть в множестве существующих id, иначе падение. Приёмка: подменить одну ссылку на свободный id и убедиться, что сборка **упала**. ### Ловушка 5 — проверять допущение МИНИ-КЕЙСОМ, а не чтением кода Всё вышеперечисленное найдено **трассировкой одной записи на минимальном входе** (один сценарий, `build_line` обёрнут логированием id), а не чтением эмиттера. Трассировка напечатала `id=4098` там, где в источнике `8550` — это и назвало баг одной строкой. ```python orig = mod.build_line seen = [] mod.build_line = lambda t, i, f: (seen.append(i), orig(t, i, f))[1] mod.convert(minimal_subset) # один сценарий / одна запись print(seen) # сверить с id, которые есть на входе ``` **Проверка подозрения на регресс — против чистого HEAD, до обвинения своей правки:** ```bash git stash push -u -m wip # работа спасается только stash'ем python3 test_roundtrip.py # базовая линия на чистом HEAD git stash pop ``` База тоже сломана → унаследовано; база зелёная → это твоя правка. И **коммитить сразу, как только круг зелёный** — `git checkout --` затирает незакоммиченное без возврата.