323 lines
21 KiB
Markdown
323 lines
21 KiB
Markdown
---
|
||
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_<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`,
|
||
**целевой артефакт не обновляет**.
|
||
|
||
```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 --` затирает незакоммиченное без возврата.
|