mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
H2: the benchmark budgets were calibrated on the author's sandbox with a 1.14x margin — the review runner measured tsFullCandidateMs at 169-171 ms against a 100 ms ceiling. Budgets now keep the spec's 2-3x allowance over the SLOWEST observed machine, and the benchmark runs as a step of the Validate perf job on every push (it needs no browser and no bundle), not only inside the weekly mutation gate. M1: the promised AC1 backend test exists now and does what AC1 means: it patches validate_junction_limits with a thread-recording wrapper inside the real HA harness — on the event loop that would be MainThread — and proves the verdicts survived the move (a clean write is accepted, a write adding a spike is refused with junction_limit_angle). Spec revision 4 rewrites AC1 around this invariant instead of a fragile millisecond assertion. M2: §4.6 equivalence is now behavioural on both sides (three boundary fixtures each: as-is counts equal through-migration counts, TS and python), and the parity suite gained the §7 boundary fixtures (exact 15°, exact 20 cm, the thickness-step filler run, exact 5 cm). H1 was already closed by 7513f93d (the review ran on the previous HEAD): check-docs is green on this tree — the screenshots and their manifest come from one capture run. Issue: #330 User-Visible: no
170 lines
7.0 KiB
JavaScript
170 lines
7.0 KiB
JavaScript
/**
|
|
* #330 §5: the performance contract of the junction limits (#329).
|
|
*
|
|
* Guards the CLASS of regression — a quadratic path returning — not runner
|
|
* noise: budgets are 2-3x the measured post-fix numbers. Measures the exact
|
|
* code the write barrier runs: the pure П1-П5 checks over a 576-atom plan
|
|
* (the S2 reproduction grid of #330), for both mirrors. The python half runs
|
|
* through a child process with the real custom_components module.
|
|
*
|
|
* Runs on test-build/ like benchmark:wall-model — no browser, no bundle.
|
|
*/
|
|
import { spawnSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
import {
|
|
checkNodes, checkSegmentLengths, checkNodeDistances, checkRoomClearance,
|
|
cmToUnits,
|
|
} from '../test-build/junction-limits.js';
|
|
import { commitWallSegmentModel } from '../test-build/wall-segment-model.js';
|
|
import { GRID_STEP_N } from '../test-build/space-geometry.js';
|
|
import {
|
|
innerContourForRoom, multiWallNodesForGeometry, wallBodiesGeometry,
|
|
} from '../test-build/wall-thickness.js';
|
|
|
|
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
const CELL = 5;
|
|
const GRID_N = 12; // 576 contour atoms — the #330 S2 grid
|
|
const WARMUPS = 2;
|
|
const SAMPLES = 5;
|
|
|
|
// Budgets are calibrated from the SLOWEST machine observed, not the
|
|
// author's: the CI review runner measured tsFullCandidateMs at 169-171 ms
|
|
// where the dev sandbox saw 88 (code-review 330-r1 H2). Each budget keeps
|
|
// the spec's 2-3x allowance over that worst observation, so the bench turns
|
|
// red for the O(n²) class (which costs seconds), not for a slower runner.
|
|
const BUDGETS = {
|
|
tsSegmentLengthsMs: 60,
|
|
tsNodeDistancesMs: 80,
|
|
tsFullCandidateMs: 400,
|
|
pyWarmValidateMs: 300,
|
|
pyColdValidateMs: 5000,
|
|
};
|
|
|
|
const u = (cm) => cmToUnits(cm, CELL, GRID_STEP_N);
|
|
const rooms = []; const walls = []; let k = 0;
|
|
for (let i = 0; i < GRID_N; i++) {
|
|
for (let j = 0; j < GRID_N; j++) {
|
|
const x0 = u(310) * i; const y0 = u(310) * j;
|
|
const poly = [[x0, y0], [x0 + u(300), y0], [x0 + u(300), y0 + u(300)], [x0, y0 + u(300)]];
|
|
rooms.push({ id: `r${i}-${j}`, name: `r${i}${j}`, area: null, poly });
|
|
for (let e = 0; e < 4; e++) {
|
|
walls.push({ key: `w${k++}`, a: poly[e], b: poly[(e + 1) % 4], cm: 15 });
|
|
}
|
|
}
|
|
}
|
|
const legacy = {
|
|
spaces: [{
|
|
id: 's', title: 's', cell_cm: CELL, view_box: [0, 0, 1, 1],
|
|
rooms, walls, openings: [], room_drafts: [], partitions: [], wall_columns: [],
|
|
}],
|
|
markers: [], settings: {},
|
|
};
|
|
const { config: v9 } = commitWallSegmentModel(JSON.parse(JSON.stringify(legacy)));
|
|
const space = v9.spaces[0];
|
|
const segments = (space.wall_segments || []).map((item) => ({
|
|
id: item.id, a: item.a, b: item.b, cm: Number(item.cm),
|
|
}));
|
|
|
|
/** The same П1-П5 set the card's write barrier computes for one candidate. */
|
|
const fullCandidate = () => {
|
|
const violations = [
|
|
...checkNodes(segments),
|
|
...checkSegmentLengths(segments, CELL, GRID_STEP_N),
|
|
...checkNodeDistances(segments, CELL, GRID_STEP_N),
|
|
];
|
|
// #330 §4.7: shared passes exactly as the card computes them.
|
|
const sharedNodes = multiWallNodesForGeometry(
|
|
space.rooms || [], space.walls || [], [], GRID_STEP_N, CELL, GRID_STEP_N, 1,
|
|
);
|
|
let sharedRoomGeometry;
|
|
if (sharedNodes.nodes.length) {
|
|
const geometry = wallBodiesGeometry(
|
|
space.rooms || [], space.walls || [], [], [], GRID_STEP_N, CELL, GRID_STEP_N, 1,
|
|
);
|
|
sharedRoomGeometry = geometry?.status === 'ok' || geometry?.status === 'degraded-extra'
|
|
? geometry.roomGeom : undefined;
|
|
}
|
|
for (const room of space.rooms || []) {
|
|
let inner = null;
|
|
try {
|
|
inner = innerContourForRoom(
|
|
space.rooms || [], String(room.id), space.walls || [], [],
|
|
GRID_STEP_N, CELL, GRID_STEP_N, 1, sharedRoomGeometry, sharedNodes,
|
|
);
|
|
} catch { inner = null; }
|
|
violations.push(...checkRoomClearance(String(room.id), inner, CELL, GRID_STEP_N));
|
|
}
|
|
return violations.length;
|
|
};
|
|
|
|
const median = (fn) => {
|
|
for (let i = 0; i < WARMUPS; i++) fn();
|
|
const times = [];
|
|
for (let i = 0; i < SAMPLES; i++) {
|
|
const start = performance.now();
|
|
fn();
|
|
times.push(performance.now() - start);
|
|
}
|
|
times.sort((left, right) => left - right);
|
|
return times[Math.floor(times.length / 2)];
|
|
};
|
|
|
|
const tsSegmentLengthsMs = median(() => checkSegmentLengths(segments, CELL, GRID_STEP_N));
|
|
const tsNodeDistancesMs = median(() => checkNodeDistances(segments, CELL, GRID_STEP_N));
|
|
const tsFullCandidateMs = median(fullCandidate);
|
|
|
|
// Python mirror: cold (legacy both sides) and warm (v9 + baseline counts).
|
|
const pyScript = `
|
|
import importlib.util, sys, types, os, json, time
|
|
root = ${JSON.stringify(REPO_ROOT)}
|
|
pr = os.path.join(root, 'custom_components'); hr = os.path.join(pr, 'houseplan')
|
|
pkg = types.ModuleType('custom_components'); pkg.__path__ = [pr]; sys.modules['custom_components'] = pkg
|
|
p2 = types.ModuleType('custom_components.houseplan'); p2.__path__ = [hr]; sys.modules['custom_components.houseplan'] = p2
|
|
spec = importlib.util.spec_from_file_location('custom_components.houseplan.junction_limits', os.path.join(hr, 'junction_limits.py'))
|
|
jl = importlib.util.module_from_spec(spec); sys.modules[spec.name] = jl; spec.loader.exec_module(jl)
|
|
legacy = json.load(sys.stdin)
|
|
from custom_components.houseplan.wall_segment_model import commit_wall_segment_model
|
|
v9, _ = commit_wall_segment_model(json.loads(json.dumps(legacy)))
|
|
prev = json.loads(json.dumps(v9)); cand = json.loads(json.dumps(v9))
|
|
cand['spaces'][0]['rooms'][0]['name'] = 'x'
|
|
t0 = time.perf_counter()
|
|
jl.validate_junction_limits(json.loads(json.dumps(legacy)), json.loads(json.dumps(legacy)))
|
|
cold = 1000 * (time.perf_counter() - t0)
|
|
counts = jl.validate_junction_limits(json.loads(json.dumps(cand)), json.loads(json.dumps(prev)))
|
|
best = None
|
|
for _ in range(3):
|
|
t0 = time.perf_counter()
|
|
jl.validate_junction_limits(json.loads(json.dumps(cand)), None, baseline_counts=counts)
|
|
warm = 1000 * (time.perf_counter() - t0)
|
|
best = warm if best is None else min(best, warm)
|
|
print(json.dumps({'pyColdValidateMs': cold, 'pyWarmValidateMs': best}))
|
|
`;
|
|
const python = process.env.PYTHON || (process.platform === 'win32' ? 'python' : 'python3');
|
|
const pyRun = spawnSync(python, ['-c', pyScript], {
|
|
input: JSON.stringify(legacy), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
|
});
|
|
if (pyRun.status !== 0) {
|
|
console.error('python mirror failed:', (pyRun.stderr || '').slice(-2000));
|
|
process.exit(1);
|
|
}
|
|
const py = JSON.parse(pyRun.stdout.trim().split('\n').pop());
|
|
|
|
const results = {
|
|
tsSegmentLengthsMs, tsNodeDistancesMs, tsFullCandidateMs,
|
|
pyWarmValidateMs: py.pyWarmValidateMs, pyColdValidateMs: py.pyColdValidateMs,
|
|
};
|
|
const pass = Object.entries(BUDGETS).every(([name, budget]) => results[name] <= budget);
|
|
console.log(JSON.stringify({
|
|
issue: 330,
|
|
fixture: { atoms: segments.length, grid: `${GRID_N}x${GRID_N}` },
|
|
warmups: WARMUPS, samples: SAMPLES,
|
|
results: Object.fromEntries(Object.entries(results).map(
|
|
([name, value]) => [name, Math.round(value * 10) / 10],
|
|
)),
|
|
budgets: BUDGETS,
|
|
pass,
|
|
}, null, 2));
|
|
if (!pass) process.exitCode = 1;
|