Files
houseplan-card/demo/benchmark_junction_limits.mjs
T
Codex ddfca3a865 fix: close code-review 330-r1 — budgets from the slowest machine, the bench in Validate, AC1 through the execution thread (#330)
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
2026-08-28 03:07:44 +03:00

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;