Files
andrey 36f0fa2623 R2-3: полное покрытие новых фич, паритет torus, полировка
- tests/test.js: 148 -> 206 проверок (разделы 23 wrap/torus, 24 зоны, 25 бюджет,
  26 размеры, 27 сейв v2/миграция, 28 рендер/resize 300x300, 29 HUD 381-400px);
  resetGlobals сбрасывает tool/reason/winner; sleep заменён на waitCond/settleViewport.
- tools/old-ref/: независимый torus-эталон (nextStepCleanWrap, CLI --torus,
  gen-torus.js, reference-torus.json); diff-our.js сверяет stepN при setWrap(true).
- index.html: медиазапрос HUD 380->400px (убрана микро-обрезка 381-388px);
  инлайн-комментарий про согласованность useWrap/setWrap.
- AGENTS.md: полный список контракта дополнен; useWrap-заметка уточнена; покрытие
  приведено в соответствие. README: скорость — константа в коде, не UI.
- .gitignore: tools/old-ref/.tmp-*.txt.
- Прогоны: check: OK; суита 206/206 (3 повтора); паритет clean 273/273 + torus 273/273.
- Ревью-гейт R2-3 (Oracle): PASS; minor/nit закрыты.
2026-10-11 11:02:23 +03:00

351 lines
20 KiB
Markdown
Raw Permalink 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.
# old-ref — независимый эталон клеточного автомата из старого прототипа
Автономный C++ harness (без Qt), который **точно** воспроизводит `Life::nextStep()`
из `old/life/life.cpp` и печатает эталонные состояния поля.
Задача harness — зафиксировать поведение прототипа **как есть**, включая баг
`parent[]` (массив объявлен вне циклов и не обнуляется на каждую клетку).
Ничего не «улучшается».
## Файлы
| Файл | Что это |
|---|---|
| `old_ref.cpp` | исходник harness (порт + эталон «чистое большинство» + варианты для анализа) |
| `old_ref` | скомпилированный бинарник (**в git не коммитится** — собирается `g++`) |
| `reference.json` | **главный эталон**: массив `{name,w,h,steps:[ascii0..asciiN]}` (выходы ПРОТОТИПА) |
| `ref-<name>.txt` | те же выходы по одному кейсу в человекочитаемом виде (`=== step k ===`) |
| `run.log` | полный лог прогона (**генерируется**, в git не коммитится — `.gitignore: *.log`) |
| `ce1.txt` | минимальный контрпример отдельным файлом (3×3) |
| `gen-clean.js` / `reference-clean.json` | генератор и эталон **чистого правила** (наша спецификация) |
| `gen-torus.js` / `reference-torus.json` | генератор и эталон **чистого правила на торе (wrap)** — независимая проверка опции «Замкнутая карта» |
| `gen-fixture.js` | пересборка `tests/parity-fixture.json` для суиты |
| `diff-our.js` | дифтест `index.html` против `reference-clean.json`/`reference.json` (+`reference-torus.json`, если есть) |
## Оси и индексация
Поле — `QVector<QVector<unsigned char>> field`; инициализация:
`field.resize(wid); for i field[i].resize(hei)`.
**`field[i][j]`, где `i` — X (столбец, `0..wid-1`), `j` — Y (строка, `0..hei-1`).**
Подтверждено:
- `painter.cpp`: `drawRect(i*sclx + 1, j*scly + 1, ...)` — `i` по горизонтали, `j` по вертикали;
- `life.cpp` `mouseEvent`: `xi = x*wid, yi = y*hei; field[xi][yi]`.
**ASCII в harness:** число строк = `hei` (Y), число символов в строке = `wid` (X),
`ascii[j][i] = field[i][j]`. То есть строка ASCII = `j` (Y сверху вниз), позиция в строке = `i` (X слева направо).
`.` = 0 (мёртвая), `1` = игрок 1, `2` = игрок 2.
**Порядок обхода клеток в `nextStep`: X-мажорный** — внешний цикл `i` (X), внутренний `j` (Y).
Порядок опроса 8 соседей: `left, up, right, down, up-left, down-left, up-right, down-right`
(сначала 4-соседство, затем диагонали).
## Сборка и запуск
Harness сохранён в репозитории в `tools/old-ref/`. Исходник `old_ref.cpp`
самодостаточен (без Qt и без `old/`), компилируется любым `g++` из C++17.
Готовый бинарник `old_ref` **в git не коммитится** (собирается локально).
```bash
cd tools/old-ref
g++ -std=c++17 -O2 -o old_ref old_ref.cpp
./old_ref > run.log # прогоняет батарею, пишет reference.json и ref-*.txt
```
CLI-режим (одно поле, без записи файлов):
```bash
# <file.txt> <steps> [--proto|--clean|--swap|--torus]
printf '..2\n2.1\n1..\n' > ce1.txt
./old_ref ce1.txt 3 --proto # точный порт прототипа
./old_ref ce1.txt 3 --clean # эталон «чистое большинство из 3» (жёсткие границы)
./old_ref ce1.txt 3 --torus # то же чистое правило, но заворот через края (wrap)
./old_ref ce1.txt 3 --swap # тот же прототип, но обход клеток Y-мажорный
```
`--torus` — эталон для **новой** опции «Замкнутая карта»: в прототипе `old` заворотов
не было (см. разведку `exp-3`), поэтому независимого прототипного эталона нет; правило
рождения — то же чистое «большинство из 3», но соседи берутся с противоположной стороны.
## Формат `reference.json`
```json
[
{
"name": "cc_3x3_stale",
"w": 3,
"h": 3,
"steps": [
"..2\n2.1\n1..\n",
".2.\n...\n.2.\n",
"...\n...\n...\n"
]
}
]
```
`steps[0]` — стартовое поле, `steps[k]` — поле после `k` шагов `nextStep()` прототипа.
Строки разделены `\n`; поле `w×h` (строк `h`, символов в строке `w`).
Набор кейсов: пустое поле, блок 2×2, блинкер (гор./верт.), глайдер, одиночная клетка,
смешанные цвета (`mix_2_1`, `mix_row_121`), рождения по 3 соседям (`birth_112/122/121/221`),
несколько рождений за шаг (`multi_births`), спец-случай залежавшегося `parent[]`
(`stale_split`, `cc_3x3_stale`), детерминированные «плотные» LCG-поля 5×5…12×12
(seed'ы без `rand()`), одноцветные поля.
## Что именно делает `nextStep` прототипа (цитата, дословно)
```cpp
void Life::nextStep() {
int num;
int parent[8];
for (int k=0; k<8; k++) parent[k]=0; // обнуляется ОДИН раз, не на клетку
for (int i = 0; i < wid; i++) { // i = X
for (int j = 0; j < hei; j++) { // j = Y
num = 0;
if (i>0) if(field[i-1][j]>0) num++,parent[num-1] = field[i-1][j];
if (j>0) if(field[i][j-1]>0) num++,parent[num-1] = field[i][j-1];
if (i<wid-1) if(field[i+1][j]>0) num++,parent[num-1] = field[i+1][j];
if (j<hei-1) if(field[i][j+1]>0) num++,parent[num-1] = field[i][j+1];
if (i>0 && j>0) if(field[i-1][j-1]>0) num++, parent[num-1] = field[i-1][j-1];
if (i>0 && j<hei-1) if(field[i-1][j+1]>0) num++, parent[num-1] = field[i-1][j+1];
if (i<wid-1 && j>0) if(field[i+1][j-1]>0) num++, parent[num-1] = field[i+1][j-1];
if (i<wid-1 && j<hei-1) if(field[i+1][j+1]>0) num++, parent[num-1] = field[i+1][j+1];
if (field[i][j] == 0) {
if (num == 3) {
int firstp=0,secondp=0,p=0;
for (int n=0; n<8; n++) {
if (parent[n]>0) {
if (firstp == 0) firstp = parent[n];
else {
if (secondp == 0) secondp = parent[n];
else {
if (parent[n] == secondp) p=secondp;
else p = firstp;
}
}
}
}
tmp[i][j] = p;
}
} else {
if (num < 2 || num > 3) tmp[i][j] = 0;
else tmp[i][j] = field[i][j];
}
}
}
field = tmp;
}
```
Границы жёсткие: соседи за краем не считаются (условия `i>0`, `i<wid-1`, `j>0`, `j<hei-1`).
### Разбор правила рождения (пока данных нет — «чистый» случай)
`parent[0..2]` — цвета 1-го, 2-го и 3-го живых соседей в порядке опроса.
Логика `firstp/secondp/p` в чистом виде (нет залежавшихся значений) эквивалентна
правилу «цвет большинства из 3 соседей» для двух игроков:
| 3 соседа | `firstp` | `secondp` | 3-й (`parent[2]`) | `p` | итог |
|---|---|---|---|---|---|
| 1,1,2 | 1 | 1 | 2 | 1 | 1 |
| 1,2,1 | 1 | 2 | 1 | 1 | 1 |
| 2,1,1 | 2 | 1 | 1 | 1 | 1 |
| 1,2,2 | 1 | 2 | 2 | 2 | 2 |
| 2,1,2 | 2 | 1 | 2 | 2 | 2 |
| 2,2,1 | 2 | 2 | 1 | 2 | 2 |
## Анализ (пункт 4)
### 4.1. Совпадает ли прототип с «чистым большинством из 3»?
**НЕТ.** На батарее — **78 расхождений** (шаг × кейс). Расхождения возникают
только в полях со смешанными цветами `1` и `2`, где **ранее в порядке обхода**
встречалась клетка с **≥4 живыми соседями** (она заполняет `parent[3..7]`),
а затем встречается клетка с ровно 3 соседями. На одноцветных полях,
блоке, блинкере, глайдере и «изолированных» рождениях (`birth_*`) расхождений нет.
Причина — **баг `parent[]`**: массив не обнуляется на каждую клетку. Для клетки
с `num==3` реально записываются только `parent[0..2]`, но индексы `parent[3..7]`
содержат цвета 4..8-го соседей **последней ранее обработанной** клетки, у которой
их было больше. Цикл `for (n=0; n<8; n++)` видит эти «протухшие» значения.
Итоговый `p` определяется **последним ненулевым** элементом `parent[0..7]`:
`p = (parent[last] == secondp) ? secondp : firstp` — т.е. 3-й реальный сосед
игнорируется, если есть залежавшееся значение.
### 4.2. Минимальный контрпример
Поле 3×3 (`cc_3x3_stale` в `reference.json`, файл `ce1.txt`):
```
..2
2.1
1..
```
Шаг 1:
```
Ожидалось (чистое большинство) Прототип (факт)
.2. .2.
... ...
.1. .2.
```
Расходятся клетки `(x=1, y=2)` (внизу по центру): «чистое большинство» даёт `1`,
прототип — `2`.
**Трассировка.** Сканирование X-мажор: `(0,0),(0,1),(0,2),(1,0),(1,1),(1,2),…`
- `(0,0)`: 1 сосед → `parent=[2,…]`;
- `(0,1)`,`(0,2)`: по 1 соседу;
- `(1,0)`: 3 соседа `[2,2,1]` → рождение `2` (совпадает у обоих);
- `(1,1)`: 4 соседа `[2,1,1,2]` → **не** рождается, но пишет `parent[3]=2` (это залежавшееся значение);
- `(1,2)`: реальные соседи (в порядке опроса) `(0,2)=1`, `(0,1)=2`, `(2,1)=1` → `parent=[1,2,1]`, `num=3` → рождение.
Но `parent[3]` всё ещё `=2` от клетки `(1,1)`.
Цикл: `firstp=1`, `secondp=2`, `parent[2]=1≠2 → p=1`, затем `parent[3]=2==secondp → p=2`.
→ `tmp[1][2]=2`. **Ошибка: 3-й (реальный) сосед перекрыт залежавшимся `parent[3]`.**
Ещё один найденный случайный контрпример (5×5):
```
2.2.
..1.
2..1
..1.
1...
```
### 4.3. Зависит ли результат от порядка обхода клеток?
**ДА, зависит.** Тот же код с обходом `j` внешний / `i` внутренний (`--swap`) даёт
**60 расхождений** на батарее. Пример — `rnd_5x5_s1`, шаг 1, клетка `(x=0, y=2)`:
```
X-мажор (прототип) Y-мажор (swap)
..1.2 ..1.2
..... .....
1.... 2.... <- здесь 1 vs 2
.1.22 .1.22
..... .....
```
При этом **сам прототип детерминирован**: `parent` инициализируется нулями, все
записи детерминированы, `rand()`/неинициализированной памяти нет. Зависимость —
только от фиксированного порядка обхода (X-мажор), который и зашит в прототип.
### 4.4. Бывают ли «протечки» цвета (цвет не из соседей)?
**НЕТ.** `p` может принять только `firstp` (цвет 1-го живого соседа) или
`secondp` (цвет 2-го живого соседа) — оба гарантированно реальные соседи
(при `num==3` индексы `parent[0]` и `parent[1]` всегда записаны текущей клеткой).
Залежавшиеся значения могут лишь переключить выбор между этими двумя реальными
цветами, но не подставить посторонний цвет. Эмпирически: на **300 000** случайных
полей **2 378 830** рождений — «цвет не среди соседей»: **0**.
Итог: прототип **никогда** не даёт клетке цвет, отсутствующий среди её 3 живых
соседей; он лишь иногда выбирает **не тот из двух присутствующих** (меньшинство
вместо большинства).
## Что должна реализовать новая игра
По `AGENTS.md` новая игра намеренно **исправляет** баг: правило — «цвет
новорождённой = цвет **большинства из 3** живых соседей, tie-break — наименьший id».
Чтобы **точно повторить прототип** (байт-в-байт), нужно было бы воспроизвести баг:
```
parent[8] = {0,...,0} // ОДИН раз на весь шаг
for i in 0..wid-1: // X-мажор
for j in 0..hei-1:
num = 0
for каждого соседа в порядке [left,up,right,down,up-left,down-left,up-right,down-right]:
if сосед жив: parent[num] = цвет соседа; num++
if cell == 0:
if num == 3:
firstp = secondp = p = 0
for n in 0..7: // включает залежавшиеся parent[3..7]
if parent[n] > 0:
if firstp == 0: firstp = parent[n]
elif secondp == 0: secondp = parent[n]
elif parent[n] == secondp: p = secondp
else: p = firstp
next[i][j] = p
else:
next[i][j] = (num==2 || num==3) ? cell : 0
// мёртвые без рождения: next[i][j] остаётся значением из копии field (== 0)
field = next
```
**Но это НЕ то, что зафиксировано в `AGENTS.md` для новой игры.** Спецификация
требует чистое правило «большинство из 3 живых соседей» (баг прототипа явно
назван багом и воспроизводить его не нужно). Поэтому:
- **Правильная реализация новой игры** = `nextStepClean()` из `old_ref.cpp`:
собрать ровно 3 живых соседа, взять цвет, встретившийся ≥2 раз (при двух игроках
всегда однозначно). Именно эти выходы (`--clean`) и есть эталон для новой игры.
- `reference.json` содержит выходы **прототипа** (с багом) — он нужен, чтобы
понимать фактическое поведение старого кода и объяснять расхождения.
- Для сверки новой реализации с «правильным» правилом используйте `--clean`
(или независимо посчитанное большинство): на всех кейсах, кроме случайных
смешанных полей и `cc_3x3_stale`, прототип и чистое правило совпадают.
## Дифтест с новой реализацией
Дополнительные файлы (рядом с harness):
| Файл | Что это |
|---|---|
| `gen-clean.js` | генерирует `reference-clean.json` из CLI `--clean` для всех кейсов `reference.json` |
| `reference-clean.json` | эталон **чистого правила** (та же структура `{name,w,h,steps}`) |
| `gen-torus.js` | генерирует `reference-torus.json` из CLI `--torus` (чистое правило + заворот) |
| `reference-torus.json` | эталон **чистого правила на торе** (независимая проверка `wrap`) |
| `diff-our.js` | дифтест: `index.html` (headless) против `reference-clean.json`, `reference.json` и `reference-torus.json` |
| `gen-fixture.js` | делает компактную фикстуру паритета для суиты (`tests/parity-fixture.json`) |
Как воспроизвести (из корня репозитория):
```bash
# 1) собрать harness
g++ -std=c++17 -O2 -o tools/old-ref/old_ref tools/old-ref/old_ref.cpp
# 2) (при необходимости) пересобрать эталон чистого правила
cd tools/old-ref
node gen-clean.js # reference-clean.json (28 кейсов, 273 сравнения)
node gen-torus.js # reference-torus.json (чистое правило + wrap)
# 3) дифтест новой игры; exit 0 — наш step == clean (и torus, если файл есть)
node diff-our.js
CHROMIUM=/usr/bin/chromium-browser node diff-our.js # при необходимости — путь к Chromium
# 4) обновить фикстуру для суиты
node gen-fixture.js # -> tests/parity-fixture.json
```
`diff-our.js` автоматически берёт `index.html` из корня репозитория, а
`gen-fixture.js` пишет фикстуру в `tests/parity-fixture.json` (пути считаются
от `tools/old-ref/`, от корня репо всё работает как есть).
Результат (батарея из 28 кейсов, по 10 шагов; `cc_3x3_stale` — 3 шага):
- **наш `step` == `clean` на всех 273 (кейс × шаг) полях — 273/273**, 0 расхождений,
0 `pageerror`. То есть новая реализация точно соответствует чистому правилу
«большинство из 3 живых соседей + tie-break наименьший id».
- **наш `step` vs `proto` (баг `parent[]`): 195/273 совпало, 78 расхождений**
в тех же **10 кейсах**, где `proto ≠ clean`: `cc_3x3_stale`, `rnd_5x5_s2`,
`rnd_6x6_s1`, `rnd_6x6_s2`, `rnd_8x8_s1`, `rnd_8x8_s2`, `rnd_10x10_s1`,
`rnd_10x10_s2`, `rnd_12x12_s1`, `rnd_12x12_s2`. Это ожидаемое поведение
(баг прототипа намеренно исправлен), а не дефект новой игры.
- Классика (пустое поле, блок 2×2, блинкеры, глайдер, одиночная клетка,
смешанные `mix_*`, рождения `birth_*`, `multi_births`, `stale_split`,
`rnd_5x5_s1`, одноцветные поля): `proto == clean == наш`.
- **Опция «Замкнутая карта» (`wrap`/torus): наш `step` == torus-эталон 273/273**
(0 расхождений), `gen-torus.js`/`reference-torus.json`. 133 из 273 (кейс×шаг)
полей на торе отличаются от жёстких границ — эталон содержателен. Заворот —
новая опция (в прототипе `old` её не было), поэтому эталон считает сам harness
строго по чистому правилу с соседями через край.
Паритет закреплён в headless-суите: `tests/parity-fixture.json` (6 кейсов:
классика + плотные смешанные двухцветные + `cc_3x3_stale`) и проверки в
`tests/test.js` (раздел 16). Известное расхождение с `proto` зафиксировано там
как ожидаемое — наравне с требованием «наш == clean».