test: grade the wall-key invariant by what the product does
Validate / docs (push) Failing after 25s
Validate / process-workflow-sync (push) Successful in 1m13s
Validate / provenance (push) Failing after 1m16s
Validate / process-gate (push) Failing after 1m25s
Validate / changes (push) Successful in 1m9s
Validate / reuse (push) Successful in 58s
Validate / hacs (push) Failing after 17s
Validate / hassfest (push) Failing after 20s
Validate / frontend (push) Successful in 10m20s
Validate / backend (push) Failing after 9m54s
Validate / smoke (1) (push) Failing after 18m2s
Validate / smoke (2) (push) Failing after 17m49s
Validate / smoke (3) (push) Failing after 15m44s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 15m1s
Validate / performance_smoke (push) Failing after 13m49s

The first cut of checkWallKeys compared the stored key against endpoints
snapped to the lattice and called any mismatch a violation. Both halves were
wrong, and measurement says so: wallIntervals reports the query key for the
disputed edge as 0.887500,0.195833@1.5706, i.e. the form built from the
coordinates as stored, and the two owner configurations that differ in exactly
these keys produce byte-identical wall bodies and multi-wall node maps. The
check would have reddened a plan that renders correctly.

Graded now: a drift inside the tolerant fallback's half-pitch reach is an
observation, a key beyond it or one that does not parse as coordinates is a
violation. The threshold is expressed in grid steps with a 1e-3 slack — with a
relative 1e-6 the four identically drifted records of one plan split between
the two classes on their last bits, so the check repeated the very rounding tie
it exists to expose.

visual-matrix leaves KEY_CONTRACT_DEBT: its keys drift inside the reach and now
read as observations. large-house stays — its labels do not parse, and
wallIntervals shows all 80 solid edges resolving to zero thickness (#260).

Issue: #259
User-Visible: no
This commit is contained in:
Claude
2026-08-23 14:11:22 +03:00
parent 2970b8091b
commit 5e95a28406
4 changed files with 217 additions and 117 deletions
+91 -50
View File
@@ -5,7 +5,7 @@ import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
checkReferences, checkWallKeys, checkWallRecordsPreserved, latticePoint,
checkReferences, checkWallKeys, checkWallRecordsPreserved, keyMidpoint,
readModel, wallKey,
} from '../scripts/model-invariants.mjs';
import { wallKey as productWallKey } from '../test-build/wall-thickness.js';
@@ -113,16 +113,19 @@ const demoStandModel = () => {
/**
* Признанный долг по контракту ключей (#260), а не список исключений.
*
* `large-house` пишет метки вида `perf-wall-0-3`: они не разбираются как
* координаты вовсе, поэтому в перф-профиле путь поиска по ключу мёртв и
* толщину спасают только точные концы. `visual-matrix` пишет ключ с четырьмя
* знаками вместо шести — там расхождение попадает в допуск и рендер не страдает.
* Обе — вход golden и бюджетов, поэтому правка требует переприёмки эталонов:
* решение владельца, а не правка по ходу.
* `large-house` пишет метки вида `perf-wall-0-3`. Они не разбираются как
* координаты, поэтому их не находит ни точное совпадение, ни терпимый запас:
* измерено продуктовым `wallIntervals` — все 80 сплошных рёбер фикстуры
* остаются с нулевой толщиной и тел стен не возникает вовсе. Это вход и
* golden (4 сцены), и всех шести перф-бюджетов, поэтому правка требует
* переприёмки эталонов — решение владельца, а не правка по ходу.
*
* `visual-matrix` в этот список не входит: её ключи с четырьмя знаками вместо
* шести расходятся в пределах запаса, продукт их находит, и проверка выдаёт по
* ним наблюдения, а не нарушения.
*/
const KEY_CONTRACT_DEBT = new Map([
['large-house.mjs:makeLargeHouseFixture', 147],
['visual-matrix.mjs:makeVisualMatrixFixture', 23],
]);
test('все модели, которые возит с собой проект, инварианты не нарушают (#254)', async () => {
@@ -149,7 +152,7 @@ test('все модели, которые возит с собой проект,
// убрать запись. Молчаливого исключения здесь быть не должно, иначе
// проверка тихо перестанет что-либо значить.
const debt = KEY_CONTRACT_DEBT.get(label) ?? 0;
const keys = checkWallKeys(fixture.config);
const keys = checkWallKeys(fixture.config, { notes: [] });
assert.equal(keys.length, debt, debt
? `${label}: признанный долг #260 — ожидалось ${debt} записей с ключом не по`
+ ` контракту, найдено ${keys.length}. Починили — уберите запись из`
@@ -218,50 +221,87 @@ test('readModel понимает экспорт, ответ config/get и сыр
});
// --------------------------- инвариант 3: ключи ------------------------------
// #258. Числа ниже не придуманы: это две записи из экспорта владельца, на
// которых после «Оптимизировать» появились белые клинья в местах схода стен.
// Стены вертикальные, длиной 71 и 49 шагов решётки — нечётной, поэтому середина
// приходится ровно на границу округления, и бакет решает шум в последних битах.
// #258/#259. Числа ниже не придуманы: это записи из экспортов владельца.
//
// Главное, что здесь закреплено, — ГРАДАЦИЯ. Первая редакция проверки считала
// сдвиг ключа на шаг решётки нарушением и сверяла ключ с концами, приведёнными
// к узлам. И то и другое неверно: продукт ключует от координат как они лежат в
// конфигурации (проверено `wallIntervals`: для спорного ребра ключ запроса
// `0.887500,0.195833@1.5706`), а запись, ушедшую на полшага, он НАХОДИТ —
// два конфига владельца, различающиеся ровно такими ключами, дают побайтово
// одинаковые тела стен.
const KEYED = (key, cm, a, b) => ({
config: { spaces: [{ id: 'sp1', cell_cm: 5, rooms: [], walls: [{ key, cm, a, b }] }] },
spaces: [{ id: 'sp1', cell_cm: 5, rooms: [], walls: [{ key, cm, a, b }] }],
});
test('#258: ключ, пересчитанный от сырых концов, — нарушение', () => {
const found = checkWallKeys(KEYED(
test('#258: ключ, ушедший на шаг решётки, — наблюдение, а не нарушение', () => {
for (const [key, cm, a, b] of [
['0.887500,0.200000@1.5706', 29, [0.8875, 0.05], [0.8875, 0.345833333]],
['0.979167,0.450000@1.5706', 28, [0.979166667, 0.345833333], [0.979166667, 0.55]],
]) {
const notes = [];
assert.deepEqual(checkWallKeys(KEYED(key, cm, a, b), { notes }), [],
'проверка, красящая план, который рисуется верно, отключается первой');
assert.equal(notes.length, 1);
assert.equal(notes[0].kind, 'stale_wall_key');
// Граница названа прямо: попадание у продукта решает шум в последних битах.
assert.match(notes[0].detail, /ровно на его границе/);
}
});
test('#258: ключ, равный ключу своего ребра, не даёт даже наблюдения', () => {
const notes = [];
assert.deepEqual(checkWallKeys(KEYED(
'0.887500,0.195833@1.5706', 29, [0.8875, 0.05], [0.8875, 0.345833333],
).config);
), { notes }), []);
assert.deepEqual(notes, []);
});
test('#260: ключ, который не разбирается как координаты, — нарушение', () => {
const notes = [];
const found = checkWallKeys(
KEYED('perf-wall-0-3', 15, [0.1, 0.1], [0.5, 0.1]), { notes },
);
assert.equal(found.length, 1);
assert.equal(found[0].kind, 'wall_key');
assert.equal(found[0].reference, '0.887500,0.200000@1.5706');
const second = checkWallKeys(KEYED(
'0.979167,0.445833@1.5706', 28, [0.979166667, 0.345833333], [0.979166667, 0.55],
).config);
assert.equal(second.length, 1);
assert.equal(second[0].reference, '0.979167,0.450000@1.5706');
assert.equal(found[0].reference, '0.300000,0.100000@0.0000');
assert.match(found[0].detail, /не разбирается как координаты/);
assert.deepEqual(notes, [], 'нарушение не дублируется наблюдением');
});
test('#258: тот же ключ до дефекта нарушением не считается', () => {
// Обратная половина того же дефекта, и она важнее прямой: форма проверки «от
// сырых концов» помечала бы именно ЭТО состояние, а план на нём рисовался
// верно. Проверка, ругающаяся на исправное, отключается в первую неделю.
assert.deepEqual(checkWallKeys(KEYED(
'0.887500,0.200000@1.5706', 29, [0.8875, 0.05], [0.8875, 0.345833333],
).config), []);
test('ключ, ушедший дальше терпимого запаса, — нарушение', () => {
// Два шага вместо полшага: столько запас не покрывает ни при каком округлении.
const notes = [];
const found = checkWallKeys(
KEYED('0.300000,0.108333@0.0000', 15, [0.1, 0.1], [0.5, 0.1]), { notes },
);
assert.equal(found.length, 1);
assert.match(found[0].detail, /2\.00 шага/);
assert.deepEqual(notes, []);
});
test('запись без концов ключевую проверку не роняет', () => {
// Совместимость: у старых записей есть только ключ. Сверять не с чем, и это
// не повод объявлять их сломанными.
const notes = [];
assert.deepEqual(checkWallKeys({
spaces: [{ id: 'sp1', walls: [{ key: 'legacy', cm: 15 }, { cm: 20 }] }],
}), []);
}, { notes }), []);
assert.deepEqual(checkWallKeys({ spaces: [{ id: 'sp1', walls: [
{ key: '', cm: 15, a: [0, 0], b: [0.1, 0] },
{ key: 42, cm: 15, a: [0, 0], b: [0.1, 0] },
] }] }), []);
] }] }, { notes }), []);
assert.deepEqual(checkWallKeys(null), []);
assert.deepEqual(notes, []);
});
test('keyMidpoint читает координаты ключа и отвергает метку (#259)', () => {
assert.deepEqual(keyMidpoint('0.887500,0.200000@1.5706'), [0.8875, 0.2]);
assert.deepEqual(keyMidpoint('-0.637500,-0.208333@0.0000'), [-0.6375, -0.208333]);
for (const bad of ['perf-wall-0-3', 'legacy', '', null, undefined, 'a,b@c', '1,2']) {
assert.equal(keyMidpoint(bad), null, `разобрано то, что не должно: ${bad}`);
}
});
test('копия wallKey в скрипте совпадает с продуктовой (#259)', () => {
@@ -283,11 +323,14 @@ test('копия wallKey в скрипте совпадает с продукт
}
}
assert.ok(checked > 500, `сверено ${checked} отрезков: набор подозрительно мал`);
// Вырожденный отрезок и ничья округления — там, где формулы расходятся первыми.
// Вырожденный отрезок, ничья округления и координаты не с решётки — там, где
// формулы расходятся первыми.
for (const [a, b] of [
[[0.5, 0.5], [0.5, 0.5]],
[[0.8875, 0.05], [0.8875, 0.345833333]],
[[0.979166667, 0.345833333], [0.979166667, 0.55]],
[[0.06, 0.08], [0.48, 0.08]],
[[-1.670833333, -0.208333333], [0.4, -0.208333333]],
[[0, 0], [1 / 240, 0]],
]) {
assert.equal(wallKey(a, b), productWallKey(a, b, GRID_STEP_N),
@@ -295,20 +338,18 @@ test('копия wallKey в скрипте совпадает с продукт
}
});
test('latticePoint даёт точный узел, а не почти узел (#259)', () => {
// Делением на шаг вместо индекса узла 83/240 превращается в значение, которое
// отличается в последних битах — а весь инвариант живёт именно там.
const [, y] = latticePoint([0, 0.345833333]);
assert.equal(y, 83 / 240);
assert.equal(latticePoint([0.05, 0.55])[1], 132 / 240);
assert.deepEqual(latticePoint([-1.670833333, -0.208333333]), [-401 / 240, -50 / 240]);
});
test('детектор ключей умеет падать (#259)', () => {
// Проверка на проверку: инвариант, который не может сработать, бесполезен.
const good = KEYED('0.050000,0.050000@0.0000', 20, [0, 0.05], [0.1, 0.05]).config;
assert.deepEqual(checkWallKeys(good), []);
const broken = JSON.parse(JSON.stringify(good));
broken.spaces[0].walls[0].key = '0.050000,0.054167@0.0000';
assert.equal(checkWallKeys(broken).length, 1);
test('градация по шагам не решается последними битами (#259)', () => {
// Четыре записи второго пространства владельца ушли на одни и те же полшага.
// С относительным допуском 1e-6 две из них становились нарушением, две —
// наблюдением: проверка повторяла ту самую ничью округления, которую должна
// показывать. Все четыре обязаны попасть в один класс.
const walls = [
['-0.637500,-0.208333@0.0000', 30, [-1.670833333, -0.208333333], [0.4, -0.208333333]],
['-0.637500,1.266667@0.0000', 20, [0.4, 1.266666667], [-1.670833333, 1.266666667]],
['0.354167,2.287500@0.0000', 20, [-0.354166667, 2.2875], [1.058333333, 2.2875]],
['0.354167,3.866667@0.0000', 30, [1.058333333, 3.866666667], [-0.354166667, 3.866666667]],
].map(([key, cm, a, b]) => ({ key, cm, a, b }));
const notes = [];
assert.deepEqual(checkWallKeys({ spaces: [{ id: 'sp2', cell_cm: 1, walls }] }, { notes }), []);
assert.equal(notes.length, 4, 'все четыре — один класс, а не два');
});