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
+88 -43
View File
@@ -220,59 +220,93 @@ export function wallKey(a, b, pitch = GRID_STEP_N) {
const bucket = Math.round(angle * 1800) / 1800;
const precision = pitch > 0 && pitch < 0.01 ? 6 : pitch < 1 ? 4 : 2;
return `${mx.toFixed(precision)},${my.toFixed(precision)}@${bucket.toFixed(4)}`;
};
/** Середина, записанная в ключе. `null` — ключ не разбирается как координаты. */
export function keyMidpoint(key) {
const match = /^(-?[0-9.]+),(-?[0-9.]+)@(-?[0-9.]+)$/.exec(String(key ?? ''));
if (!match) return null;
const x = Number(match[1]), y = Number(match[2]);
return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null;
}
/**
* Вершина на ближайшем узле решётки.
* Инвариант 3: ключ записи толщины опознаёт своё же ребро (#258, #259).
*
* Через индекс узла, а не делением: `Math.round(v * 240) / 240` даёт точное
* `k/240`, тогда как умножение на шаг накапливает разницу в последних битах —
* а весь этот инвариант живёт именно там.
* Что здесь измерено, а не предположено. Продукт ищет запись по ключу,
* посчитанному от координат ребра **как они лежат в конфигурации** —
* `keyOf(a, b, pitch, coordScale)` в `lookupWall`. Проверено исполнением на
* экспортах владельца: `wallIntervals` даёт для спорного ребра ключ запроса
* `0.887500,0.195833@1.5706`, то есть форму от сырых концов. Первая редакция
* этой проверки сверяла с концами, приведёнными к узлам решётки, и была
* неверна: на решёточную форму продукт не смотрит нигде.
*
* Отсюда две степени, а не одна. `lookupWall` после промаха по строке даёт
* терпимый запас — полшага решётки по середине при совпадении направления:
*
* - расхождение в пределах запаса — НАБЛЮДЕНИЕ. Запись находится, план
* рисуется верно. Проверено: два конфига владельца, различающиеся ровно
* такими ключами, дают побайтово одинаковые тела стен. Объявить это
* нарушением значит покрасить здоровый план — а проверка с ложными
* срабатываниями отключается первой;
* - расхождение больше запаса либо ключ, который вообще не разбирается как
* координаты, — НАРУШЕНИЕ. Такую запись не находит ни точное совпадение,
* ни запас. Ровно это происходит с `demo/fixtures/large-house.mjs` (#260):
* метки вида `perf-wall-0-3` не разбираются, и все 80 сплошных рёбер
* фикстуры остаются с нулевой толщиной — 0 тел стен на плане, который
* служит перф-бюджетом.
*/
export const latticePoint = (p) => [
Math.round(p[0] * GRID_N) / GRID_N,
Math.round(p[1] * GRID_N) / GRID_N,
];
/**
* Инвариант 3: ключ записи толщины — это ключ решёточного ребра (#258, #259).
*
* Сравниваются строки, без допусков: допуск здесь и был причиной промаха.
* `checkReferences` проверяет попадание середины на ребро с точностью 0.004,
* а сдвиг ключа на один шаг решётки равен 0.00417 — проверка стояла ровно на
* границе своего же допуска.
*
* Ключ считается от концов, ПРИВЕДЁННЫХ К УЗЛАМ, а не от сырых. Разница не
* косметическая, и первая формулировка в #258 была из-за неё неверной: до
* дефекта ключ `0.887500,0.200000@1.5706` не совпадал с ключом от сырых концов
* записи, и план при этом рисовался верно. Рендер ключует от узловой формы,
* поэтому сверять надо с ней; форма от сырых концов помечает исправное
* состояние и пропускает дефектное — проверено на паре экспортов «до/после».
*
* Причина, по которой одна вершина даёт два разных ключа: `wallKey` квантует
* середину через `Math.round`, а у стены нечётной длины в шагах середина
* попадает ровно на границу округления. `83/240` даёт 47.5 шага и бакет 48,
* записанное в конфиге `0.345833333` — 47.49999996 и бакет 47.
*/
export function checkWallKeys(config) {
export function checkWallKeys(config, { notes = [] } = {}) {
const violations = [];
const reach = GRID_STEP_N * 0.5;
for (const space of Array.isArray(config?.spaces) ? config.spaces : []) {
const spaceId = String(space?.id ?? '?');
for (const wall of space?.walls || []) {
const a = point(wall?.a), b = point(wall?.b);
// Запись только с ключом — совместимость: проверять нечем, и это не повод
// объявлять её сломанной.
// Запись только с ключом — совместимость: сверять не с чем, и это не
// повод объявлять её сломанной.
if (!a || !b) continue;
if (typeof wall?.key !== 'string' || !wall.key) continue;
const expected = wallKey(latticePoint(a), latticePoint(b));
if (wall.key !== expected) {
violations.push({
invariant: 'wall_keys', kind: 'wall_key', owner: `${spaceId}:${wall.key}`,
const expected = wallKey(a, b);
if (wall.key === expected) continue;
const owner = `${spaceId}:${wall.key}`;
const stored = keyMidpoint(wall.key);
if (!stored) {
violations.push({ invariant: 'wall_keys', kind: 'wall_key', owner,
reference: expected,
detail: `ключ записи ${wall?.cm} см не равен ключу решёточного ребра —`
+ ' запись не найдётся при отрисовке',
});
detail: `ключ записи ${wall?.cm} см не разбирается как координаты —`
+ ' её не найдёт ни точное совпадение, ни терпимый запас' });
continue;
}
const drift = Math.hypot(stored[0] - (a[0] + b[0]) / 2, stored[1] - (a[1] + b[1]) / 2);
// Сравнение с запасом обязано иметь собственный допуск. Сдвиг ключа на
// один шаг решётки даёт середину ровно на полшага, то есть ровно на
// границе запаса — и исход у продукта решает шум в последних битах.
// Измерено: продукт такую запись НАХОДИТ (тела стен двух конфигов
// владельца, различающихся именно этим, побайтово одинаковы). Приговор,
// который решает шум, — не приговор.
// Допуск задан в шагах, а не в долях от `reach`: сдвиг на один шаг даёт
// ровно 0.5, и разные представления одной вершины (точный узел против
// девяти знаков в конфигурации) двигают эту величину на ~5e-5 шага. С
// относительным допуском 1e-6 четыре одинаковых записи одного плана
// делились на «нарушение» и «наблюдение» по последним битам — проверка
// повторяла ту самую болезнь, которую должна показывать. Ближайший
// настоящий класс — 1.5 шага, до него 200%, так что 1e-3 ничего не прячет.
const steps = drift / GRID_STEP_N;
if (steps > 0.5 + 1e-3) {
violations.push({ invariant: 'wall_keys', kind: 'wall_key', owner,
reference: expected,
detail: `середина ключа ушла на ${steps.toFixed(2)} шага —`
+ ' дальше терпимого запаса в полшага, запись не найдётся' });
continue;
}
const edge = Math.abs(steps - 0.5) <= 1e-3
? ' и ровно на его границе: у продукта исход решает шум в последних битах'
: '';
notes.push({ invariant: 'wall_keys', kind: 'stale_wall_key', owner,
reference: expected,
detail: `ключ не равен ключу своего ребра, расхождение ${steps.toFixed(2)}`
+ ` шага — запись находится только через терпимый запас${edge}` });
}
}
return violations;
@@ -286,12 +320,23 @@ export function readModel(text) {
return { config, layout };
}
/** Наблюдения перечисляются по смыслу: «их 39» читателю ничего не говорит. */
function noteSummary(notes) {
const counts = new Map();
for (const note of notes) counts.set(note.kind, (counts.get(note.kind) || 0) + 1);
const titles = {
unknown_owner: 'позиции без записи маркера',
stale_wall_key: 'записей толщины находятся только через терпимый запас',
};
return [...counts].map(([kind, n]) => `${n} — ${titles[kind] || kind}`).join('; ') + '.';
}
function report(violations, notes = []) {
if (!violations.length) {
const tail = notes.length
? `\nНаблюдений (не нарушения): ${notes.length} — позиции без записи маркера.`
? `\nНаблюдений (не нарушения): ${notes.length}. ` + noteSummary(notes)
: '';
return 'Инварианты выполнены: ссылки разрешимы, ключи записей толщины на месте.'
return 'Инварианты выполнены: ссылки разрешимы, записи толщины находятся по ключу.'
+ tail;
}
const lines = [`Нарушений: ${violations.length}.`, ''];
@@ -308,7 +353,7 @@ function report(violations, notes = []) {
wall_carrier: 'Записи толщины вне рёбер и перегородок',
open_span_carrier: 'Виртуальные проёмы вне границ комнат',
lost: 'Потерянные записи толщины',
wall_key: 'Ключи записей толщины не совпадают с ребром решётки',
wall_key: 'Записи толщины, которые не найдутся по ключу',
};
for (const [kind, list] of byKind) {
lines.push(`${titles[kind] || kind}: ${list.length}`);
@@ -336,7 +381,7 @@ function main(argv) {
const notes = [];
const violations = [
...checkReferences(model, { notes }),
...checkWallKeys(model.config),
...checkWallKeys(model.config, { notes }),
];
if (argv.includes('--json')) console.log(JSON.stringify({ violations, notes }, null, 2));
else console.log(report(violations, notes));
+24 -12
View File
@@ -231,27 +231,39 @@ export const MUTANTS = [
}],
},
{
id: 'invariant-keys-from-raw-endpoints',
guard: 'node --test --test-name-pattern="ключ, пересчитанный от сырых концов" '
id: 'invariant-keys-cry-wolf',
guard: 'node --test --test-name-pattern="ушедший на шаг решётки" '
+ 'test/model-invariants.test.mjs',
because: 'ключ считается от концов НА РЕШЁТКЕ, а не от сырых: от сырых проверка '
+ 'помечает исправное состояние и пропускает дефектное — ровно этой ошибкой была '
+ 'первая формулировка в #258',
because: 'запись, ушедшую на полшага, продукт НАХОДИТ — измерено на двух конфигах '
+ 'владельца с побайтово одинаковыми телами стен; объявить это нарушением значит '
+ 'покрасить исправный план, а такую проверку отключают первой (#258)',
patches: [{
file: 'scripts/model-invariants.mjs',
find: ' const expected = wallKey(latticePoint(a), latticePoint(b));',
replace: ' const expected = wallKey(a, b);',
find: ' if (steps > 0.5 + 1e-3) {',
replace: ' if (steps > 0) {',
}],
},
{
id: 'invariant-keys-tolerate-one-step',
guard: 'node --test --test-name-pattern="детектор ключей умеет падать" '
id: 'invariant-keys-tolerate-any-drift',
guard: 'node --test --test-name-pattern="дальше терпимого запаса" '
+ 'test/model-invariants.test.mjs',
because: 'смысл проверки — сравнение строк без допусков: допуск здесь и был причиной '
+ 'промаха, потому что сдвиг ключа на шаг решётки равен допуску checkReferences',
because: 'ключ, ушедший дальше полшага, не находит ни точное совпадение, ни запас — '
+ 'если проверка это пропустит, запись толщины молча перестанет существовать',
patches: [{
file: 'scripts/model-invariants.mjs',
find: ' if (wall.key !== expected) {',
find: ' if (steps > 0.5 + 1e-3) {',
replace: ' if (false) {',
}],
},
{
id: 'invariant-keys-accept-a-label',
guard: 'node --test --test-name-pattern="не разбирается как координаты" '
+ 'test/model-invariants.test.mjs',
because: 'метка вместо ключа (perf-wall-0-3 в перф-фикстуре, #260) оставляет все 80 '
+ 'сплошных рёбер без толщины: проверка обязана отличать её от сдвига на полшага',
patches: [{
file: 'scripts/model-invariants.mjs',
find: ' if (!stored) {',
replace: ' if (false) {',
}],
},