Files
houseplan-card/scripts/model-invariants.mjs
T
Claude a988f7c6f1 test: measure how far the stored geometry sits off the lattice
Stage 0 of ADR #282. A lattice node is k/240, which has no exact binary
representation, and a stored coordinate is a float. Nobody could say how much of
a real plan is affected, and Optimize promises to remove coordinate noise
without a definition of noise that can be checked.

latticeProfile splits every coordinate of the model into three populations,
because they are three different problems: exactly on a node, near a node but
not exact, and legitimately off grid. The middle one is the defect class behind
\#258, \#279 and the non-converging Optimize; the last one is authored geometry
the current model allows and must not be called a violation.

Measured on the owner's installation: space 1 has 208 coordinates, 33.65 per
cent exactly on a node and 65.38 per cent in the noise class; space 2 has 21.23
against 78.77. The worst deviation is 8e-8 of a step — invisible, and enough to
put a wall key in the neighbouring bucket.

The counterpart is what makes it worth having: every shipped fixture has zero
noise, all of its off-grid values being authored. Our own test data therefore
cannot reproduce this class by construction, which is why the owner finds these
defects and the gates do not. A test pins that property so it cannot drift.

No violations are produced, no gate turns red, and nothing is repaired: what to
do with a vertex 8e-8 from a node is the owner's decision, and this measures its
price first.

Issue: #283
User-Visible: no
2026-08-24 10:45:48 +03:00

528 lines
26 KiB
JavaScript
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.
#!/usr/bin/env node
/**
* Инварианты модели плана (#254).
*
* npm run invariants -- --config <файл> # экспорт, config/get или сырой config
* npm run invariants -- --config <файл> --layout <файл>
* npm run invariants -- --config <файл> --json
*
* Зачем это существует. Самый дорогой класс дефектов проекта — не ошибки
* формул, а потеря согласованности между геометрией и ссылками на неё: #253
* (ресайз потерял запись толщины), #244 (маркеры на удалённые пространства),
* #252 (37 забытых позиций в layout), #248, #126. Каждый раз это находил
* человек глазами. Здесь те же вопросы задаются машинно и одинаково.
*
* Structural geometry is the exception: the CLI deliberately imports the
* compiled production preparation instead of maintaining a second boolean
* model which can drift from the renderer (#278).
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { checkOptimizeGeometry } from '../test-build/plan-geometry-preflight.js';
/** Доля шага сетки, в пределах которой запись считается лежащей на ребре. */
const EDGE_TOLERANCE = 0.004;
/** Решётка редактора: та же, что `GRID_N` в `src/space-geometry.ts`. */
const GRID_N = 240;
const GRID_STEP_N = 1 / GRID_N;
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
const point = (value) => (Array.isArray(value) && isFiniteNumber(value[0])
&& isFiniteNumber(value[1]) ? [value[0], value[1]] : null);
const roomPolygon = (room) => {
const poly = Array.isArray(room?.poly) ? room.poly.map(point).filter(Boolean) : [];
if (poly.length >= 3) return poly;
// Прямоугольная запись старого формата — тоже полигон, просто записанный иначе.
if ([room?.x, room?.y, room?.w, room?.h].every(isFiniteNumber)) {
return [[room.x, room.y], [room.x + room.w, room.y],
[room.x + room.w, room.y + room.h], [room.x, room.y + room.h]];
}
return null;
};
const edgesOf = (poly) => poly.map((a, index) => [a, poly[(index + 1) % poly.length]]);
const distToSegment = (p, a, b) => {
const dx = b[0] - a[0], dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
if (len2 < 1e-18) return Math.hypot(p[0] - a[0], p[1] - a[1]);
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
};
/** Все отрезки пространства, на которых законно жить записи толщины. */
const carriers = (space) => {
const out = [];
for (const room of space?.rooms || []) {
const poly = roomPolygon(room);
if (poly) out.push(...edgesOf(poly));
}
for (const partition of space?.partitions || []) {
const a = point(partition?.a), b = point(partition?.b);
if (a && b) out.push([a, b]);
}
return out;
};
const onSomeCarrier = (span, list, tolerance) => list.some(([a, b]) =>
distToSegment(span[0], a, b) <= tolerance && distToSegment(span[1], a, b) <= tolerance);
/**
* Инвариант 2: каждая ссылка указывает на существующий объект.
*
* Каждое нарушение адресуется: что ссылается, куда и чего не нашли. Пустой
* список — не «наверное всё хорошо», а «проверено».
*/
export function checkReferences({ config, layout = {} } = {}, { notes = [] } = {}) {
const violations = [];
const spaces = Array.isArray(config?.spaces) ? config.spaces : [];
const spaceIds = new Set(spaces.map((space) => String(space?.id ?? '')).filter(Boolean));
const markerIds = new Set((config?.markers || [])
.map((marker) => String(marker?.id ?? '')).filter(Boolean));
const roomIdsBySpace = new Map(spaces.map((space) => [String(space?.id ?? ''),
new Set((space?.rooms || []).map((room) => String(room?.id ?? '')).filter(Boolean))]));
const areasBySpace = new Map(spaces.map((space) => [String(space?.id ?? ''),
new Set((space?.rooms || []).map((room) => String(room?.area ?? '')).filter(Boolean))]));
const add = (kind, owner, reference, detail) =>
violations.push({ invariant: 'references', kind, owner, reference, detail });
for (const marker of config?.markers || []) {
if (marker?.removed) continue;
const space = marker?.space == null ? '' : String(marker.space);
if (space && !spaceIds.has(space)) {
add('marker_space', String(marker.id ?? '?'), space, 'пространства не существует');
}
}
for (const [key, position] of Object.entries(layout || {})) {
const space = position?.s == null ? '' : String(position.s);
if (space && !spaceIds.has(space)) {
add('layout_space', key, space, 'пространства не существует');
continue;
}
// Владелец позиции: подпись комнаты, групповая метка области либо маркер.
if (key.startsWith('rl_')) {
const roomId = key.slice(3);
const rooms = roomIdsBySpace.get(space);
if (rooms && !rooms.has(roomId)) {
add('layout_owner', key, roomId, 'комнаты не существует в этом пространстве');
}
continue;
}
if (key.startsWith('grp_')) {
const area = key.slice(4);
const areas = areasBySpace.get(space);
if (areas && areas.size && !areas.has(area)) {
add('layout_owner', key, area, 'в этом пространстве нет комнаты с такой областью');
}
continue;
}
if (!markerIds.has(key)) {
// Позиция может принадлежать устройству HA, у которого ещё нет записи в
// `markers`: маркеры создаются лениво, а позиция сохраняется сразу. По
// одной конфигурации это не отличить от мусора, поэтому — наблюдение, а
// не нарушение. Проверка с ложными срабатываниями умирает первой.
notes.push({ invariant: 'references', kind: 'unknown_owner', owner: key,
reference: key, detail: 'владелец не найден в конфигурации (возможно устройство HA)' });
}
}
for (const space of spaces) {
const spaceId = String(space?.id ?? '?');
const list = carriers(space);
const tolerance = EDGE_TOLERANCE;
for (const wall of space?.walls || []) {
const a = point(wall?.a), b = point(wall?.b);
if (!a || !b) continue; // запись только с ключом — совместимость, не ссылка
if (!list.length || !onSomeCarrier([a, b], list, tolerance)) {
add('wall_carrier', `${spaceId}:${wall?.key ?? '?'}`, `${wall?.cm} см`,
'запись толщины не лежит ни на одном ребре комнаты и ни на одной перегородке');
}
}
for (const span of space?.open_spans || []) {
const a = point(span?.a), b = point(span?.b);
if (!a || !b) continue;
if (!list.length || !onSomeCarrier([a, b], list, tolerance)) {
add('open_span_carrier', `${spaceId}:${span?.id ?? '?'}`, 'open_span',
'виртуальный проём не лежит на границе существующих комнат');
}
}
}
return violations;
}
/**
* Инвариант 1: запись толщины не исчезает.
*
* Сравнивается мультимножество значений `cm`, а не суммарная длина: ресайз
* законно укорачивает стены, а склейка двух одинаковых записей законно
* уменьшает их число. Незаконно ровно одно — исчезновение значения целиком,
* как в #253, где 33 см пропали вместе с кладкой соседних комнат.
*
* `allowClear` — единственное исключение, и оно объявляется вызывающим:
* пользователь очистил толщину явно.
*/
export function checkWallRecordsPreserved(before, after, { allowClear = false } = {}) {
if (allowClear) return [];
const counts = (walls) => {
const map = new Map();
for (const wall of walls || []) {
if (!isFiniteNumber(wall?.cm) || !(wall.cm > 0)) continue;
const key = String(wall.cm);
map.set(key, (map.get(key) || 0) + 1);
}
return map;
};
const from = counts(before), to = counts(after);
const violations = [];
for (const [cm, was] of from) {
const now = to.get(cm) || 0;
if (now === 0) {
violations.push({
invariant: 'wall_records', kind: 'lost', owner: `${cm} см`,
reference: `было ${was}`, detail: 'записи этой толщины исчезли целиком',
});
}
}
return violations;
}
/**
* Копия ключа отрезка из `src/wall-thickness.ts`.
*
* Дублировать формулу приходится: модуль сознательно читает сырой JSON без
* сборки, а `wallKey` живёт в TypeScript. Дубль величины, видимой в двух
* местах, — ровно тот дефект, который проект ловил трижды (#233, #234, #258),
* поэтому копия прикреплена тестом: `test/model-invariants.test.mjs` берёт
* настоящий `wallKey` из `test-build` и сверяет с этой копией на наборе
* отрезков, включая попадающие в ничью округления. Разойдутся — покраснеет.
*/
const quantise = (value, pitch) => (!(pitch > 0) || !Number.isFinite(value)
? value : Math.round(value / pitch) * pitch);
const keyEpsilon = (pitch) => Math.max(Math.abs(pitch) * 1e-6, 1e-9);
const canonicalKeyCoordinate = (value, pitch) => {
if (!(pitch > 0) || !Number.isFinite(value)) return value;
const snapped = quantise(value, pitch);
return Math.abs(snapped - value) <= keyEpsilon(pitch) ? snapped : value;
};
const segmentDirection = (a, b) => {
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy);
if (length < 1e-12) return [1, 0];
dx /= length; dy /= length;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) return [-dx, -dy];
return [dx, dy];
};
export function wallKey(a, b, pitch = GRID_STEP_N) {
const ca = [canonicalKeyCoordinate(a[0], pitch), canonicalKeyCoordinate(a[1], pitch)];
const cb = [canonicalKeyCoordinate(b[0], pitch), canonicalKeyCoordinate(b[1], pitch)];
const mx = quantise((ca[0] + cb[0]) / 2, pitch);
const my = quantise((ca[1] + cb[1]) / 2, pitch);
const [dx, dy] = segmentDirection(ca, cb);
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
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;
}
/**
* Диагностика совместимого ключа записи толщины (#258, #259).
*
* После #258 точная пара `a/b` является строгой идентичностью того же span и
* разрешается до legacy midpoint fallback. Поэтому любой отличный или даже
* неразбираемый compatibility key у записи с валидными endpoints — наблюдение,
* а не нарушение: runtime найдёт запись по endpoints, а явный Optimize
* перепишет стабильный key. Legacy key-only запись проверить и исправить по
* догадке нельзя, поэтому она по-прежнему пропускается.
*/
export function checkWallKeys(config, { notes = [] } = {}) {
const violations = [];
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(a, b);
if (wall.key === expected) continue;
const owner = `${spaceId}:${wall.key}`;
const stored = keyMidpoint(wall.key);
const drift = stored
? Math.hypot(stored[0] - (a[0] + b[0]) / 2, stored[1] - (a[1] + b[1]) / 2)
/ GRID_STEP_N
: null;
const driftText = drift === null ? 'ключ не разбирается как координаты'
: `середина отличается на ${drift.toFixed(2)} шага`;
notes.push({ invariant: 'wall_keys', kind: 'stale_wall_key', owner,
reference: expected,
detail: `${driftText}; запись находится по точной паре endpoints,`
+ ' явный Optimize перепишет совместимый ключ' });
}
}
return violations;
}
/**
* Стадия 0 из ADR #282: профиль отклонений от решётки.
*
* Весь класс #258/#279/#248 держится на одном свойстве представления: узел
* решётки это `k/240`, число без точного двоичного представления, а хранимая
* координата это float. Сколько реальной геометрии лежит вне решётки и
* насколько далеко — не знает никто, а Optimize берётся «убрать шум
* координат», не имея проверяемого определения шума.
*
* Здесь ничего не судится и не чинится: считается профиль. Три населения
* разделены намеренно, потому что это разные проблемы, а не разные степени
* одной:
*
* exact — точно на узле, делать нечего;
* noise — рядом с узлом, но не точно. ЭТО класс дефектов: ключ стены
* попадает не в тот бакет, Optimize не сходится, «почти
* ортогональная» стена рисует клин;
* offGrid — далеко от узла. Законная по нынешней модели геометрия: авторские
* координаты фикстур (0.06 — это 14.4 шага от узла) ничем не
* запрещены. Объявить их нарушением значит получить проверку,
* которую отключат в первую неделю.
*
* Граница между noise и offGrid — доля шага, а не абсолют: `NOISE_STEPS`.
* Взята на четыре порядка ниже шага, то есть заведомо ниже любого осмысленного
* пользовательского ввода и заведомо выше двоичного мусора одиночной операции.
*/
const NOISE_STEPS = 1e-4;
const latticeDeviation = (value) => {
const steps = value * GRID_N;
return Math.abs(steps - Math.round(steps));
};
/** Все координаты модели с адресом, по которому их можно найти глазами. */
function* modelCoordinates(config, layout = {}) {
for (const space of Array.isArray(config?.spaces) ? config.spaces : []) {
const spaceId = String(space?.id ?? '?');
for (const room of space?.rooms || []) {
const poly = Array.isArray(room?.poly) ? room.poly : [];
for (const [index, p] of poly.entries()) {
const point = pointOf(p);
if (point) yield { kind: 'room', owner: `${spaceId}:${room?.id ?? '?'}#${index}`, point };
}
for (const field of ['x', 'y', 'w', 'h']) {
if (isFiniteNumber(room?.[field])) {
yield { kind: 'room', owner: `${spaceId}:${room?.id ?? '?'}.${field}`, point: [room[field], room[field]] };
}
}
}
for (const part of space?.partitions || []) {
for (const end of ['a', 'b']) {
const point = pointOf(part?.[end]);
if (point) yield { kind: 'partition', owner: `${spaceId}:${part?.id ?? '?'}.${end}`, point };
}
}
for (const wall of space?.walls || []) {
for (const end of ['a', 'b']) {
const point = pointOf(wall?.[end]);
if (point) yield { kind: 'wall', owner: `${spaceId}:${wall?.key ?? '?'}.${end}`, point };
}
}
for (const span of space?.open_spans || []) {
for (const end of ['a', 'b']) {
const point = pointOf(span?.[end]);
if (point) yield { kind: 'open_span', owner: `${spaceId}:${span?.id ?? '?'}.${end}`, point };
}
}
for (const column of space?.wall_columns || []) {
const point = pointOf(column?.center);
if (point) yield { kind: 'column', owner: `${spaceId}:${column?.id ?? '?'}`, point };
}
}
for (const [key, position] of Object.entries(layout || {})) {
const point = pointOf([position?.x, position?.y]);
if (point) yield { kind: 'layout', owner: key, point };
}
}
const pointOf = (value) => (Array.isArray(value) && isFiniteNumber(value[0])
&& isFiniteNumber(value[1]) ? [value[0], value[1]] : null);
/**
* Профиль модели: сколько координат точно на узле, сколько в шуме, сколько
* законно вне сетки. Ни одного нарушения не возвращается — по построению.
*/
export function latticeProfile({ config, layout = {} } = {}) {
const buckets = { exact: 0, noise: 0, offGrid: 0 };
const byKind = new Map();
let worstNoise = null;
let total = 0;
for (const { kind, owner, point } of modelCoordinates(config, layout)) {
for (const [axis, value] of [['x', point[0]], ['y', point[1]]]) {
const deviation = latticeDeviation(value);
const bucket = deviation === 0 ? 'exact' : deviation < NOISE_STEPS ? 'noise' : 'offGrid';
total++;
buckets[bucket]++;
const seen = byKind.get(kind) || { exact: 0, noise: 0, offGrid: 0 };
seen[bucket]++;
byKind.set(kind, seen);
if (bucket === 'noise' && (!worstNoise || deviation > worstNoise.steps)) {
worstNoise = { kind, owner, axis, value, steps: deviation };
}
}
}
return {
total,
...buckets,
noiseSteps: NOISE_STEPS,
worstNoise,
byKind: Object.fromEntries([...byKind].map(([kind, counts]) => [kind, counts])),
};
}
/** Разобрать любой из трёх форматов, в которых приходит конфигурация. */
export function readModel(text) {
const parsed = JSON.parse(text);
const config = parsed?.payload?.config ?? parsed?.result?.config ?? parsed?.config ?? parsed;
const layout = parsed?.payload?.layout ?? parsed?.result?.layout ?? parsed?.layout ?? {};
return { config, layout };
}
/** Production structural pass with bounded, anonymised diagnostics. */
export function checkPhysicalGeometry(config) {
let result;
try { result = checkOptimizeGeometry(config); } catch {
return [{
invariant: 'physical_geometry', kind: 'geometry_exception', owner: 'config',
reference: 'prepare-exception', detail: 'production geometry check failed',
}];
}
return result.failures.map((failure, index) => ({
invariant: 'physical_geometry', kind: 'physical_geometry',
owner: `space[${index + 1}]`, reference: failure.reason,
detail: 'canonical wall geometry is not safe for a write',
}));
}
/** Наблюдения перечисляются по смыслу: «их 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: 'записей толщины используют exact endpoints вместо своего ключа',
};
return [...counts].map(([kind, n]) => `${n} — ${titles[kind] || kind}`).join('; ') + '.';
}
/** Профиль решётки на языке решения, а не на языке счётчиков (ADR #282). */
function latticeReport(profile) {
const share = (n) => (profile.total ? `${(n / profile.total * 100).toFixed(2)}%` : '—');
const lines = [
`Координат в модели: ${profile.total}.`,
'',
` точно на узле ${profile.exact} (${share(profile.exact)})`,
` шум у узла ${profile.noise} (${share(profile.noise)})`
+ ` — ближе ${profile.noiseSteps} шага, но не точно`,
` законно вне сетки ${profile.offGrid} (${share(profile.offGrid)})`,
'',
];
if (profile.worstNoise) {
const w = profile.worstNoise;
lines.push(`Худший шум: ${w.owner} по ${w.axis} = ${w.value}`
+ ` — ${w.steps.toExponential(2)} шага от узла.`, '');
}
lines.push('По видам объектов (точно / шум / вне сетки):');
for (const [kind, counts] of Object.entries(profile.byKind)) {
lines.push(` ${kind.padEnd(11)} ${counts.exact} / ${counts.noise} / ${counts.offGrid}`);
}
lines.push('', 'Шум — это класс дефектов #258/#279/#248: ключ стены попадает не в тот',
'бакет, Optimize не сходится. «Вне сетки» — законная геометрия нынешней модели.',
'Стадия 0 ADR #282 измеряет, а не судит: нарушений здесь не бывает.');
return lines.join('\n');
}
function report(violations, notes = []) {
if (!violations.length) {
const tail = notes.length
? `\nНаблюдений (не нарушения): ${notes.length}. ` + noteSummary(notes)
: '';
return 'Инварианты выполнены: ссылки разрешимы, записи толщины находятся.'
+ tail;
}
const lines = [`Нарушений: ${violations.length}.`, ''];
const byKind = new Map();
for (const violation of violations) {
const list = byKind.get(violation.kind) || [];
list.push(violation);
byKind.set(violation.kind, list);
}
const titles = {
marker_space: 'Маркеры ссылаются на несуществующие пространства',
layout_space: 'Позиции ссылаются на несуществующие пространства',
layout_owner: 'Позиции без владельца',
wall_carrier: 'Записи толщины вне рёбер и перегородок',
open_span_carrier: 'Виртуальные проёмы вне границ комнат',
physical_geometry: 'Небезопасная каноническая геометрия стен',
geometry_exception: 'Сбой проверки канонической геометрии стен',
lost: 'Потерянные записи толщины',
wall_key: 'Записи толщины, которые не найдутся по ключу',
};
for (const [kind, list] of byKind) {
lines.push(`${titles[kind] || kind}: ${list.length}`);
for (const violation of list.slice(0, 12)) {
lines.push(` ${violation.owner} → ${violation.reference}: ${violation.detail}`);
}
if (list.length > 12) lines.push(` и ещё ${list.length - 12}`);
lines.push('');
}
return lines.join('\n').trimEnd();
}
function main(argv) {
const arg = (name) => {
const index = argv.indexOf(name);
return index >= 0 ? argv[index + 1] : undefined;
};
const configPath = arg('--config');
if (!configPath) {
console.error('использование: model-invariants.mjs --config <файл> [--layout <файл>]'
+ ' [--lattice] [--json]');
return 2;
}
const model = readModel(readFileSync(configPath, 'utf8'));
if (arg('--layout')) model.layout = readModel(readFileSync(arg('--layout'), 'utf8')).layout;
const notes = [];
if (argv.includes('--lattice')) {
const profile = latticeProfile(model);
if (argv.includes('--json')) console.log(JSON.stringify({ lattice: profile }, null, 2));
else console.log(latticeReport(profile));
return 0;
}
const violations = [
...checkReferences(model, { notes }),
...checkWallKeys(model.config, { notes }),
...checkPhysicalGeometry(model.config),
];
if (argv.includes('--json')) console.log(JSON.stringify({ violations, notes }, null, 2));
else console.log(report(violations, notes));
// Код возврата — не приговор конфигурации пользователя, а сигнал для CI.
return violations.length ? 1 : 0;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
process.exit(main(process.argv.slice(2)));
}