[2026-09-18] eagle: personal/projects/zont-config-compiler.md personal/tech/roundtrip-key-verification.md

This commit is contained in:
Alexey Martemyanov
2026-09-18 00:32:31 +06:00
parent 9cf7a6f618
commit 573b1e2471
2 changed files with 256 additions and 4 deletions
+157 -4
View File
@@ -30,6 +30,18 @@ aliases:
- ZONT расписание trigger
- ZONT unresolved снят
- ZONT битая ссылка id
- ZONT авто-id
- ZONT автогенерация id
- ZONT круг 50
- ZONT окно id 4098 19999
- ZONT ID_MIN ID_MAX
- ZONT _IdAllocator
- ZONT дыры снизу вверх
- ZONT keep renames питфолл
- ZONT барьер id in obj
- ZONT питфолл 104
- ZONT валидация ссылок
- ZONT ссылка в никуда
- ZONT конец сценария
- ZONT терминатор сценария
- ZONT unresolved = конец сценария
@@ -638,6 +650,14 @@ enabled = not (f5 & 8)
> **(в) дорисовка «для читаемости»** — `type`, `object_name`, `_head`: имена и ярлыки, которых
> в строке нет. Alex видит их мгновенно и считает выдумкой. **YAML = строка конфига + вывод из тела.**
> 🔴 **Питфоллы круга 50 (авто-id) — читать ПЕРЕД правкой id:**
> **101** `keep()` не писал в `renames` → регресс −66 объектов (§25.7);
> **102** «id есть в YAML» ≠ «объект можно без id» — граница по секциям измерена (§25.7);
> **103** ссылка на несуществующий id **не проверяется** — мусор уезжает в конфиг, exit=0 (§25.7);
> **104** барьер `'id' in obj` отсекает узел **до** аллокатора, молча, exit=0 (§25.9).
> **Общий урок:** обернуть сборку строки недостаточно — надо снять условия допуска во **всех**
> циклах вывода, и проверять факт трассировкой мини-кейса, а не чтением кода.
**Корень:** я подменял решение Alex своим и считал это работой. Когда он говорит «наличием поля X» —
это **ответ**, а не повод искать обходной путь.
@@ -959,7 +979,8 @@ https://lk.zont-online.ru/download/firmwares/H2000_PRO_<HW>__<FW>_<PROFILE>.zip
| **`objcmd`** | ✅ **РАЗОБРАН**`descr` + `args` + `target: <id>` (`10029`/`10033`/`10036`/`10053`). `args` остаётся **только** у `objcmd` |
| **`args` у `set_var`** | ⛔ **ОТМЕНЁН** (круг 40, `4b99d8e`) — был выдумкой: `0` = поле 4, `1` = поле 5 (**производное**: «в поле 2 литерал, а не id»). Энкодер ставит поле 5 сам (§21.2.1) |
| **`action` / шаг 46 в `steps`** | ⛔ **ОТМЕНЁН** (круг 39, `8f2edd4`) — шаг 46 в `steps` не пишется; его id едет в **`trigger.step_id`**, `steps` = **тела действий** (§21.3) |
| Коммит | **`4b99d8e` ← текущий HEAD** · `8f2edd4` (step_id) · `272f4c6` (trigger.step) · `6d0b6a5` · `4c6a6f5` (орфаны-param) · `806ed2e` (end) · `b770bed` (revert) · `ba6ef44` · `0cbbeb8` (расписание 50) · `2007771` (условие 47) · `0c4b9a6` (`var` поле 2) · `16ec710` (круг 32) · `375d01a` · `07c076f` |
| Коммит | **`5d31d6e` ← текущий HEAD** (круги 46-49, запушен в Gitea) · `9c83651` (exit) · `5d31d6e` · `75f46f3` (тип 9 / set_contour_target) · `5fdedc9` (тип 5: `action`) · `4b99d8e` (`args` у set_var снят) · `8f2edd4` (step_id) · `272f4c6` (trigger.step) · `6d0b6a5` · `4c6a6f5` (орфаны-param) · `806ed2e` (end) · `b770bed` (revert) · `ba6ef44` · `0cbbeb8` (расписание 50) · `2007771` (условие 47) · `0c4b9a6` (`var` поле 2) · `16ec710` (круг 32) · `375d01a` · `07c076f` |
| 🔴 Незакоммичено | **авто-id (круг 50)**`yml-to-config.py` изменён, **113+/26**, поверх `5d31d6e`. Аллокатор + окно + hard fail написаны, регресс `759 → 759` зелёный, авто-id end-to-end **НЕ работает** (§25.9-25.11). Коммит — по команде Alex |
| Откачено | `87e315c` («set-var target into `set_var`») — снят `git reset --soft` без разрешения Alex (§10.1) |
| Ранее | `823fabd` — форма сценария §5; `1cc010a`**содержит сломанные версии**; закрыт §9.1 |
| Документация | ✅ **три дока сведены в один**`personal/projects/zont-config-compiler.md` (§9.2) |
@@ -3709,9 +3730,141 @@ Alex: **«если id есть то очевидно ничего не прид
**Правило одной строкой:** `id` в YAML = решение человека, неприкосновенно. Нет `id` — выдаём сами.
Ссылка на несуществующий id — всегда ошибка, автозамены нет ни в одном случае.
**Риски:** (а) ссылки в новые объекты — реальная проблема, требует решения шага 3; (б) порядок
эмиссии: `S`-блоки первыми, `Z` — после, id нужен раньше строки; (в) **верхняя граница**
`max+1 = 20556` перескочит железо, вопрос к Alex открыт.
### 25.7. ✅ ВЫПОЛНЕНО (круг 50, 2026-09-18) — аллокатор написан, регресс зелёный
| Что | Файл / место | Статус |
|---|---|---|
| `ID_MIN = 4098`, `ID_MAX = 19999` | `yml-to-config.py`, модульные константы | ✅ |
| Класс `_IdAllocator` — дыры снизу вверх, hard fail «id window exhausted» | там же | ✅ |
| Врезка в 3 узких места: `add_z_line`, `build_line('Z', ...)` ×7, `reconstruct_from_raw` ×16 | там же | ✅ |
| `_alloc_id` — 3 источника «своего» id: `_known_ids`, `renames`, `known=True` | там же | ✅ |
| Регресс | `test_roundtrip.py`**759 → 759, расхождений 0** | ✅ |
Правки внесены тремя идемпотентными скриптами `/tmp/patch_autoid{,2,3}.py`
(проверка `count == 1` на каждую замену, печатают OK/SKIP).
#### 🔴 ПИТФОЛЛ 101 — `keep()` не писал в `renames`: регресс −66 объектов
**Симптом:** регресс `759 → 693`, потеряно ровно **66** строк, **все типа 46** формы
`#Z8550=46,0,8548,[8549],[]`.
**Как искалось (правильный порядок):** сначала трассировка **мини-кейса** (один сценарий `8547`),
а не чтение кода. Диагностика `/tmp/dbg50.py` подменила `build_line` и напечатала все id, дошедшие
до сборки: `#Z4098=46,0,8548,[8549],[]` — строка рождалась **с новым номером**, а ссылка в поле 2
сценария оставалась `[8550]`. Объект уезжал в никуда.
**Причина:** `_alloc_id` считает id «своим», если он есть в `renames`. `keep()` писал id только
в `_used`, **но не в `renames`** → для `_alloc_id` id выглядел незанятым → выдавался новый номер.
**Лечение:** `keep()` регистрирует **тождественное переименование** `id -> id`
(`self.renames.setdefault(obj_id, obj_id)`). После этого все три источника своего id
обрабатываются одинаково.
**Урок:** `keep()` и `alloc()` обязаны писать в **один и тот же** словарь. Два словаря занятости
(`_used` и `renames`) — источник расхождения; любая функция, помечающая id занятым, регистрирует
его и в `renames`.
#### 🔴 ПИТФОЛЛ 102 — «отдельный id есть в YAML» ≠ «объект можно оставить без id»
Замерено `/tmp/test_autoid_granica.py` (снять id у одного объекта секции, собрать, посмотреть вердикт):
| Секция | Результат | Причина |
|---|---|---|
| `relay_commands` | ❌ **объект пропал** (старая строка ушла, новая не появилась) | см. §25.8 |
| `heating_circuits` | ❌ `unknown param 'target_temp' ... (type None)` | на объект ссылаются **по типу** |
| `temperature_sensors` | ❌ `unknown event 'lost' ... (type None)` | то же |
| `actions` / `mqtt_topics` / `gui_switches` | ❌ `Ошибка: 'id'` | цикл по секции требует `id` **у всех** записей |
**Граница авто-id (подтверждено данными):**
| Класс объекта | Можно без id? |
|---|---|
| Объект-**лист**, на который никто не ссылается (по id или по типу) | ✅ да |
| Объект, на который ссылаются **по типу** (`_body_type(id)` — события/параметры 49) | ❌ нет: тип берётся по id |
| Объект в секции, чей цикл вывода требует `id` у каждой записи | ❌ нет без правки цикла |
#### 🔴 ПИТФОЛЛ 103 — ссылка на несуществующий id НЕ проверяется (дыра до круга 50)
Кейс B приёмки: `target: 9464` подменён на `target: 99999`**сборка прошла, exit=0**, мусор
`99999` уехал в выходной конфиг.
**Это опаснее потери объекта:** контроллер примет команду в никуда. Правило Alex — «ссылка на
несуществующий id = ошибка» — **в коде отсутствует**, его надо написать:
`validate_config` обязан сверять ссылки (`target`, `output_id`, `object`, `left`/`right`, поля-списки)
с множеством существующих id и падать, если ссылка висит в воздухе.
### 25.8. ⏳ ОСТАТОК круга 50 (не сделано)
1. ~~**`relay_commands` без id теряется** — причина не установлена~~ → ✅ **ПРИЧИНА УСТАНОВЛЕНА**
(см. §25.9, питфолл 104).
2. **Валидация ссылок** (питфолл 103) — `validate_config` не сверяет ссылки с существующими id.
3. **Секции, требующие `id` у каждой записи** (`actions`, `mqtt_topics`, `gui_switches`) — для
авто-id нужно ослабить циклы до `.get('id')`.
4. **Объекты-источники по типу** — авто-id возможен только вместе с правкой `_body_type`
(тип должен выводиться до присвоения id, из самой записи).
### 25.9. 🔴 ПИТФОЛЛ 104 — барьер `'id' in obj` отсекает узел ДО аллокатора
Решение пункта 1 остатка — **не догадкой, а диагностикой** `/tmp/dbg_relaycmd.py` (печатает узел до/после
снятия id, exit энкодера, наличие нового id в выводе, попадание в `_known_ids`).
**Факт:** аллокатор **не вызывается вообще**. Узел `relay_commands` без `id` выбрасывается молча
раньше — условием цикла вывода:
```python
# LINE 2046, цикл TYPE_ORDER
for obj in obj_list:
if isinstance(obj, dict) and 'id' in obj: # ← узел без id сюда не входит
zid = _alloc_id(obj['id'])
```
**Тот же барьер стоит в ТРЁХ местах** — именно он давал `Ошибка: 'id'` в замерах §25.6:
| Место (строка) | Условие | Что молча отсекает |
|---|---|---|
| 2046, цикл `TYPE_ORDER` | `if isinstance(obj, dict) and 'id' in obj` | **все секции**: `relay_commands`, `heating_circuits`, … |
| 1546, цикл орфанов | `if not isinstance(_obj, dict) or 'id' not in _obj: continue` | `actions`, `mqtt_topics`, `gui_switches` |
| 1308, `_body_index` | `if ... and 'id' in _obj and 'raw' in _obj` | тела без id (ссылки на них не находятся) |
**Урок:** при внедрении авто-id недостаточно обернуть **сборку** строки (`build_line`/`add_z_line`) —
надо снять **условия допуска** во всех циклах вывода. Иначе объект не доходит до аллокатора и
пропадает **без единого сообщения об ошибке**. Признак: `exit=0`, строка отсутствует и в старом, и в
новом номере.
### 25.10. 🎯 ОТКРЫТОЕ РЕШЕНИЕ (вопрос задан Alex, ответа нет)
Правка трёх барьеров = открыть авто-id для всех секций. Выбор объёма:
| Вариант | Что делается | Объём / риск |
|---|---|---|
| **1. Только объекты-листья** *(рекомендация)* | 3 правки условий допуска; объекты, на которые ссылаются по типу, продолжают требовать явный `id` | ~30 мин, риск низкий |
| **2. Все секции** | дополнительно снять барьер «`id` у каждой записи» в 6+ циклах + разобрать `_body_type` (тип объекта нужен ДО присвоения id) | ~2 ч, риск средний — можно молча сломать ссылки по типу |
Аргумент за вариант 1: закрывает реальный сценарий («написал новую реле-команду, номер придумался
сам»). Объекты-источники всегда заводит прибор, и номер им выдаёт контроллер, а не мы.
### 25.11. 📌 Статус на конец сессии 2026-09-18 (для следующей)
| Позиция | Состояние |
|---|---|
| Аллокатор, окно, hard fail | ✅ **написано** в `yml-to-config.py` (**НЕ закоммичено**) |
| Регресс 9/9 | ✅ `759 → 759`, расхождений 0 |
| Авто-id end-to-end | ❌ **не работает**: барьеры §25.9 не сняты |
| Валидация ссылок | ❌ нет (питфолл 103) |
| Рабочее дерево | `yml-to-config.py` изменён, **113+/26**, поверх `5d31d6e` |
🔴 **Работа НЕ закоммичена.** Коммит — по команде Alex (питфолл: он сам скажет).
Правки внесены скриптами `/tmp/patch_autoid.py`, `/tmp/patch_autoid2.py`, `/tmp/patch_autoid3.py`
(идемпотентные, проверка `count == 1`, печатают OK/SKIP).
**Инструменты диагностики (в `/tmp`, не в репо):** `/tmp/dbg50.py` (трассировка `build_line`,
минимальный кейс сценария), `/tmp/dbg_relaycmd.py` (барьер `'id' in obj`),
`/tmp/test_autoid.py` (тест авто-генерации), `/tmp/test_autoid_granica.py` (граница: какие объекты
можно без id), `/tmp/accept_autoid.py` (приёмка: замена id + ссылка в никуда).
**Риски (актуальны):** (а) ссылки в новые объекты — реальная проблема, требует решения шага 3;
(б) порядок эмиссии: `S`-блоки первыми, `Z` — после, id нужен раньше строки;
(в) ~~верхняя граница~~ — ✅ **ЗАКРЫТО**: фикс-окно `4098..19999`, решение Alex.
---
@@ -221,3 +221,102 @@ 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 --` затирает незакоммиченное без возврата.