mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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 }, {
|
||||
|
||||
Vendored
+75
-75
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Сохранение и изменение размеров на больших планах больше не подвешивают
|
||||
Home Assistant: проверка ограничений стыков (#329) ускорена на порядки —
|
||||
тяжёлая часть ушла из основного цикла сервера, повторные вычисления
|
||||
кэшируются ([#330](https://github.com/Matysh/houseplan-card/issues/330)).
|
||||
|
||||
- Острые углы комнат снова выглядят честно: очень острая вершина
|
||||
распадалась на «трезубец» из пиков и вырезов, а рисование теперь не
|
||||
разрешает саму причину — стены под углом меньше 15°, больше шести стен в
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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-документ не мигрируется');
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user