Files
houseplan-card/scripts/model-invariants.mjs
T
Claude 523190d8f4
Validate / docs (push) Failing after 26s
Validate / process-workflow-sync (push) Successful in 35s
Validate / provenance (push) Successful in 46s
Validate / changes (push) Successful in 31s
Validate / process-gate (push) Failing after 45s
Validate / reuse (push) Successful in 46s
Validate / hacs (push) Failing after 16s
Validate / hassfest (push) Failing after 18s
Validate / frontend (push) Successful in 9m19s
Validate / backend (push) Failing after 9m31s
Validate / smoke (1) (push) Failing after 17m56s
Validate / smoke (3) (push) Failing after 17m52s
Validate / smoke (2) (push) Failing after 18m9s
Validate / smoke_done (push) Skipped
Validate / golden (push) Failing after 23m10s
Validate / performance_smoke (push) Failing after 51m44s
test: one thickness record must not describe two wall roles
The continuity gate cannot see the defect from the owner's 66.json: masonry is
continuous there and the record agrees with what is painted. The record itself
is wrong — a partial resize left 43 steps of a former shared boundary as an
exterior wall while it kept the 20 cm of that boundary, next to 30 cm exterior
neighbours. Thickness followed the key, not the role of the edge.

A width check would not have caught it, and I built one before throwing it away.
It compares the painted body against the record, and here the two agree. On real
plans it also fires where masonry is legitimately wider — columns, junction
influence, abutting parallel walls: 76 to 82 steps measured against 4 expected,
every case legal. A gate that needs explaining half the time is noise.

The defect is expressible in a single state instead: one record whose span is
partly shared and partly exterior. Nobody sets that on purpose. Roles come from
the polygons — a stretch is shared when another room's edge covers it — so the
check needs neither a build nor product code.

Two traps found by measurement, both of which produced false positives. Count
distinct rooms rather than edges: in a corner one room owns two edges, and
counting edges called every exterior corner a shared boundary — 12 and 14 false
positives. And do not sample the endpoints: an endpoint is a node, where a wall
legitimately touches two rooms, and including them reported 95 per cent exterior
on every wall abutting a shared one.

Mutation coverage, stated honestly: the endpoint mutant is killed by the
real-plan test. The rooms-versus-edges mutant survived — once endpoints are
excluded, counting edges gives the same answer on real plans, so that choice is
not load-bearing. I removed the mutant rather than ship a surviving one, and said
so in the code.

Issue: #287
User-Visible: no
2026-08-24 13:20:34 +03:00

617 lines
31 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])),
};
}
/**
* Инвариант 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;
}
/** Разобрать любой из трёх форматов, в которых приходит конфигурация. */
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: 'Записи толщины, которые не найдутся по ключу',
mixed_role_record: 'Записи толщины, описывающие сразу общую и наружную стену',
};
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 }),
...checkMixedRoleRecords(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)));
}