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
This commit is contained in:
Claude
2026-08-24 10:45:48 +03:00
parent d73790aaab
commit a988f7c6f1
2 changed files with 220 additions and 2 deletions
+148 -1
View File
@@ -279,6 +279,119 @@ export function checkWallKeys(config, { notes = [] } = {}) {
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);
@@ -314,6 +427,33 @@ function noteSummary(notes) {
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
@@ -358,12 +498,19 @@ function main(argv) {
};
const configPath = arg('--config');
if (!configPath) {
console.error('использование: model-invariants.mjs --config <файл> [--layout <файл>] [--json]');
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 }),
+72 -1
View File
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
import {
checkReferences, checkWallKeys, checkWallRecordsPreserved, keyMidpoint,
readModel, wallKey,
latticeProfile, readModel, wallKey,
} from '../scripts/model-invariants.mjs';
import { wallKey as productWallKey } from '../test-build/wall-thickness.js';
import { GRID_STEP_N } from '../test-build/space-geometry.js';
@@ -298,3 +298,74 @@ test('near-grid key normalization removes last-bit grading (#258, #259)', () =>
assert.deepEqual(checkWallKeys({ spaces: [{ id: 'sp2', cell_cm: 1, walls }] }, { notes }), []);
assert.deepEqual(notes, []);
});
// ------------------- стадия 0 ADR #282: профиль решётки ----------------------
// Мера, а не приговор. Нарушений здесь не бывает по построению: авторская
// координата вне сетки законна, а «шум» — это отдельное население, из которого
// растут #258, #279 и несходящийся Optimize.
const oneRoom = (poly) => ({
config: { spaces: [{ id: 'sp1', cell_cm: 5, rooms: [{ id: 'r1', poly }] }] },
layout: {},
});
test('профиль различает узел, шум и законную геометрию вне сетки (#282)', () => {
// 83/240 выбрано не случайно: это та самая вершина с дачи владельца. В
// двоичном виде она 0.34583333333333333, в хранилище лежит как 0.345833333 —
// и эти 8e-8 шага перебросили ключ стены в соседний бакет в #258. Узел вида
// 24/240 = 0.1 для примера не годится: он выживает округление до девяти
// знаков без изменений, и «шума» на нём не получить.
const node = 83 / 240;
const noise = Number((83 / 240).toFixed(9));
assert.notEqual(node, noise, 'выбранный узел обязан терять точность при записи');
const offGrid = 0.06; // 14.4 шага от узла — авторская координата
const profile = latticeProfile(oneRoom([[node, node], [noise, noise], [offGrid, offGrid]]));
assert.equal(profile.total, 6);
assert.equal(profile.exact, 2, 'узел обязан считаться точным');
assert.equal(profile.noise, 2, '9 знаков от того же узла — это шум, а не узел');
assert.equal(profile.offGrid, 2, '0.06 — законная геометрия, а не дефект');
assert.equal(profile.worstNoise.kind, 'room');
assert.ok(profile.worstNoise.steps > 0 && profile.worstNoise.steps < profile.noiseSteps);
});
test('профиль видит все виды объектов, а не только комнаты (#282)', () => {
const off = 0.0605;
const profile = latticeProfile({
config: { spaces: [{
id: 'sp1',
rooms: [{ id: 'r1', poly: [[off, off]] }],
partitions: [{ id: 'p1', a: [off, off], b: [off, off] }],
walls: [{ key: 'k', cm: 20, a: [off, off], b: [off, off] }],
open_spans: [{ id: 's1', a: [off, off], b: [off, off] }],
wall_columns: [{ id: 'c1', center: [off, off] }],
}] },
layout: { m1: { s: 'sp1', x: off, y: off } },
});
assert.deepEqual(Object.keys(profile.byKind).sort(),
['column', 'layout', 'open_span', 'partition', 'room', 'wall']);
assert.equal(profile.total, 18);
});
test('модели проекта не несут шума решётки (#282)', async () => {
// Свойство, которое стоит знать про себя: тестовые данные проекта чисты —
// весь их офф-грид авторский. Значит воспроизвести на них #258/#279 нельзя
// по построению, а на реальном плане владельца шум составляет две трети
// координат. Именно поэтому этот класс дефектов находит владелец, а не гейт.
const models = [];
for (const file of fixtureModules()) {
const loaded = await import(`../demo/fixtures/${file}`);
for (const [name, value] of Object.entries(loaded)) {
if (typeof value !== 'function' || !/^make.*Fixture$/.test(name)) continue;
const fixture = value();
if (fixture?.config) models.push([`${file}:${name}`, fixture]);
}
}
models.push(['demo/srv/demo.html', demoStandModel()]);
assert.ok(models.length >= 3);
for (const [label, fixture] of models) {
const profile = latticeProfile({ config: fixture.config, layout: fixture.layout || {} });
assert.equal(profile.noise, 0,
`${label}: в тестовых данных появился шум решётки`
+ `${profile.worstNoise ? ` — ${profile.worstNoise.owner}` : ''}`);
}
});