Files
obsidian-vault/personal/tech/roundtrip-key-verification.md
T

25 KiB
Raw Blame History

title, namespace, type, created, updated, tags, related
title namespace type created updated tags related
🔁 Round-trip key verification — проверка нового ключа конвертера personal how-to 2026-09-17 2026-09-18
personal
how-to
testing
converters
reference
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-сценарий выше).

# переименовал переменную/ключ — найди КАЖДОЕ обращение, а не только определение
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 («записалось») — не проверка. Подменить новое значение и пересобрать; эффект обязан появиться в выходе.

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_<x>\|def dump_<x>" *.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, целевой артефакт не обновляет.

# приёмка: сначала перегенерировать ЦЕЛЕВОЙ файл, потом смотреть глазами
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). Проверять не только число объектов и строк, но и состав служебных секций до/после.

Проверка, которую стоит добавить в любой конвертер:

# число объектов в 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 отсекает запись ДО аллокатора

for obj in obj_list:
    if isinstance(obj, dict) and 'id' in obj:    # ← запись без id сюда не входит
        zid = resolve_id(obj['id'])

Запись исчезает при exit=0 и не появляется ни под старым, ни под новым id. Такой же барьер обычно стоит в нескольких циклах — обход типов, обход орфанов/секций, индекс тел:

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 — ссылка в никуда НЕ проверяется (опасная половина)

- 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 — это и назвало баг одной строкой.

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, до обвинения своей правки:

git stash push -u -m wip    # работа спасается только stash'ем
python3 test_roundtrip.py   # базовая линия на чистом HEAD
git stash pop

База тоже сломана → унаследовано; база зелёная → это твоя правка. И коммитить сразу, как только круг зелёныйgit checkout -- затирает незакоммиченное без возврата.

Ловушка 6 — 🔴 «круг чистый» ≠ «объект выпущен» (2026-09-18, круг 51)

Самая коварная из ловушек: приёмка показала exit=0 и roundtrip: ЧИСТЫЙ для всех трёх проверяемых типов — при том, что объект молча исчезал из вывода. Круг проверяет «вывод парсера == вход», а входом был вывод энкодера, из которого объект уже пропал. Зелёный круг на потере объекта.

Как ловить — считать объект по ИМЕНИ в артефакте, а не полагаться на круг:

hits = [l for l in out.split("\r\n") if "ТЕСТ-ДАТЧИК" in l]
if not hits:
    print("ОБЪЕКТ ПОТЕРЯН")     # круг при этом может быть «ЧИСТЫЙ»

Ловушка 6.1 — первопричина: alloc(None) не кэширует → два id на один объект

Энкодер проходит объекты дважды: секционный цикл кладёт строки в lines, затем цикл TYPE_ORDER собирает result_lines, ища zid in z_dict. Если выдача номера не кэшируется, второй проход выдаёт другой номер, zid не находится в z_dict и строка теряется молча.

Доказательство — временные принты в копию энкодера:

DBG-T1  [..., "#Z4102=1,'0','ТЕСТ-ДАТЧИК',..."]      ← проход 1 выдал 4102
DBG-TZ  obj_id=4101 -> zid=4101 in_z_dict=False      ← проход 2 выдал 4101, строки нет

Лечение — номер должен быть «липким»: сразу после выдачи зарегистрировать его тождественным переименованием (keep()), чтобы любой последующий _alloc_id(<int>) вернул тот же номер.

if 'id' not in obj:
    obj['id'] = _alloc.alloc(None)
    _alloc.keep(obj['id'])        # ← без этой строки объект пропадёт

Урок: при внедрении авто-id проверять, сколько раз энкодер проходит один объект, и кэшируется ли выдача между проходами. «Круг чистый» этого не покажет (см. ловушку 6).

Приёмка авто-id — полный чек-лист (все пункты обязательны, ни один не заменяет другой):

# Проверка Что ловит
1 exit=0 падение кодирования
2 объект найден в выводе по имени потерю объекта (ловушки 1, 2, 6)
3 выданный id внутри окна ID_MIN..ID_MAX прыжок в железный диапазон
4 ссылка владельца → id ребёнка (устройство → регистр) битую связь
5 круг чистый на всём файле регресс
6 круг по всем снапшотам, не по свежему регресс на старых дампах