Files
houseplan-card/scripts/model-invariants.mjs
T
Codex 1c598a287a fix: the room_wall_ids invariant applies to model v8 and later again (#316, review r4 H1)
The r3-M1 fix was applied as a mechanical substring replacement and flipped
BOTH model_version conditions in the file; the independent room_wall_ids
invariant (#244/#252) silently stopped checking every v9+ document. Only the
opening_host requirement is scoped to model v8 — room_wall_ids is back to
'v8 and every later version', now pinned by its first regression test
(phantom wall_ids reported on v9 and v8; executed red on the broken
comparison, green after the fix).

Issue: #316
User-Visible: no
2026-08-27 00:21:23 +03:00

899 lines
45 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 <файл> --near-axis --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';
import { classifyNearAxisSegment } from '../test-build/near-axis.js';
import {
LATTICE_GRID_N as GRID_N,
LATTICE_NOISE_STEPS as NOISE_STEPS,
} from '../test-build/coordinate-canonicalization.js';
/** Доля шага сетки, в пределах которой запись считается лежащей на ребре. */
const EDGE_TOLERANCE = 0.004;
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]]);
/** Deduplicated physical near-axis segments, including both room-owner copies. */
export function nearAxisProfile(config) {
const spaces = [];
let total = 0;
const keyOf = (a, b) => {
const ka = `${a[0]},${a[1]}`, kb = `${b[0]},${b[1]}`;
return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
};
for (const space of config?.spaces || []) {
const found = new Set();
for (const room of space?.rooms || []) {
const poly = roomPolygon(room);
if (!poly) continue;
for (const [a, b] of edgesOf(poly)) {
if (classifyNearAxisSegment(a, b)) found.add(keyOf(a, b));
}
}
for (const draft of space?.room_drafts || []) {
for (let index = 0; index + 1 < (draft?.points || []).length; index++) {
const a = point(draft.points[index]), b = point(draft.points[index + 1]);
if (a && b && classifyNearAxisSegment(a, b)) found.add(`draft:${draft.id}:${index}`);
}
}
for (const partition of space?.partitions || []) {
const a = point(partition?.a), b = point(partition?.b);
if (a && b && classifyNearAxisSegment(a, b)) {
found.add(`partition:${partition.id || keyOf(a, b)}`);
}
}
const count = found.size;
spaces.push({ spaceId: String(space?.id || ''), count });
total += count;
}
return { total, spaces };
}
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 activeMarkerIds = new Set((config?.markers || [])
.filter((marker) => marker?.removed !== true)
.map((marker) => String(marker?.id ?? '')).filter(Boolean));
const activeLightMarkerIds = new Set((config?.markers || [])
.filter((marker) => marker?.removed !== true && marker?.is_light === true)
.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 markerId = String(marker.id ?? '?');
const space = marker?.space == null ? '' : String(marker.space);
if (space && !spaceIds.has(space)) {
add('marker_space', markerId, space, 'пространства не существует');
}
const room = marker?.room_id == null ? '' : String(marker.room_id);
if (room && spaceIds.has(space) && !roomIdsBySpace.get(space)?.has(room)) {
add('marker_room', markerId, room, 'комнаты не существует в пространстве маркера');
}
const segments = marker?.vacuum?.segment_map;
if (segments && typeof segments === 'object' && !Array.isArray(segments)) {
for (const [segment, value] of Object.entries(segments)) {
const roomId = String(value ?? '');
if (roomId && spaceIds.has(space) && !roomIdsBySpace.get(space)?.has(roomId)) {
add('vacuum_room', `${markerId}:${segment}`, roomId,
'комнаты сегмента не существует в пространстве маркера');
}
}
}
for (const value of Array.isArray(marker?.controls) ? marker.controls : []) {
if (typeof value !== 'string' || !value.startsWith('marker:')) continue;
const target = value.slice('marker:'.length);
if (!activeLightMarkerIds.has(target)) {
add('marker_control', markerId, target,
activeMarkerIds.has(target)
? 'маркер-цель не является источником света'
: 'активного маркера-цели не существует');
}
}
const badgeSource = marker?.value_badge?.source;
if (badgeSource?.kind === 'derived_marker_state') {
const ref = String(badgeSource.ref ?? '');
const target = ref.startsWith('marker:') ? ref.slice('marker:'.length) : '';
if (!target || !activeLightMarkerIds.has(target)) {
add('marker_badge', markerId, ref || '?',
target && activeMarkerIds.has(target)
? 'маркер-источник не является источником света'
: 'активного маркера-источника не существует');
}
}
}
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 roomIds = roomIdsBySpace.get(spaceId) || new Set();
const wallSegments = new Map((space?.wall_segments || [])
.map((segment) => [String(segment?.id ?? ''), segment]).filter(([id]) => id));
for (const room of space?.rooms || []) {
const roomId = String(room?.id ?? '?');
for (const target of Array.isArray(room?.open_to) ? room.open_to : []) {
const targetId = String(target ?? '');
if (targetId && !roomIds.has(targetId)) {
add('room_open_to', `${spaceId}:${roomId}`, targetId,
'комнаты назначения не существует в том же пространстве');
}
}
if (Number(config?.model_version || 0) >= 8) {
const wallIds = Array.isArray(room?.wall_ids) ? room.wall_ids : [];
const poly = roomPolygon(room) || [];
if (wallIds.length !== poly.length) {
add('room_wall_ids', `${spaceId}:${roomId}`, String(wallIds.length),
`ожидалось по одному id для ${poly.length} рёбер`);
}
for (const target of wallIds) {
const targetId = String(target ?? '');
if (!targetId || !wallSegments.has(targetId)) {
add('room_wall_ids', `${spaceId}:${roomId}`, targetId || '?',
'сегмента стены не существует в том же пространстве');
}
}
}
}
const partitionIds = new Set((space?.partitions || [])
.map((partition) => String(partition?.id ?? '')).filter(Boolean));
for (const opening of space?.openings || []) {
const host = opening?.host;
if (!host) {
// #316 §3.3: since model v9 an unhosted contour opening is a valid
// degraded state (the migration keeps an opening with no in-place
// carrier as data, inert until re-placed). Only model v8 documents
// still require an explicit host on every opening.
if (Number(config?.model_version || 0) === 8) {
add('opening_host', `${spaceId}:${opening?.id ?? '?'}`, '?',
'в model v8 у проёма нет явной стены-хоста');
}
continue;
}
const target = String(host.id ?? '');
const exists = host?.kind === 'partition'
? partitionIds.has(target)
: host?.kind === 'wall' && wallSegments.has(target);
if (!target || !exists) {
add('opening_host', `${spaceId}:${opening?.id ?? '?'}`, target || '?',
'стены-хоста не существует в том же пространстве');
}
}
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 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 };
}
const origin = pointOf([room?.x, room?.y]);
if (origin) yield { kind: 'room', owner: `${spaceId}:${room?.id ?? '?'}.origin`, point: origin };
const size = pointOf([room?.w, room?.h]);
if (size) yield { kind: 'room', owner: `${spaceId}:${room?.id ?? '?'}.size`, point: size };
}
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 opening of space?.openings || []) {
const point = pointOf([opening?.x, opening?.y]);
if (point) yield { kind: 'opening', owner: `${spaceId}:${opening?.id ?? '?'}`, point };
}
for (const draft of space?.room_drafts || []) {
for (const [index, value] of (draft?.points || []).entries()) {
const point = pointOf(value);
if (point) yield { kind: 'room_draft', owner: `${spaceId}:${draft?.id ?? '?'}#${index}`, point };
}
}
for (const decor of space?.decor || []) {
const id = `${spaceId}:${decor?.id ?? '?'}`;
if (decor?.kind === 'line') {
const a = pointOf([decor?.x1, decor?.y1]);
const b = pointOf([decor?.x2, decor?.y2]);
if (a) yield { kind: 'decor', owner: `${id}.a`, point: a };
if (b) yield { kind: 'decor', owner: `${id}.b`, point: b };
} else if (['rect', 'ellipse', 'furniture'].includes(decor?.kind)) {
const origin = pointOf([decor?.x, decor?.y]);
const size = pointOf([decor?.w, decor?.h]);
if (origin) yield { kind: 'decor', owner: `${id}.origin`, point: origin };
if (size) yield { kind: 'decor', owner: `${id}.size`, point: size };
} else if (decor?.kind === 'text') {
const point = pointOf([decor?.x, decor?.y]);
if (point) yield { kind: 'decor', owner: 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 offGridValues = new Set();
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';
if (bucket === 'offGrid') offGridValues.add(Number(value).toFixed(12));
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,
offGridUnique: offGridValues.size,
noiseSteps: NOISE_STEPS,
worstNoise,
byKind: Object.fromEntries([...byKind].map(([kind, counts]) => [kind, counts])),
};
}
/**
* Инвариант 4: запись толщины не описывает сразу общую и наружную стену (#287).
*
* Откуда это взялось. Владелец прислал пару экспортов до и после ресайза: одна
* комната из общей пары сдвинулась на 43 шага, вторая осталась. Нижние 43 шага
* стены перестали быть общими — стали наружными, — но продолжают нести 20 см
* бывшей общей границы, тогда как соседние наружные несут 30 см. Толщина
* сохранена ПО КЛЮЧУ, а не по роли ребра.
*
* Проверить это можно в одном состоянии, без пары «до и после»: признак —
* **одна запись толщины, чей пролёт частью общий, а частью наружный**. Такую
* запись пользователь не мог задать осознанно: он назначал толщину границе
* между двумя комнатами либо наружной стене, но не обоим сразу.
*
* Роль считается из полигонов и не требует ни сборки, ни продуктового кода:
* участок общий, если его накрывает ребро другой комнаты.
*/
const SHARE_TOLERANCE = 1e-6;
const segmentsOfSpace = (space) => {
const out = [];
for (const room of space?.rooms || []) {
const poly = roomPolygon(room);
if (!poly) continue;
for (const [a, b] of edgesOf(poly)) out.push({ room: String(room?.id ?? '?'), a, b });
}
return out;
};
/** Доля пролёта записи, накрытая ребром ДРУГОЙ комнаты, по точкам выборки. */
const roleProfile = (wall, segments, samples = 41) => {
const a = point(wall?.a), b = point(wall?.b);
if (!a || !b) return null;
const dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy);
if (!(length > SHARE_TOLERANCE)) return null;
let shared = 0;
let outer = 0;
// Концы записи не выбираются: конец — это узел, а не участок. В узле стена
// законно касается рёбер двух комнат, и включение концов давало «95%
// наружного» на каждой наружной стене, упирающейся в общую.
for (let i = 1; i < samples; i++) {
const t = i / samples;
const p = [a[0] + dx * t, a[1] + dy * t];
// Считаются РАЗНЫЕ комнаты, а не рёбра: в углу одной комнаты точка лежит
// сразу на двух её рёбрах. Проверено мутантом: после исключения концов
// (ниже) подсчёт рёбер на реальных планах даёт тот же результат, то есть
// сам по себе этот выбор не несущий — он оставлен как смысловая страховка,
// а не как то, чем держится проверка. Мутанта на него не ставлю: он
// выживает, а выживающий мутант хуже отсутствующего.
const owners = new Set();
for (const segment of segments) {
if (distToSegment(p, segment.a, segment.b) <= SHARE_TOLERANCE) owners.add(segment.room);
}
if (owners.size >= 2) shared++;
else if (owners.size === 1) outer++;
}
return { shared, outer, samples: samples - 1 };
};
export function checkMixedRoleRecords(config) {
const violations = [];
for (const space of Array.isArray(config?.spaces) ? config.spaces : []) {
const spaceId = String(space?.id ?? '?');
const segments = segmentsOfSpace(space);
if (!segments.length) continue;
for (const wall of space?.walls || []) {
const profile = roleProfile(wall, segments);
if (!profile) continue;
// Оба вида в одной записи, и ни один не является краевым шумом выборки.
const edge = Math.max(2, Math.round(profile.samples * 0.05));
if (profile.shared >= edge && profile.outer >= edge) {
const outerShare = profile.outer / profile.samples;
violations.push({
invariant: 'wall_roles', kind: 'mixed_role_record',
owner: `${spaceId}:${wall?.key ?? '?'}`,
reference: `${wall?.cm} см`,
detail: `пролёт записи частью общий, частью наружный`
+ ` (${(outerShare * 100).toFixed(0)}% наружного) — толщина сохранена`
+ ' по ключу, а не по роли ребра',
});
}
}
}
return violations;
}
/**
* Геометрия, которая ничего не рисует, но выключает ручки ресайза (#296).
*
* Зачем отдельная проверка. Перегородка, лежащая ровно под стеной комнаты, и
* черновик из двух точек не видны на плане и не портят ни один снимок модели —
* ни ключи, ни роли, ни решётку, ни кладку. При этом `resolveSafeResize`
* законно отказывает по ним `duplicate-physical-wall`, и пользователь получает
* выключенную ручку с подсказкой про объект, который нельзя ни увидеть, ни
* выделить, ни удалить. Ни один прежний гейт этого класса не видит: все они
* измеряют снимок, а эта геометрия снимок не портит.
*
* Судится только доказуемое: перекрытие по длине больше шага решётки — то есть
* не касание углом, — и черновик, который не может стать комнатой ни при какой
* последующей правке.
*/
const collinearOverlapN = (a, b, c, d) => {
const dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy);
if (!(length > GRID_STEP_N)) return 0;
const ux = dx / length, uy = dy / length;
const across = (p) => Math.abs((p[0] - a[0]) * uy - (p[1] - a[1]) * ux);
if (across(c) > EDGE_TOLERANCE || across(d) > EDGE_TOLERANCE) return 0;
const along = (p) => (p[0] - a[0]) * ux + (p[1] - a[1]) * uy;
const lo = Math.max(0, Math.min(along(c), along(d)));
const hi = Math.min(length, Math.max(along(c), along(d)));
return Math.max(0, hi - lo);
};
/** Точки черновика как есть: замыкание контура здесь не предполагается. */
const draftSegments = (draft) => {
const points = Array.isArray(draft?.points) ? draft.points.map(point).filter(Boolean) : [];
const segments = [];
for (let i = 0; i + 1 < points.length; i++) segments.push([points[i], points[i + 1]]);
return { points, segments };
};
export function checkHiddenObstacles(config) {
const violations = [];
for (const space of Array.isArray(config?.spaces) ? config.spaces : []) {
const spaceId = String(space?.id ?? '?');
const edges = [];
for (const room of space?.rooms || []) {
const poly = roomPolygon(room);
if (!poly) continue;
for (const [a, b] of edgesOf(poly)) edges.push({ a, b, room: String(room?.id ?? '?') });
}
const longestOverlap = (a, b) => {
let best = null;
for (const edge of edges) {
const overlap = collinearOverlapN(edge.a, edge.b, a, b);
if (overlap > GRID_STEP_N && (!best || overlap > best.overlap)) {
best = { overlap, room: edge.room };
}
}
return best;
};
for (const partition of space?.partitions || []) {
const a = point(partition?.a), b = point(partition?.b);
if (!a || !b) continue;
const hit = longestOverlap(a, b);
if (!hit) continue;
violations.push({
invariant: 'hidden_obstacles', kind: 'partition_over_room_wall',
owner: `${spaceId}:${partition?.id ?? '?'}`,
reference: `${(hit.overlap / GRID_STEP_N).toFixed(0)} шагов по стене ${hit.room}`,
detail: 'перегородка лежит на стене комнаты: на плане её не видно,'
+ ' а ресайз этой стены она выключает',
});
}
for (const draft of space?.room_drafts || []) {
const { points, segments } = draftSegments(draft);
if (points.length < 3) {
violations.push({
invariant: 'hidden_obstacles', kind: 'unusable_draft',
owner: `${spaceId}:${draft?.id ?? '?'}`,
reference: `${points.length} точки`,
detail: 'контур не может стать комнатой ни при какой правке,'
+ ' но препятствием для ресайза остаётся',
});
continue;
}
for (const [a, b] of segments) {
const hit = longestOverlap(a, b);
if (!hit) continue;
violations.push({
invariant: 'hidden_obstacles', kind: 'draft_over_room_wall',
owner: `${spaceId}:${draft?.id ?? '?'}`,
reference: `${(hit.overlap / GRID_STEP_N).toFixed(0)} шагов по стене ${hit.room}`,
detail: 'незакрытый контур лежит на стене комнаты и выключает её ресайз',
});
break;
}
}
}
return violations;
}
/** Разобрать runtime-ответы, сырой config и tracked single-space fixtures. */
export function readModel(text) {
const parsed = JSON.parse(text);
const source = parsed?.payload?.config ?? parsed?.result?.config ?? parsed?.config ?? parsed;
const config = !Array.isArray(source?.spaces) && parsed?.space
? { spaces: [parsed.space] }
: source;
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: 'Маркеры ссылаются на несуществующие пространства',
marker_room: 'Маркеры ссылаются на несуществующие комнаты',
vacuum_room: 'Сегменты пылесоса ссылаются на несуществующие комнаты',
marker_control: 'Управление светом ссылается на несовместимый маркер',
marker_badge: 'Бейдж значения ссылается на несовместимый маркер',
room_open_to: 'Связи комнат ссылаются на несуществующие комнаты',
opening_host: 'Проёмы ссылаются на несуществующие стены',
room_wall_ids: 'Комнаты ссылаются на несуществующие сегменты стен',
layout_space: 'Позиции ссылаются на несуществующие пространства',
layout_owner: 'Позиции без владельца',
wall_carrier: 'Записи толщины вне рёбер и перегородок',
open_span_carrier: 'Виртуальные проёмы вне границ комнат',
physical_geometry: 'Небезопасная каноническая геометрия стен',
geometry_exception: 'Сбой проверки канонической геометрии стен',
lost: 'Потерянные записи толщины',
wall_key: 'Записи толщины, которые не найдутся по ключу',
mixed_role_record: 'Записи толщины, описывающие сразу общую и наружную стену',
partition_over_room_wall: 'Перегородки, лежащие на стенах комнат',
draft_over_room_wall: 'Незакрытые контуры, лежащие на стенах комнат',
unusable_draft: 'Черновики, которые не могут стать комнатой',
};
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|--near-axis] [--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;
}
if (argv.includes('--near-axis')) {
const profile = nearAxisProfile(model.config);
if (argv.includes('--json')) console.log(JSON.stringify({ nearAxis: profile }, null, 2));
else {
console.log(`Почти осевых физических стен: ${profile.total}.`);
for (const space of profile.spaces.filter((item) => item.count)) {
console.log(` ${space.spaceId || '(без id)'}: ${space.count}`);
}
}
return 0;
}
const violations = [
...checkReferences(model, { notes }),
...checkWallKeys(model.config, { notes }),
...checkMixedRoleRecords(model.config),
...checkHiddenObstacles(model.config),
...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)));
}