fix: enforce exact lattice coordinates

Issue: #291
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-24 17:13:21 +03:00
parent c8a3878568
commit 4ccf4a3ccc
31 changed files with 1213 additions and 316 deletions
@@ -0,0 +1,94 @@
#!/usr/bin/env node
/** Executable inventory of every persisted config/layout coordinate writer (#291). */
import { readFileSync, readdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const defaultRoot = fileURLToPath(new URL('..', import.meta.url));
const occurrences = (source, needle) => {
const out = [];
let from = 0;
while (true) {
const index = source.indexOf(needle, from);
if (index < 0) return out;
out.push(index);
from = index + needle.length;
}
};
const requireWindow = (errors, source, index, pattern, label, before = 650) => {
const window = source.slice(Math.max(0, index - before), index + 260);
if (!pattern.test(window)) errors.push(`${label} bypasses the canonical boundary`);
};
export function checkCoordinateWriteBarriers(root = defaultRoot) {
const errors = [];
const frontend = readFileSync(resolve(root, 'src/houseplan-card.ts'), 'utf8');
const configWrites = occurrences(frontend, "type: 'houseplan/config/set'");
if (configWrites.length !== 1) errors.push(`frontend config writer inventory: ${configWrites.length}`);
for (const index of configWrites) requireWindow(
errors, frontend, index,
/const candidate = canonicalizeConfigGeometry\(this\._serverCfg\);[\s\S]*config: candidate/,
'frontend config/set', 3600,
);
const positionWrites = occurrences(frontend, "type: 'houseplan/layout/update'");
if (positionWrites.length !== 2) errors.push(`frontend position writer inventory: ${positionWrites.length}`);
for (const [ordinal, index] of positionWrites.entries()) requireWindow(
errors, frontend, index,
/const pos = canonicalizePosition\([^)]+\);[\s\S]*pos(?:[,\s}])/,
`frontend layout/update #${ordinal + 1}`,
);
const localWrites = occurrences(frontend, 'localStorage.setItem(LS_KEY');
if (localWrites.length !== 1) errors.push(`frontend local layout writer inventory: ${localWrites.length}`);
for (const index of localWrites) requireWindow(
errors, frontend, index,
/this\._layout = canonicalizeLayoutGeometry\(this\._layout\);/,
'frontend localStorage layout', 350,
);
const optimizeWrites = occurrences(frontend, "type: 'houseplan/plan/optimize'");
if (optimizeWrites.length !== 1) errors.push(`frontend Optimize writer inventory: ${optimizeWrites.length}`);
for (const index of optimizeWrites) requireWindow(
errors, frontend, index,
/config: d\.config,[\s\S]*layout: d\.layout/,
'frontend Optimize transaction', 300,
);
const componentRoot = resolve(root, 'custom_components/houseplan');
const pythonFiles = readdirSync(componentRoot)
.filter((name) => name.endsWith('.py'));
for (const name of pythonFiles) {
const source = readFileSync(resolve(componentRoot, name), 'utf8');
if (name === 'store.py') continue;
if (name === 'trails.py') continue; // operational breadcrumb Store, not plan geometry
if (/\b(?:runtime|rt)\.(?:config_store|store)\.async_save\s*\(/.test(source)) {
errors.push(`backend direct plan Store writer: custom_components/houseplan/${name}`);
}
}
const store = readFileSync(resolve(componentRoot, 'store.py'), 'utf8');
if (!/out\["layout"\] = canonicalize_layout_geometry\(layout\)/.test(store)) {
errors.push('backend layout_store_payload bypasses lattice canonicalization');
}
if (!/canonical_config = canonicalize_config_geometry\(config\)/.test(store)) {
errors.push('backend async_save_config_state bypasses lattice canonicalization');
}
if (occurrences(store, 'runtime.store.async_save(').length !== 1
|| occurrences(store, 'runtime.config_store.async_save(').length !== 1) {
errors.push('backend central Store writer inventory changed');
}
return errors;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const errors = checkCoordinateWriteBarriers();
if (errors.length) {
for (const error of errors) console.error(`coordinate write barrier: ${error}`);
process.exitCode = 1;
} else {
console.log('coordinate write barrier: OK');
}
}
+35 -9
View File
@@ -20,12 +20,14 @@ 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;
/** Решётка редактора: та же, что `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);
@@ -342,8 +344,6 @@ export function checkWallKeys(config, { notes = [] } = {}) {
* Взята на четыре порядка ниже шага, то есть заведомо ниже любого осмысленного
* пользовательского ввода и заведомо выше двоичного мусора одиночной операции.
*/
const NOISE_STEPS = 1e-4;
const latticeDeviation = (value) => {
const steps = value * GRID_N;
return Math.abs(steps - Math.round(steps));
@@ -359,11 +359,10 @@ function* modelCoordinates(config, layout = {}) {
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]] };
}
}
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']) {
@@ -387,6 +386,33 @@ function* modelCoordinates(config, layout = {}) {
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]);
+92 -9
View File
@@ -692,14 +692,13 @@ export const MUTANTS = [
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="issue 248 Optimize stays" '
+ 'test/plan-optimizer.test.mjs',
because: 'the pure Optimize candidate must already equal the nine-decimal pair that the '
+ 'backend writes, or a server-event reload turns 1/240 into fresh coordinate noise (#248)',
because: 'Optimize must remove near-node tails before visible Align so storage-only cleanup '
+ 'is not misreported as a user-visible move and the exact candidate survives reload (#291)',
patches: [{
file: 'src/plan-optimizer.ts',
find: ' const persistedConfig = canonicalizeConfigGeometry(config);\n'
+ ' const persistedLayout = canonicalizeLayoutGeometry(finalAligned.layout);',
replace: ' const persistedConfig = config;\n'
+ ' const persistedLayout = finalAligned.layout;',
find: ' canonicalizeConfigGeometryInPlace(config);\n'
+ ' canonicalizeLayoutGeometryInPlace(references.layout);',
replace: ' // mutant: skip the pre-Align lattice boundary',
}],
},
{
@@ -770,14 +769,98 @@ export const MUTANTS = [
{
id: 'quantization-hits-allowlist',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="nine-decimal fixture contract" '
+ '&& node --test --test-name-pattern="scalar\\+lattice fixture contract" '
+ 'test/coordinate-canonicalization.test.mjs',
because: 'presentation and calibration values are deliberately outside geometry; widening '
+ 'the allow-list silently changes user data that has no ULP topology problem',
patches: [{
file: 'src/coordinate-canonicalization.ts',
find: " for (const marker of records(root.markers)) fields(marker, ['angle']);",
replace: " for (const marker of records(root.markers)) fields(marker, ['angle', 'size']);",
find: " for (const marker of records(root.markers)) scalarFields(marker, ['angle']);",
replace: " for (const marker of records(root.markers)) scalarFields(marker, ['angle', 'size']);",
}],
},
{
id: 'lattice-round-truncates',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="all 4801 lattice nodes" '
+ 'test/coordinate-canonicalization.test.mjs',
because: 'truncating toward zero maps every negative and fractional node to the wrong exact '
+ 'double, recreating topology drift even though the output still looks grid-like (#291)',
patches: [{
file: 'src/coordinate-canonicalization.ts',
find: ' const scaled = value * LATTICE_GRID_N;\n'
+ ' const nearest = Math.round(scaled);\n'
+ ' const deviation = Math.abs(scaled - nearest);',
replace: ' const scaled = value * LATTICE_GRID_N;\n'
+ ' const nearest = Math.trunc(scaled);\n'
+ ' const deviation = Math.abs(scaled - nearest);',
}],
},
{
id: 'python-lattice-round-truncates',
guard: 'node scripts/backend-test-guard.mjs all_4801_lattice_nodes '
+ 'tests_backend/test_coordinate_canonicalization.py',
because: 'the backend must use the JavaScript Math.round tie direction and nearest-node '
+ 'semantics; truncation would make old-client writes diverge from the card (#291)',
patches: [{
file: 'custom_components/houseplan/coordinate_canonicalization.py',
find: ' nearest = math.floor(scaled + 0.5)',
replace: ' nearest = math.trunc(scaled)',
}],
},
{
id: 'lattice-noise-threshold-too-small',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="all 4801 lattice nodes" '
+ 'test/coordinate-canonicalization.test.mjs',
because: 'a threshold below the measured nine-decimal tail leaves the real 1/240 noise '
+ 'population intact while nominal exact nodes continue to pass (#291)',
patches: [{
file: 'src/coordinate-canonicalization.ts',
find: 'export const LATTICE_NOISE_STEPS = 1e-4;',
replace: 'export const LATTICE_NOISE_STEPS = 1e-9;',
}],
},
{
id: 'lattice-noise-threshold-too-large',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="all 4801 lattice nodes" '
+ 'test/coordinate-canonicalization.test.mjs',
because: 'a broad threshold silently attracts authored off-grid geometry instead of only '
+ 'removing the measured storage tail (#291)',
patches: [{
file: 'src/coordinate-canonicalization.ts',
find: 'export const LATTICE_NOISE_STEPS = 1e-4;',
replace: 'export const LATTICE_NOISE_STEPS = 0.5;',
}],
},
{
id: 'lattice-layout-allowlist-omitted',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="scalar\\+lattice fixture contract" '
+ 'test/coordinate-canonicalization.test.mjs',
because: 'config-only canonicalization lets marker and room-label writes recreate the same '
+ 'noise through the independent layout Store (#291)',
patches: [{
file: 'src/coordinate-canonicalization.ts',
find: ' for (const value of Object.values(root)) {\n'
+ ' const item = record(value);\n'
+ " if (item) latticeFields(item, ['x', 'y']);\n"
+ ' }\n return result;',
replace: ' // mutant: omit all layout x/y values\n return result;',
}],
},
{
id: 'lattice-unknown-fields-recursive',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="scalar\\+lattice fixture contract" '
+ 'test/coordinate-canonicalization.test.mjs',
because: 'recursively rounding future, physical and calibration numbers corrupts data which '
+ 'is deliberately outside the persisted coordinate allow-list (#291)',
patches: [{
file: 'src/coordinate-canonicalization.ts',
find: ' }\n return value;\n}\n\n/** Existing scalar contract',
replace: ' }\n return canonicalizeNumber(value);\n}\n\n/** Existing scalar contract',
}],
},
{