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

224 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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
```
Рост этой секции = форма не разобрана, а не «данных нет».