test: one thickness record must not describe two wall roles
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

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
This commit is contained in:
Claude
2026-08-24 13:20:34 +03:00
parent d5659478aa
commit 523190d8f4
3 changed files with 176 additions and 2 deletions
+89
View File
@@ -392,6 +392,93 @@ export function latticeProfile({ config, layout = {} } = {}) {
};
}
/**
* Инвариант 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);
@@ -479,6 +566,7 @@ function report(violations, notes = []) {
geometry_exception: 'Сбой проверки канонической геометрии стен',
lost: 'Потерянные записи толщины',
wall_key: 'Записи толщины, которые не найдутся по ключу',
mixed_role_record: 'Записи толщины, описывающие сразу общую и наружную стену',
};
for (const [kind, list] of byKind) {
lines.push(`${titles[kind] || kind}: ${list.length}`);
@@ -514,6 +602,7 @@ function main(argv) {
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));
+13
View File
@@ -479,6 +479,19 @@ export const MUTANTS = [
replace: ' if (false) {',
}],
},
{
id: 'invariant-roles-sample-endpoints',
guard: 'node --test --test-name-pattern="реальные планы проекта эту проверку" '
+ 'test/model-invariants.test.mjs',
because: 'конец записи — узел, а не участок: там стена законно касается двух комнат, '
+ 'и включение концов в выборку даёт ложное срабатывание на каждой наружной стене, '
+ 'упирающейся в общую (#287)',
patches: [{
file: 'scripts/model-invariants.mjs',
find: ' for (let i = 1; i < samples; i++) {',
replace: ' for (let i = 0; i <= samples; i++) {',
}],
},
{
id: 'invariant-keys-cry-wolf',
guard: 'node --test --test-name-pattern="старый и неразбираемый compatibility key" '
+74 -2
View File
@@ -5,8 +5,8 @@ import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
checkReferences, checkWallKeys, checkWallRecordsPreserved, keyMidpoint,
latticeProfile, readModel, wallKey,
checkMixedRoleRecords, checkReferences, checkWallKeys, checkWallRecordsPreserved,
keyMidpoint, 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';
@@ -418,3 +418,75 @@ test('реальные планы: сплошные рёбра без запис
+ ` ожидалось ${plan.zeroThicknessSolidEdges}`);
}
});
// ------- инвариант 4: запись толщины не смешивает общее и наружное (#287) ----
// Воспроизведение дефекта владельца из `66.json`: ресайз сдвинул одну комнату
// общей пары, вторая осталась, и нижняя часть стены перестала быть общей —
// но продолжает нести толщину бывшей общей границы.
const pair = (leftPoly, wall) => ({
spaces: [{
id: 'sp', cell_cm: 1,
rooms: [
{ id: 'left', poly: leftPoly },
{ id: 'right', poly: [[1, 0], [2, 0], [2, 1], [1, 1]] },
],
walls: [wall],
}],
});
test('#287: до ресайза общая стена нарушением не считается', () => {
assert.deepEqual(checkMixedRoleRecords(pair(
[[0, 0], [1, 0], [1, 1], [0, 1]], { key: 'k', cm: 20, a: [1, 0], b: [1, 1] },
)), []);
});
test('#287: после частичного ресайза запись описывает и общее, и наружное', () => {
const found = checkMixedRoleRecords(pair(
[[0, 0], [1, 0], [1, 1.2], [0, 1.2]], { key: 'k2', cm: 20, a: [1, 0], b: [1, 1.2] },
));
assert.equal(found.length, 1);
assert.equal(found[0].kind, 'mixed_role_record');
assert.match(found[0].detail, /частью общий, частью наружный/);
assert.match(found[0].detail, /наружного/);
});
test('#287: наружный угол одной комнаты — не общая граница', () => {
// Считать надо РАЗНЫЕ комнаты, а не рёбра: в углу точка лежит сразу на двух
// рёбрах одной комнаты. Подсчёт рёбер давал ложное срабатывание на каждой
// наружной стене.
assert.deepEqual(checkMixedRoleRecords({
spaces: [{
id: 'sp', cell_cm: 1,
rooms: [{ id: 'only', poly: [[0, 0], [1, 0], [1, 1], [0, 1]] }],
walls: [{ key: 'k', cm: 30, a: [0, 0], b: [1, 0] }],
}],
}), []);
});
test('#287: наружная стена, упирающаяся в общую, нарушением не считается', () => {
// Конец записи — это узел, а не участок: там стена законно касается рёбер
// двух комнат. Включение концов в выборку давало «95% наружного» на каждой
// такой стене, то есть ложное срабатывание на ровном месте.
const found = checkMixedRoleRecords({
spaces: [{
id: 'sp', cell_cm: 1,
rooms: [
{ id: 'left', poly: [[0, 0], [1, 0], [1, 1], [0, 1]] },
{ id: 'right', poly: [[1, 0], [2, 0], [2, 1], [1, 1]] },
],
// наружная стена левой комнаты, оба конца упираются в общую границу x=1
walls: [{ key: 'outer', cm: 30, a: [0, 0], b: [1, 0] }],
}],
});
assert.deepEqual(found, []);
});
test('#287: реальные планы проекта эту проверку проходят', () => {
for (const file of ['real-plan-first-floor.json', 'real-plan-second-floor.json']) {
const { space } = JSON.parse(
readFileSync(resolve(repoRoot, 'test/fixtures', file), 'utf8'));
assert.deepEqual(checkMixedRoleRecords({ spaces: [space] }), [],
`${file}: ложное срабатывание на реальном плане`);
}
});