25 KiB
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 |
|
|
🔁 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)
# ✅ изменилась РОВНО одна строка, в ней маркер
# ❌ ноль изменений = энкодер ключ проигнорировал → искать ДРУГУЮ ветку кода
Проверять обе половины:
- Парсер — ключ есть в промежуточном файле, на ожидаемом месте.
- Энкодер — подмена доезжает до выхода,
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 строка изменилась, маркер на месте»).
- Питфолл «две точки входа» — чтобы следующая сессия сразу проверяла обе.
🔴 Одно имя на два разных действия = схлопывание (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 | круг по всем снапшотам, не по свежему | регресс на старых дампах |