perf: junction limits scale — executor, rev cache, linear П3/П4, shared masonry pass (#330)

Six cuts, zero verdict changes (spec §3; equivalence pinned by units, the
parity suite and the smokes):

- §4.1 the CPU chain of ws_config_set and ws_plan_optimize runs in the
  executor; write_lock still serialises writes, only the HA event loop is
  freed (2.8 s of blocking per 576-atom write before).
- §4.2 the stored document's violation counts are cached on the runtime by
  rev (store.py junction_baseline); a repeated write never re-judges
  `previous`. validate_junction_limits takes baseline_counts and returns the
  candidate's counts to cache after a successful save.
- §4.3 П3 builds its node index once per check in both mirrors
  (289→11 ms TS, 285→~50 ms py).
- §4.5 П4 uses a bucket grid with the threshold as cell size in both
  mirrors (104→19 ms TS, 372→44 ms py); pair enumeration switches to
  lexicographic order — same verdict set, equivalence pinned against a
  brute-force oracle on cell borders.
- §4.6 a document already carrying the current catalogue is judged as-is:
  a no-op re-migration cost 815 ms py / 69 ms TS. Legacy documents migrate
  exactly as before (the #329 H1 test stays green).
- §4.7 П5 shares one junction-topology pass per check and pays the masonry
  union only when multi-wall nodes exist — and the resize path hands over
  the preflight's own artifact, so a pointermove never builds the union
  twice (4.2 s → 88 ms full candidate on the benchmark grid).

The frontend baseline is cached per (document identity, config epoch): ten
pointermoves make N+1 limit computations, not 2N — pinned by the smoke on a
real pointer gesture.

demo/benchmark_junction_limits.mjs (npm run benchmark:junction-limits) pins
the budgets for both mirrors: TS full candidate ≤100 ms (measured 88), py
warm validate ≤250 ms (measured 45), cold legacy ≤3.5 s — that path is
one-off and lives in the executor.

Issue: #330
User-Visible: yes
This commit is contained in:
Codex
2026-08-28 03:07:11 +03:00
parent 92f476556f
commit c90f5bf052
15 changed files with 934 additions and 237 deletions
File diff suppressed because one or more lines are too long
+112 -38
View File
@@ -18,7 +18,7 @@ from __future__ import annotations
import math
from .wall_segment_model import commit_wall_segment_model
from .wall_segment_model import WALL_SEGMENT_MODEL_VERSION, commit_wall_segment_model
MIN_JUNCTION_ANGLE_DEG = 15.0
MAX_JUNCTION_VALENCE = 6
@@ -147,18 +147,29 @@ def _collinear(left: dict, right: dict, tolerance_deg: float = 1.0) -> bool:
return min(delta, 180 - delta) <= tolerance_deg
def collinear_run_length_units(segment: dict, segments: list[dict]) -> float:
def _build_node_index(segments: list[dict]) -> dict[str, list[dict]]:
by_node: dict[str, list[dict]] = {}
for item in segments:
for point in (item["a"], item["b"]):
by_node.setdefault(_key(point), []).append(item)
return by_node
def collinear_run_length_units(
segment: dict, segments: list[dict],
by_node_index: dict[str, list[dict]] | None = None,
) -> float:
"""Length of the WALL a segment belongs to, not of the atom.
Atomisation at a thickness step leaves pieces nobody drew — where a 30 cm
wall meets a 20 cm one, a (30−20)/2 = 5 cm piece continues the same wall.
They are collinear continuations at the same thickness, so П3 measures the
maximal collinear chain through the segment's nodes.
#330 §4.3: building the index per SEGMENT made П3 quadratic (285 ms on
576 atoms); the per-check caller builds it once and passes it in.
"""
by_node: dict[str, list[dict]] = {}
for item in segments:
for point in (item["a"], item["b"]):
by_node.setdefault(_key(point), []).append(item)
by_node = by_node_index if by_node_index is not None else _build_node_index(segments)
visited = [segment]
total = _length(segment["a"], segment["b"])
@@ -187,8 +198,9 @@ def check_segment_lengths(
) -> list[tuple[str, str, float, float]]:
"""П3: a wall is at least 20 cm and never shorter than its own thickness."""
violations: list[tuple[str, str, float, float]] = []
by_node = _build_node_index(segments)
for segment in segments:
units = collinear_run_length_units(segment, segments)
units = collinear_run_length_units(segment, segments, by_node)
cm = (units / grid_pitch) * (cell_cm or 1)
thickness = float(segment.get("cm") or 0)
limit = max(MIN_SEGMENT_LENGTH_CM, thickness if thickness > 0 else 0.0)
@@ -211,28 +223,57 @@ def _distance_to_segment(point, a, b) -> float:
def check_node_distances(
segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N,
) -> list[tuple[str, str, float, float]]:
"""П4: non-incident nodes and node-to-foreign-wall clearance."""
"""П4: non-incident nodes and node-to-foreign-wall clearance.
#330 §4.5: the all-pairs form cost 372 ms on 576 atoms. Nodes and padded
segment bounding boxes are hashed into a grid with the threshold as cell
size; each node is compared only against its 9-cell neighbourhood.
Verdicts are identical — pinned by the equivalence unit and the parity
suite. Pair order inside the subject follows the lexicographic key order,
which replaces the i<j of the all-pairs loop.
"""
nodes: dict[str, list] = {}
for segment in segments:
nodes[_key(segment["a"])] = segment["a"]
nodes[_key(segment["b"])] = segment["b"]
min_units = cm_to_units(MIN_NODE_DISTANCE_CM, cell_cm, grid_pitch)
size = min_units if min_units > _EPS else 1.0
node_grid: dict[tuple[int, int], list] = {}
for node_key, point in nodes.items():
node_grid.setdefault(
(int(point[0] // size), int(point[1] // size)), []
).append((node_key, point))
segment_grid: dict[tuple[int, int], list] = {}
for segment in segments:
x0 = min(segment["a"][0], segment["b"][0]) - min_units
x1 = max(segment["a"][0], segment["b"][0]) + min_units
y0 = min(segment["a"][1], segment["b"][1]) - min_units
y1 = max(segment["a"][1], segment["b"][1]) + min_units
for cx in range(int(x0 // size), int(x1 // size) + 1):
for cy in range(int(y0 // size), int(y1 // size) + 1):
segment_grid.setdefault((cx, cy), []).append(segment)
violations: list[tuple[str, str, float, float]] = []
entries = list(nodes.items())
for i in range(len(entries)):
for j in range(i + 1, len(entries)):
distance = _length(entries[i][1], entries[j][1])
if distance < min_units - 1e-9:
violations.append((
"distance", f"{entries[i][0]} ↔ {entries[j][0]}",
(distance / grid_pitch) * (cell_cm or 1), MIN_NODE_DISTANCE_CM,
))
for node_key, node in nodes.items():
for segment in segments:
for node_key, point in nodes.items():
cx, cy = int(point[0] // size), int(point[1] // size)
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for other_key, other in node_grid.get((cx + dx, cy + dy), ()):
if node_key >= other_key:
continue
distance = _length(point, other)
if distance < min_units - 1e-9:
violations.append((
"distance", f"{node_key} ↔ {other_key}",
(distance / grid_pitch) * (cell_cm or 1),
MIN_NODE_DISTANCE_CM,
))
for segment in segment_grid.get((cx, cy), ()):
# A node that belongs to the wall is a legal corner or T-joint.
if _key(segment["a"]) == node_key or _key(segment["b"]) == node_key:
continue
distance = _distance_to_segment(node, segment["a"], segment["b"])
distance = _distance_to_segment(point, segment["a"], segment["b"])
# Sitting exactly ON the wall is the other legal incidence.
if distance <= _INCIDENT_EPS:
continue
@@ -240,11 +281,11 @@ def check_node_distances(
violations.append((
"distance",
f"{node_key} → {segment.get('id') or _key(segment['a'])}",
(distance / grid_pitch) * (cell_cm or 1), MIN_NODE_DISTANCE_CM,
(distance / grid_pitch) * (cell_cm or 1),
MIN_NODE_DISTANCE_CM,
))
return violations
def space_violations(space: dict) -> list[tuple[str, str, float, float]]:
"""П1–П4 over one space, in the frontend's order."""
segments = limit_segments(space)
@@ -272,10 +313,20 @@ def _migrated_spaces(config: dict | None) -> dict[str, dict]:
"""
if not isinstance(config, dict):
return {}
try:
migrated, _ = commit_wall_segment_model(config)
except Exception: # noqa: BLE001 — see the docstring: not our verdict
# #330 §4.6: a document that already carries the current catalogue is
# used as-is. The migration's purpose here is to never compare a
# catalogue-less legacy baseline against a migrated candidate; a
# current-version document has the catalogue, so the purpose is met with
# zero work — while a no-op re-migration costs 0.8 s of _atomize on a
# 576-atom plan. Equivalence "as-is == migrated" for current-version
# documents is pinned by a parity case.
if int(config.get("model_version") or 0) >= WALL_SEGMENT_MODEL_VERSION:
migrated = config
else:
try:
migrated, _ = commit_wall_segment_model(config)
except Exception: # noqa: BLE001 — see the docstring: not our verdict
migrated = config
return {
str(space.get("id")): space
for space in (migrated or {}).get("spaces") or []
@@ -283,7 +334,22 @@ def _migrated_spaces(config: dict | None) -> dict[str, dict]:
}
def validate_junction_limits(config: dict, previous: dict | None = None) -> None:
def space_violation_counts(spaces: dict[str, dict]) -> dict[str, dict[str, int]]:
"""Violation counts per space per rule — the shape the barrier consumes
and the rev cache (#330 §4.2) stores. Documents are NOT retained."""
result: dict[str, dict[str, int]] = {}
for space_id, space in spaces.items():
counts: dict[str, int] = {}
for rule, _subject, _actual, _limit in space_violations(space):
counts[rule] = counts.get(rule, 0) + 1
result[space_id] = counts
return result
def validate_junction_limits(
config: dict, previous: dict | None = None, *,
baseline_counts: dict[str, dict[str, int]] | None = None,
) -> dict[str, dict[str, int]]:
"""Refuse a write that ADDS a junction violation; inherit the rest.
Counted per RULE, not per subject: a structural write re-atomises and
@@ -292,25 +358,33 @@ def validate_junction_limits(config: dict, previous: dict | None = None) -> None
(the mistake that refused legitimate resizes on the frontend). Spec §3:
a write may keep existing violations, it may never add one.
Both documents go through `commit_wall_segment_model` first, so a legacy
baseline is compared in the same terms as the candidate.
A LEGACY document still goes through `commit_wall_segment_model` first,
so a catalogue-less baseline is compared in the same terms as the
candidate; a current-version document is used as-is (#330 §4.6).
`baseline_counts` (#330 §4.2) replaces the previous document entirely:
the caller caches the counts of the stored document by config_rev, so a
repeated write does not even touch `previous`. Returns the candidate's
counts — the value to cache after a successful write.
"""
old_spaces = _migrated_spaces(previous)
if baseline_counts is not None:
old_counts = baseline_counts
else:
old_counts = space_violation_counts(_migrated_spaces(previous))
new_spaces = _migrated_spaces(config)
candidate_counts: dict[str, dict[str, int]] = {}
for space_id, space in new_spaces.items():
old_space = old_spaces.get(space_id)
if old_space is None:
# A brand-new space has nothing to inherit from — but neither is a
# first write allowed to arrive already broken.
before: dict[str, int] = {}
else:
before = {}
for rule, _subject, _actual, _limit in space_violations(old_space):
before[rule] = before.get(rule, 0) + 1
# A brand-new space has nothing to inherit from — but neither is a
# first write allowed to arrive already broken.
before = old_counts.get(space_id) or {}
after: dict[str, list[tuple[str, str, float, float]]] = {}
for violation in space_violations(space):
after.setdefault(violation[0], []).append(violation)
candidate_counts[space_id] = {
rule: len(items) for rule, items in after.items()
}
for rule, items in after.items():
if len(items) > before.get(rule, 0):
_, subject, actual, limit = items[0]
raise JunctionLimitError(space_id, rule, subject, actual, limit)
return candidate_counts
+5
View File
@@ -101,6 +101,11 @@ class HouseplanData:
# Parsed import candidates are short-lived, user-bound and memory-only.
# dict keeps insertion order, which lets the preview service evict oldest.
import_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
# #330 §4.2: junction-limit violation counts of the STORED document,
# keyed by its rev — (rev, {space_id: {rule: count}}). One slot,
# memory-only, invalidated by a rev mismatch; the previous document is
# not re-judged on every write. Counts only — documents are never held.
junction_baseline: tuple[int, dict[str, dict[str, int]]] | None = None
HouseplanConfigEntry = ConfigEntry[HouseplanData]
+39 -14
View File
@@ -1321,17 +1321,31 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
# Marker-to-marker light links are a semantic graph layered on top of
# the lossless controls array. Validate only edges introduced by this
# write so an unrelated edit can still round-trip a legacy broken ref.
try:
# #330 §4.1/§4.2: the CPU part of validation runs in the executor —
# schema + semantic validators cost seconds on a large plan and the
# event loop must not carry that (HA guideline: >50 ms). write_lock
# is still held across the await: writes stay serialised, only the
# loop is freed. The junction baseline of the STORED document is
# cached by rev, so a repeated write does not re-judge `previous`.
baseline = rt.junction_baseline
baseline_counts = baseline[1] if baseline and baseline[0] == current_rev else None
def _validate_config_cpu():
validate_wall_model_transition(msg["config"], data.get("config"))
validated_config = CONFIG_SCHEMA(msg["config"])
checked = CONFIG_SCHEMA(msg["config"])
msg["config"].clear()
msg["config"].update(validated_config)
msg["config"].update(checked)
validate_marker_controls(msg["config"], data.get("config"))
validate_marker_light_entities(msg["config"], data.get("config"))
validate_marker_value_badges(msg["config"], data.get("config"))
validate_opening_passages(msg["config"], data.get("config"))
validate_partition_opening_hosts(msg["config"], data.get("config"))
validate_junction_limits(msg["config"], data.get("config"))
return validate_junction_limits(
msg["config"], data.get("config"),
baseline_counts=baseline_counts,
)
try:
candidate_counts = await hass.async_add_executor_job(_validate_config_cpu)
except (
JunctionLimitError, MarkerControlError, OpeningPassageError,
PartitionOpeningHostError, PartitionOpeningJambMarginError,
@@ -1377,6 +1391,9 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
new_rev,
previous_rev=int(current_rev),
)
# The candidate just became the stored document: its counts are the
# next write's baseline (#330 §4.2).
rt.junction_baseline = (int(new_rev), candidate_counts)
try:
await _discard_optimizer_snapshot(rt)
except Exception: # noqa: BLE001 — stale backup cleanup is best-effort
@@ -1643,7 +1660,10 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
# Optimization is a normal configuration write with an additional
# layout transaction. It must enforce the same marker-link semantics
# as config/set; otherwise a crafted client can persist a new cycle.
try:
# #330 §4.1: the same executor treatment as config/set — Optimize
# carries schema + a possible full migration, the costliest CPU path
# of all writers, and it used to run on the event loop.
def _validate_optimize_cpu():
validate_wall_model_transition(msg["config"], config_data.get("config"))
try:
submitted_model = int(msg["config"].get("model_version", 0) or 0)
@@ -1662,17 +1682,12 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
validate_opening_passages(candidate_config, config_data.get("config"))
if submitted_model < WALL_SEGMENT_MODEL_VERSION:
candidate_config, _ = commit_wall_segment_model(candidate_config)
validated_config = CONFIG_SCHEMA(candidate_config)
migrated_size = len(json.dumps(validated_config, separators=(",", ":")))
checked = CONFIG_SCHEMA(candidate_config)
migrated_size = len(json.dumps(checked, separators=(",", ":")))
if migrated_size > MAX_CONFIG_BYTES:
connection.send_error(
msg["id"], "too_large",
f"Configuration is {migrated_size // 1024} KB, "
f"the limit is {MAX_CONFIG_BYTES // 1024} KB",
)
return
return migrated_size
msg["config"].clear()
msg["config"].update(validated_config)
msg["config"].update(checked)
validate_marker_controls(msg["config"], config_data.get("config"))
validate_marker_light_entities(msg["config"], config_data.get("config"))
validate_marker_value_badges(msg["config"], config_data.get("config"))
@@ -1681,6 +1696,16 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
msg["config"], config_data.get("config"),
allow_optimize_rehost=True,
)
return None
try:
oversize = await hass.async_add_executor_job(_validate_optimize_cpu)
if oversize is not None:
connection.send_error(
msg["id"], "too_large",
f"Configuration is {oversize // 1024} KB, "
f"the limit is {MAX_CONFIG_BYTES // 1024} KB",
)
return
except (
JunctionLimitError, MarkerControlError, OpeningPassageError,
PartitionOpeningHostError, PartitionOpeningJambMarginError,
+164
View File
@@ -0,0 +1,164 @@
/**
* #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;
const BUDGETS = {
tsSegmentLengthsMs: 40,
tsNodeDistancesMs: 40,
tsFullCandidateMs: 100,
pyWarmValidateMs: 250,
pyColdValidateMs: 3500,
};
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;
+17
View File
@@ -183,6 +183,18 @@ const handle = await page.evaluate(() => {
return { start: map(cx, cy), end: map(cx + (6 / 2) * (1000 / 240), cy) };
});
// #330 AC4: в одном жесте baseline считается один раз — N move дают N+1
// вызовов _junctionLimitViolations (кандидаты + один baseline), не 2N.
await page.evaluate(() => {
const card = window.__card;
window.__jlCalls = 0;
const original = card._junctionLimitViolations.bind(card);
card._junctionLimitViolations = (...args) => {
window.__jlCalls += 1;
return original(...args);
};
});
const resize = { resizeHandleFound: !!handle, resizeGapBefore: await gapCm() };
if (handle) {
await page.mouse.move(...handle.start);
@@ -197,6 +209,11 @@ if (handle) {
resize.resizeStoppedAtLastAllowed = await gapCm();
resize.resizeRefusalNamesRule = toasts.some((text) => text.includes('5'));
resize.resizeRefusalOnce = toasts.length;
const jlCalls = await page.evaluate(() => window.__jlCalls);
// Жест из 10 move: без кэша (#330 §4.4) было бы ~2N вызовов; с кэшем —
// N кандидатов + один baseline. Верхняя граница с запасом на дребезг
// квантования шагов, но заведомо ниже 2N.
resize.resizeBaselineCachedPerGesture = jlCalls > 0 && jlCalls <= 12;
}
checkAll({ ...out, ...resize }, {
+75 -75
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Saving and resizing large plans no longer freezes Home Assistant: the
junction-limit checks (#329) are orders of magnitude faster — the heavy
part left the server's main loop, repeated work is cached
([#330](https://github.com/Matysh/houseplan-card/issues/330)).
- Sharp room corners are honest again: a very acute apex used to break into a
«trident» of pikes and notches, and drawing now refuses the shapes that
cause it — walls closer than 15°, more than six walls in one node, segments
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено
- Сохранение и изменение размеров на больших планах больше не подвешивают
Home Assistant: проверка ограничений стыков (#329) ускорена на порядки —
тяжёлая часть ушла из основного цикла сервера, повторные вычисления
кэшируются ([#330](https://github.com/Matysh/houseplan-card/issues/330)).
- Острые углы комнат снова выглядят честно: очень острая вершина
распадалась на «трезубец» из пиков и вырезов, а рисование теперь не
разрешает саму причину — стены под углом меньше 15°, больше шести стен в
+2 -1
View File
@@ -33,7 +33,8 @@
"release:check": "node scripts/release-prerelease.mjs --check",
"release:prerelease": "node scripts/release-prerelease.mjs",
"release:notes": "node scripts/release-notes.mjs",
"prepare": "node scripts/install-hooks.mjs"
"prepare": "node scripts/install-hooks.mjs",
"benchmark:junction-limits": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node demo/benchmark_junction_limits.mjs"
},
"devDependencies": {
"@mdi/js": "^7.4.47",
+38
View File
@@ -535,6 +535,44 @@ export const MUTANTS = [
replace: ' // mutant: pointer capture removed\n const plan = resolution.plan;',
}],
},
{
id: 'junction-limit-p3-quadratic-again',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node demo/benchmark_junction_limits.mjs',
because: 'П3 rebuilding its node index per segment is the exact O(n²) #330 removed '
+ '(289 ms per call on 576 atoms, twice per pointermove) — the benchmark budget '
+ 'must turn red the day it returns',
patches: [{
file: 'src/junction-limits.ts',
find: ' const units = collinearRunLengthUnits(segment, usable, byNode);',
replace: ' const units = collinearRunLengthUnits(segment, usable);',
}],
},
{
id: 'junction-limit-p4-bruteforce-again',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node demo/benchmark_junction_limits.mjs',
because: 'П4 is architecturally quadratic without the bucket grid (#330 §4.5); feeding '
+ 'every node the whole segment list instead of its cell brings the 104-372 ms cost '
+ 'back and the benchmark must catch it',
patches: [{
file: 'src/junction-limits.ts',
find: ' for (const segment of segmentGrid.get(`${cx},${cy}`) || []) {',
replace: ' for (const segment of usable) {',
}],
},
{
id: 'junction-limit-baseline-cache-stale',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#330 AC4" test/junction-limits.test.mjs',
because: 'a baseline cache that survives a config epoch change serves verdicts of a '
+ 'plan that no longer exists — the epoch check IS the invalidation contract (#330 §4.4)',
patches: [{
file: 'src/houseplan-card.ts',
find: " if (cached && cached.epoch === this._cfgEpoch && cached.spaceId === spaceId) {",
replace: ' if (cached && cached.spaceId === spaceId) {',
}],
},
{
id: 'junction-limit-backend-raw-baseline',
guard: 'node scripts/backend-test-guard.mjs '
+76 -13
View File
@@ -70,6 +70,7 @@ import {
import {
degradeWalls, rekeyWallsAfterMoveChecked, wallRecordCarrierViolations,
setWallThickness, setWallThicknessForRoom, cmToField, wallCmToUnits,
multiWallNodesForGeometry,
wallEdgeBodies, wallBodiesGeometry, wallBodiesGeometryPath, wallBodiesUnionPath,
recutWallBodiesGeometry,
floorFootprintGeometry,
@@ -206,6 +207,7 @@ import {
} from './space-geometry';
import { optimizePlans, type OptimizeReport } from './plan-optimizer';
import {
WALL_SEGMENT_MODEL_VERSION,
adoptWallSegmentModelCandidateInPlace, commitWallSegmentModel,
fixedTopologyWallLineageHints, sanitizeRoomDraftPath,
resolveRoomOpeningHost, wallModelOffGridValueCount, WallSegmentModelError,
@@ -7430,7 +7432,12 @@ class HouseplanCard extends LitElement {
}
/** #329 П1-П5 over one space. Pure input, no side effects. */
private _junctionLimitViolations(config: any, spaceId: string): JunctionLimitViolation[] {
private _junctionLimitViolations(
config: any, spaceId: string,
/** #330 §4.7: an already computed masonry pass of THIS config (e.g. the
* resize preflight's artifact) — the union is never paid twice. */
sharedGeometry?: any,
): JunctionLimitViolation[] {
const space = (config?.spaces || []).find((item: any) => item?.id === spaceId);
if (!space) return [];
const cellCm = Number(space.cell_cm) > 0 ? Number(space.cell_cm) : 5;
@@ -7440,6 +7447,33 @@ class HouseplanCard extends LitElement {
...checkSegmentLengths(segments, cellCm, GRID_STEP_N),
...checkNodeDistances(segments, cellCm, GRID_STEP_N),
];
// #330 §4.7: without the shared passes every room recomputed the FULL
// junction topology and masonry union for itself — 144 rooms × a whole-
// plan polyclip pass cost 4.2 s per candidate. innerContourForRoom
// already accepts both caches (the render path uses them); compute each
// once per check and hand them to every room.
let sharedNodes: ReturnType<typeof multiWallNodesForGeometry> | null = null;
try {
sharedNodes = multiWallNodesForGeometry(
space.rooms || [], space.walls || [], [], GRID_STEP_N, cellCm, GRID_STEP_N, 1,
);
} catch { sharedNodes = null; }
let sharedRoomGeometry: any = sharedGeometry?.status === 'ok'
|| sharedGeometry?.status === 'degraded-extra'
? sharedGeometry.roomGeom : null;
// The masonry union is the expensive pass (seconds on a large plan), and
// innerContourForRoom only reaches for it when the junction topology is
// non-trivial — with no multi-wall nodes it returns the room's own inset
// early. Pay for the union once, and only when someone will read it.
if (!sharedRoomGeometry && sharedNodes && sharedNodes.nodes.length) {
try {
const geometry = wallBodiesGeometry(
space.rooms || [], space.walls || [], [], [], GRID_STEP_N, cellCm, GRID_STEP_N, 1,
);
sharedRoomGeometry = geometry?.status === 'ok' || geometry?.status === 'degraded-extra'
? geometry.roomGeom : null;
} catch { sharedRoomGeometry = null; }
}
for (const room of (space.rooms || [])) {
const roomId = String(room?.id || '');
if (!roomId) continue;
@@ -7448,6 +7482,7 @@ class HouseplanCard extends LitElement {
inner = innerContourForRoom(
space.rooms || [], roomId, space.walls || [], [],
GRID_STEP_N, cellCm, GRID_STEP_N, 1,
sharedRoomGeometry ?? undefined, sharedNodes,
);
} catch { inner = null; }
violations.push(...checkRoomClearance(roomId, inner, cellCm, GRID_STEP_N));
@@ -7463,26 +7498,52 @@ class HouseplanCard extends LitElement {
});
}
/** #330 §4.4: baseline violations per (document identity, epoch, space). */
private _junctionBaselineCache = new WeakMap<object, {
epoch: number; spaceId: string; violations: JunctionLimitViolation[];
}>();
/** #329: violations this write would ADD; inherited ones stay untouched. */
private _junctionLimitsIntroduced(
candidate: any, previousConfig: any, spaceId: string,
candidateGeometry?: any,
): JunctionLimitViolation[] {
// The baseline must be the previous document AS THE CANDIDATE SEES IT: a
// legacy space carries no wall catalogue at all, so comparing it raw with
// a migrated candidate reported every inherited short segment as new and
// refused legitimate resizes of real plans. Both sides therefore cross
// the same identity barrier first.
let inherited: JunctionLimitViolation[] = [];
try {
const migrated = commitWallSegmentModel(previousConfig).config;
inherited = this._junctionLimitViolations(migrated, spaceId);
} catch {
// An unmigratable baseline proves nothing about inheritance; never
// refuse the write on that basis.
return [];
// refused legitimate resizes of real plans. A LEGACY document therefore
// crosses the identity barrier first; a current-version document already
// carries the catalogue and is judged as-is (#330 §4.6) — a no-op
// re-migration cost 69 ms per pointermove on a 576-atom plan.
let inherited: JunctionLimitViolation[] | undefined;
// #330 §4.4: a resize gesture judges the SAME previous document on every
// pointermove. Object identity plus the config epoch make the cache both
// exact and self-invalidating — any real write bumps the epoch, and a
// freshly built baseline object simply never hits.
const cached = previousConfig && typeof previousConfig === 'object'
? this._junctionBaselineCache.get(previousConfig) : undefined;
if (cached && cached.epoch === this._cfgEpoch && cached.spaceId === spaceId) {
inherited = cached.violations;
}
if (!inherited) {
try {
const baseline = Number(previousConfig?.model_version || 0) >= WALL_SEGMENT_MODEL_VERSION
? previousConfig
: commitWallSegmentModel(previousConfig).config;
inherited = this._junctionLimitViolations(baseline, spaceId);
} catch {
// An unmigratable baseline proves nothing about inheritance; never
// refuse the write on that basis.
return [];
}
if (previousConfig && typeof previousConfig === 'object') {
this._junctionBaselineCache.set(previousConfig, {
epoch: this._cfgEpoch, spaceId, violations: inherited,
});
}
}
let next: JunctionLimitViolation[] = [];
try { next = this._junctionLimitViolations(candidate, spaceId); }
try { next = this._junctionLimitViolations(candidate, spaceId, candidateGeometry); }
catch { return []; }
return increasedViolations(next, inherited);
}
@@ -9053,8 +9114,10 @@ class HouseplanCard extends LitElement {
(space: any) => (space?.id === this._space ? sp : space),
),
};
// #330 §4.7: the preflight just built the candidate's masonry — hand it
// to П5 so the pointermove never pays the union twice.
const limited = this._junctionLimitsIntroduced(
limitCandidate, this._serverCfg, this._space,
limitCandidate, this._serverCfg, this._space, preflight.wallGeometry,
);
if (limited.length) {
this._rszLimitViolation = limited[0];
+74 -21
View File
@@ -108,6 +108,18 @@ const collinear = (left: LimitSegment, right: LimitSegment, toleranceDeg = 1): b
return Math.min(delta, 180 - delta) <= toleranceDeg;
};
const buildNodeIndex = (segments: readonly LimitSegment[]): Map<string, LimitSegment[]> => {
const byNode = new Map<string, LimitSegment[]>();
for (const item of segments) {
for (const point of [item.a, item.b]) {
const list = byNode.get(key(point));
if (list) list.push(item);
else byNode.set(key(point), [item]);
}
}
return byNode;
};
/**
* Length of the whole WALL a segment belongs to, not of the atom.
*
@@ -120,14 +132,13 @@ const collinear = (left: LimitSegment, right: LimitSegment, toleranceDeg = 1): b
*/
export function collinearRunLengthUnits(
segment: LimitSegment, segments: readonly LimitSegment[],
byNodeIndex?: Map<string, LimitSegment[]>,
): number {
const usable = usableSegments(segments);
const byNode = new Map<string, LimitSegment[]>();
for (const item of usable) {
for (const point of [item.a, item.b]) {
byNode.set(key(point), [...(byNode.get(key(point)) || []), item]);
}
}
// #330 §4.3: building the node index per SEGMENT made П3 quadratic
// (289 ms on 576 atoms). The caller that loops over every segment builds
// it once and passes it in; a direct call still builds its own.
const byNode = byNodeIndex ?? buildNodeIndex(usable);
const visited = new Set<LimitSegment>([segment]);
let total = length(segment.a, segment.b);
const walk = (from: LimitSegment, node: number[]): void => {
@@ -155,8 +166,9 @@ export function checkSegmentLengths(
): JunctionLimitViolation[] {
const violations: JunctionLimitViolation[] = [];
const usable = usableSegments(segments);
const byNode = buildNodeIndex(usable);
for (const segment of usable) {
const units = collinearRunLengthUnits(segment, usable);
const units = collinearRunLengthUnits(segment, usable, byNode);
const cm = (units / gridPitch) * (cellCm || 1);
const limit = Math.max(minLengthCm, Number(segment.cm) > 0 ? Number(segment.cm) : 0);
if (cm < limit - 1e-9) {
@@ -176,7 +188,15 @@ const distanceToSegment = (point: number[], a: number[], b: number[]): number =>
return Math.hypot(point[0] - (a[0] + dx * t), point[1] - (a[1] + dy * t));
};
/** П4: non-incident nodes and node-to-foreign-wall clearance (absolute cm). */
/**
* П4: non-incident nodes and node-to-foreign-wall clearance (absolute cm).
*
* #330 §4.5: the all-pairs form cost 104 ms on 576 atoms and grew
* quadratically. Nodes and segment bounding boxes (padded by the threshold)
* are hashed into a grid with the threshold as cell size, so each node is
* compared only against its 9-cell neighbourhood — verdicts are identical
* (equivalence pinned by unit tests and the TS↔Python parity suite).
*/
export function checkNodeDistances(
segments: readonly LimitSegment[],
cellCm: number,
@@ -190,25 +210,58 @@ export function checkNodeDistances(
nodes.set(key(segment.b), segment.b);
}
const minUnits = cmToUnits(minDistanceCm, cellCm, gridPitch);
const violations: JunctionLimitViolation[] = [];
const entries = [...nodes.entries()];
for (let i = 0; i < entries.length; i++) {
for (let j = i + 1; j < entries.length; j++) {
const distance = length(entries[i][1], entries[j][1]);
if (distance < minUnits - 1e-9) {
violations.push({
rule: 'distance', subject: `${entries[i][0]} ↔ ${entries[j][0]}`,
actual: (distance / gridPitch) * (cellCm || 1), limit: minDistanceCm,
});
const size = minUnits > EPS ? minUnits : 1;
const cellOf = (x: number, y: number): string =>
`${Math.floor(x / size)},${Math.floor(y / size)}`;
const nodeGrid = new Map<string, [string, number[]][]>();
for (const [nodeKey, point] of nodes) {
const cell = cellOf(point[0], point[1]);
const list = nodeGrid.get(cell);
if (list) list.push([nodeKey, point]);
else nodeGrid.set(cell, [[nodeKey, point]]);
}
const segmentGrid = new Map<string, LimitSegment[]>();
for (const segment of usable) {
const x0 = Math.min(segment.a[0], segment.b[0]) - minUnits;
const x1 = Math.max(segment.a[0], segment.b[0]) + minUnits;
const y0 = Math.min(segment.a[1], segment.b[1]) - minUnits;
const y1 = Math.max(segment.a[1], segment.b[1]) + minUnits;
for (let cx = Math.floor(x0 / size); cx <= Math.floor(x1 / size); cx++) {
for (let cy = Math.floor(y0 / size); cy <= Math.floor(y1 / size); cy++) {
const cell = `${cx},${cy}`;
const list = segmentGrid.get(cell);
if (list) list.push(segment);
else segmentGrid.set(cell, [segment]);
}
}
}
for (const [nodeKey, node] of nodes) {
for (const segment of usable) {
const violations: JunctionLimitViolation[] = [];
for (const [nodeKey, point] of nodes) {
const cx = Math.floor(point[0] / size);
const cy = Math.floor(point[1] / size);
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
for (const [otherKey, other] of nodeGrid.get(`${cx + dx},${cy + dy}`) || []) {
// Each unordered pair once: the lexicographic order replaces the
// i<j of the all-pairs loop, so the verdict set is identical.
if (nodeKey >= otherKey) continue;
const distance = length(point, other);
if (distance < minUnits - 1e-9) {
violations.push({
rule: 'distance', subject: `${nodeKey} ↔ ${otherKey}`,
actual: (distance / gridPitch) * (cellCm || 1), limit: minDistanceCm,
});
}
}
}
}
for (const segment of segmentGrid.get(`${cx},${cy}`) || []) {
// A node that belongs to the wall (either end) is a legal T-joint or
// corner — the rule is about NEAR misses, not incidence.
if (key(segment.a) === nodeKey || key(segment.b) === nodeKey) continue;
const distance = distanceToSegment(node, segment.a, segment.b);
const distance = distanceToSegment(point, segment.a, segment.b);
// Sitting exactly ON the wall is the other legal incidence: a T-joint
// into the middle of a foreign wall (spec П4). Only a real gap counts.
if (distance <= INCIDENT_EPS) continue;
+99
View File
@@ -310,3 +310,102 @@ test('AC10: привязка к решётке не утаскивает узе
assert.equal(after.distance || 0, 0,
'после Оптимизации узлы не сблизились под 5 см');
});
// --- #330: производительность без смены вердиктов ---
test('#330 AC2: П4 через bucket эквивалентен перебору на границах', async () => {
const { checkNodeDistances } = await import('../test-build/junction-limits.js');
// Простейший перебор — эталон, с которым обязана совпасть решётка.
const brute = (segments, cellCm) => {
const key = (p) => `${p[0].toFixed(6)},${p[1].toFixed(6)}`;
const nodes = new Map();
for (const s of segments) { nodes.set(key(s.a), s.a); nodes.set(key(s.b), s.b); }
const mu = cmToUnits(MIN_NODE_DISTANCE_CM, cellCm, PITCH);
const out = [];
const es = [...nodes.entries()];
for (let i = 0; i < es.length; i++) for (let j = i + 1; j < es.length; j++) {
const d = Math.hypot(es[i][1][0] - es[j][1][0], es[i][1][1] - es[j][1][1]);
if (d < mu - 1e-9) out.push('nn');
}
for (const [k, p] of nodes) for (const s of segments) {
if (key(s.a) === k || key(s.b) === k) continue;
const dx = s.b[0] - s.a[0], dy = s.b[1] - s.a[1];
const l2 = dx * dx + dy * dy;
const t = l2 <= 1e-18 ? 0 : Math.max(0, Math.min(1, ((p[0] - s.a[0]) * dx + (p[1] - s.a[1]) * dy) / l2));
const d = Math.hypot(p[0] - (s.a[0] + dx * t), p[1] - (s.a[1] + dy * t));
if (d > 1e-9 && d < mu - 1e-9) out.push('ns');
}
return out.length;
};
const cases = [
// ровно 5 см между узлами — проходит; 4 см — нет; T-стык; почти-касание
[{ id: 'a', a: [0, 0], b: [cm(300), 0], cm: 15 },
{ id: 'b', a: [0, cm(5)], b: [cm(300), cm(5)], cm: 15 }],
[{ id: 'a', a: [0, 0], b: [cm(300), 0], cm: 15 },
{ id: 'b', a: [0, cm(4)], b: [cm(300), cm(4)], cm: 15 }],
[{ id: 'a', a: [0, 0], b: [cm(300), 0], cm: 15 },
{ id: 'b', a: [cm(150), 0], b: [cm(150), cm(300)], cm: 15 }],
[{ id: 'a', a: [0, 0], b: [cm(300), 0], cm: 15 },
{ id: 'b', a: [cm(150), cm(4)], b: [cm(450), cm(4)], cm: 15 }],
// узлы в разных ячейках решётки, но ближе порога через границу ячейки
[{ id: 'a', a: [cm(4.9), 0], b: [cm(304.9), 0], cm: 15 },
{ id: 'b', a: [cm(9.7), cm(0.5)], b: [cm(309.7), cm(0.5)], cm: 15 }],
];
for (const [index, segments] of cases.entries()) {
const grid = checkNodeDistances(segments, CELL, PITCH).length;
assert.equal(grid, brute(segments, CELL),
`кейс ${index}: решётка и перебор разошлись`);
}
});
test('#330 AC2: индекс byNode не меняет вердикт П3', async () => {
const { checkSegmentLengths, collinearRunLengthUnits } =
await import('../test-build/junction-limits.js');
// Доборный атом 5 см при перепаде толщин остаётся законным (АС3b #329),
// одиночные 19 см — нарушением; прямой вызов без индекса согласован.
const run = [
{ id: 'long', a: [0, 0], b: [cm(349), 0], cm: 30 },
{ id: 'filler', a: [cm(349), 0], b: [cm(354), 0], cm: 30 },
{ id: 'thin', a: [cm(354), 0], b: [cm(554), 0], cm: 20 },
];
assert.equal(checkSegmentLengths(run, CELL, PITCH).length, 0);
const short = [{ id: 's', a: [0, 0], b: [cm(19), 0], cm: 15 }];
assert.equal(checkSegmentLengths(short, CELL, PITCH).length, 1);
const direct = collinearRunLengthUnits(run[1], run);
assert.ok(Math.abs(direct - cmToUnits(354, CELL, PITCH)) < 1e-9,
'прямой вызов без индекса меряет тот же прогон');
});
test('#330 AC4: baseline считается один раз для одного документа и эпохи', async () => {
// Юнит на чистую механику кэша: WeakMap по идентичности документа + эпоха.
// Полный класс карточки здесь не поднять — механика воспроизводится тем же
// алгоритмом, каким её исполняет _junctionLimitsIntroduced.
const { commitWallSegmentModel } = await import('../test-build/wall-segment-model.js');
const cache = new WeakMap();
let migrations = 0;
const introduced = (candidate, previousConfig, spaceId, epoch) => {
const cached = cache.get(previousConfig);
let inherited = cached && cached.epoch === epoch && cached.spaceId === spaceId
? cached.violations : undefined;
if (!inherited) {
const baseline = Number(previousConfig?.model_version || 0) >= 9
? previousConfig
: (migrations++, commitWallSegmentModel(previousConfig).config);
inherited = [];
void baseline;
cache.set(previousConfig, { epoch, spaceId, violations: inherited });
}
return inherited;
};
const legacyDoc = { model_version: 0, spaces: [{ id: 's', cell_cm: 5, view_box: [0, 0, 1, 1], rooms: [], walls: [], openings: [], room_drafts: [], partitions: [], wall_columns: [] }], markers: [], settings: {} };
// Жест: десять move с одним и тем же документом и эпохой — одна миграция.
for (let move = 0; move < 10; move++) introduced({}, legacyDoc, 's', 1);
assert.equal(migrations, 1, 'десять move — одна миграция baseline');
// Реальный write меняет эпоху — кэш инвалидируется.
introduced({}, legacyDoc, 's', 2);
assert.equal(migrations, 2, 'новая эпоха пересчитывает baseline');
// Документ текущей версии не мигрируется вовсе (#330 §4.6).
const v9doc = { ...legacyDoc, model_version: 9 };
introduced({}, v9doc, 's', 3);
assert.equal(migrations, 2, 'v9-документ не мигрируется');
});
+148
View File
@@ -261,3 +261,151 @@ process.stdin.on('end', () => {
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == mine
# --- #330: производительность без смены вердиктов ---
def test_330_current_version_document_is_judged_as_is():
"""AC5: v9-документ «как есть» и «через миграцию» дают один вердикт."""
spike = triangle()
legacy = {"spaces": [room_space(
[{"id": "r1", "name": "a", "area": None, "poly": spike}],
walls_of(spike, "w"),
)]}
migrated, _ = jl.commit_wall_segment_model(json.loads(json.dumps(legacy)))
as_is = jl.space_violation_counts(jl._migrated_spaces(migrated))
forced, _ = jl.commit_wall_segment_model(json.loads(json.dumps(migrated)))
through = jl.space_violation_counts(jl._migrated_spaces(forced))
assert as_is == through
# Как есть — значит БЕЗ вызова миграции: подмена должна не выполниться.
calls = []
original = jl.commit_wall_segment_model
jl.commit_wall_segment_model = lambda cfg: calls.append(1) or original(cfg)
try:
jl._migrated_spaces(migrated)
finally:
jl.commit_wall_segment_model = original
assert calls == [], "v9-документ не должен мигрироваться повторно"
def test_330_baseline_counts_replace_the_previous_document():
"""AC3: с baseline_counts вердикт идентичен пути с previous, а сам
previous не читается вовсе."""
spike = triangle()
previous = {"spaces": [room_space(
[{"id": "r1", "name": "a", "area": None, "poly": spike}],
walls_of(spike, "w"),
)]}
candidate = json.loads(json.dumps(previous))
candidate["spaces"][0]["rooms"][0]["name"] = "b"
counts = jl.validate_junction_limits(
json.loads(json.dumps(previous)), json.loads(json.dumps(previous)),
)
# Эквивалентность: унаследованное нарушение проходит обоими путями.
jl.validate_junction_limits(candidate, json.loads(json.dumps(previous)))
jl.validate_junction_limits(candidate, None, baseline_counts=counts)
# Новое нарушение отклоняется обоими путями.
box = square()
clean = {"spaces": [room_space(
[{"id": "r1", "name": "box", "area": None, "poly": box}],
walls_of(box, "b"),
)]}
clean_counts = jl.validate_junction_limits(
json.loads(json.dumps(clean)), json.loads(json.dumps(clean)),
)
broken = json.loads(json.dumps(clean))
broken["spaces"][0]["rooms"].append(
{"id": "r2", "name": "spike", "area": None, "poly": spike}
)
broken["spaces"][0]["walls"].extend(walls_of(spike, "w"))
for kwargs in ({"previous": json.loads(json.dumps(clean))},
{"baseline_counts": clean_counts}):
with pytest.raises(jl.JunctionLimitError):
jl.validate_junction_limits(json.loads(json.dumps(broken)), **kwargs)
# baseline_counts действительно замещает previous: считаем обращения.
calls = []
original = jl._migrated_spaces
def spy(config):
calls.append(config)
return original(config)
jl._migrated_spaces = spy
try:
jl.validate_junction_limits(
json.loads(json.dumps(candidate)), None, baseline_counts=counts,
)
finally:
jl._migrated_spaces = original
assert len(calls) == 1, "мигрируется только кандидат"
def test_330_p4_bucket_matches_bruteforce_on_cell_borders():
"""П4-решётка обязана совпасть с перебором и через границы ячеек."""
def brute_count(space):
segs = jl.limit_segments(space)
nodes = {}
for seg in segs:
nodes[jl._key(seg["a"])] = seg["a"]
nodes[jl._key(seg["b"])] = seg["b"]
mu = jl.cm_to_units(jl.MIN_NODE_DISTANCE_CM, space["cell_cm"])
count = 0
entries = list(nodes.items())
for i in range(len(entries)):
for j in range(i + 1, len(entries)):
if jl._length(entries[i][1], entries[j][1]) < mu - 1e-9:
count += 1
for node_key, point in nodes.items():
for seg in segs:
if jl._key(seg["a"]) == node_key or jl._key(seg["b"]) == node_key:
continue
d = jl._distance_to_segment(point, seg["a"], seg["b"])
if d > 1e-9 and d < mu - 1e-9:
count += 1
return count
def sp(pairs):
return room_space([], [
{"key": f"w{i}", "a": a, "b": b, "cm": 15}
for i, (a, b) in enumerate(pairs)
], legacy=False)
cases = [
sp([(( 0.0, 0.0), (cm(300), 0.0)), ((0.0, cm(5)), (cm(300), cm(5)))]),
sp([(( 0.0, 0.0), (cm(300), 0.0)), ((0.0, cm(4)), (cm(300), cm(4)))]),
sp([(( 0.0, 0.0), (cm(300), 0.0)), ((cm(150), 0.0), (cm(150), cm(300)))]),
sp([((cm(4.9), 0.0), (cm(304.9), 0.0)),
((cm(9.7), cm(0.5)), (cm(309.7), cm(0.5)))]),
]
for index, space in enumerate(cases):
segs = jl.limit_segments(space)
grid = len(jl.check_node_distances(segs, CELL))
assert grid == brute_count(space), f"кейс {index}: решётка != перебор"
def test_330_ac1_validator_chain_is_cheap_without_documents():
"""AC1 (модульная половина): с baseline_counts и v9-кандидатом validate
не выполняет ни одной миграции — время линейно от проверок, не от
_atomize. Полный loop-замер живёт в ws_config_set (executor), где HA
недоступен этому набору; здесь пинится сама причина дороговизны."""
import time as _time
box = square()
doc = {"spaces": [room_space(
[{"id": "r1", "name": "box", "area": None, "poly": box}],
walls_of(box, "b"),
)]}
migrated, _ = jl.commit_wall_segment_model(json.loads(json.dumps(doc)))
counts = jl.validate_junction_limits(
json.loads(json.dumps(migrated)), json.loads(json.dumps(migrated)),
)
calls = []
original = jl.commit_wall_segment_model
jl.commit_wall_segment_model = lambda cfg: calls.append(1) or original(cfg)
try:
start = _time.perf_counter()
jl.validate_junction_limits(
json.loads(json.dumps(migrated)), None, baseline_counts=counts,
)
elapsed = _time.perf_counter() - start
finally:
jl.commit_wall_segment_model = original
assert calls == [], "тёплый путь не мигрирует ни один документ"
assert elapsed < 0.5, f"тёплый validate неожиданно дорог: {elapsed:.3f}s"