fix: junction limits are honest at the boundaries (#331)

Six normative cuts, both mirrors symmetric (spec revision 3):

- §2.1 node keys quantise to 1e-7 with the repository's canonicalisation
  formula (sign·floor(|v|·1e7+0.5)/1e7, -0 normalised) — toFixed(6) keys
  split one node into two on floating debris and produced two false П4
  refusals on a legitimate resize (reproduced: -1e-8 vs 0). Node pairs
  within 2e-7 of each other (raw coordinates) are ONE node, and the
  node-to-wall incidence uses the same quantum.
- §2.2 a ~0° wedge IS a violation: two rays leaving a node the same way are
  a duplicated or overlaid wall (a butt joint yields 180°, never 0°) — the
  worst degenerate case was invisible while 0.5° was refused.
- §2.3/§2.4 the wall run is an iterative edge walk over the collinear
  component: no recursion (10 000 atoms answered, not RangeError), no
  silently dropped fork (the old .find lost every branch but the first),
  O(E) by construction, and collinearity is measured against the BASE
  segment's axis so an arc of 0.9°-per-atom pieces cannot pose as one wall.
- §2.5 an exception while judging the CANDIDATE refuses the write with the
  junction.limit_check_failed toast (fail-closed, as the #278 guard); the
  baseline branch stays fail-open by design and the smoke proves the
  asymmetry by breaking only the second call of the deterministic pair.
- §2.6 the python mirror narrows its except on the candidate side only:
  a genuine migration bug (TypeError) surfaces as an honest WS error, while
  a previous-side bug keeps the wide "no baseline" fallback — the two AC6
  cases pin the asymmetry so swapped sides turn a unit red.

Parity fixtures gain the new boundary classes (debris node, duplicate wall,
collinear fork); four new mutants pin the filter, the key precision, the
dropped branch and the fail-open hole.

Issue: #331
User-Visible: yes
This commit is contained in:
Codex
2026-08-28 04:39:46 +03:00
parent c231602a2b
commit 58f3acbbde
17 changed files with 582 additions and 217 deletions
File diff suppressed because one or more lines are too long
+66 -31
View File
@@ -18,7 +18,13 @@ from __future__ import annotations
import math
from .wall_segment_model import WALL_SEGMENT_MODEL_VERSION, commit_wall_segment_model
import logging
from .wall_segment_model import (
WALL_SEGMENT_MODEL_VERSION, WallSegmentMigrationError, commit_wall_segment_model,
)
_LOGGER = logging.getLogger(__name__)
MIN_JUNCTION_ANGLE_DEG = 15.0
MAX_JUNCTION_VALENCE = 6
@@ -28,8 +34,10 @@ MIN_NODE_DISTANCE_CM = 5.0
GRID_STEP_N = 1 / 240
_EPS = 1e-9
# Below this a node is ON the wall (T-joint), not near it.
_INCIDENT_EPS = 1e-9
# #331 §2.1: below this two points are ONE node / a node is ON the wall —
# floating debris of pre-canonicalisation arithmetic, not a near miss.
_INCIDENT_EPS = 2e-7
_KEY_FACTOR = 1e7
class JunctionLimitError(ValueError):
@@ -58,8 +66,21 @@ def _finite_point(point: object) -> bool:
)
def _quantize_key_coord(value: float) -> float:
"""#331 §2.1: the canonicalisation formula — sign*floor(|v|*f+0.5)/f.
Native round() is banker's rounding and parts ways with JS Math-style
rounding on .5 ticks; coordinate canonicalisation already encodes this
parity lesson, and node keys must follow it. -0 normalises to 0.
"""
rounded = math.copysign(
math.floor(abs(value) * _KEY_FACTOR + 0.5), value,
) / _KEY_FACTOR
return 0.0 if rounded == 0 else rounded
def _key(point) -> str:
return f"{point[0]:.6f},{point[1]:.6f}"
return f"{_quantize_key_coord(point[0])},{_quantize_key_coord(point[1])}"
def _length(a, b) -> float:
@@ -127,8 +148,9 @@ def check_nodes(segments: list[dict]) -> list[tuple[str, str, float, float]]:
if index == len(ordered) - 1:
delta += math.pi * 2
degrees = (delta * 180) / math.pi
# Collinear rays of one straight wall are a 180° pair, not a wedge.
if _EPS < degrees < smallest:
# #331 §2.2: a ~0° delta IS a violation — same-direction rays are
# a duplicated or overlaid wall (a butt joint yields 180°, not 0°).
if degrees < smallest:
smallest = degrees
if smallest < MIN_JUNCTION_ANGLE_DEG - 1e-9:
violations.append(("angle", node, smallest, MIN_JUNCTION_ANGLE_DEG))
@@ -161,38 +183,34 @@ def collinear_run_length_units(
) -> 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.
#331 §2.3/§2.4: an iterative edge walk over the collinear component — no
recursion (a 10 000-atom chain must answer, not overflow), no
combinatorial DFS (every atom joins at most once, O(E)), no silently
dropped fork. Collinearity is measured against the BASE segment's axis,
so an arc of small per-atom turns cannot pose as one straight wall.
#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.
#330 §4.3: the per-check caller builds the node index once.
"""
by_node = by_node_index if by_node_index is not None else _build_node_index(segments)
visited = [segment]
visited = {id(segment)}
total = _length(segment["a"], segment["b"])
def walk(current: dict, node: str) -> None:
nonlocal total
for candidate in by_node.get(node, []):
if any(candidate is seen for seen in visited):
frontier = [segment["a"], segment["b"]]
base_cm = float(segment.get("cm") or 0)
while frontier:
node = frontier.pop()
for candidate in by_node.get(_key(node), ()):
if id(candidate) in visited:
continue
if not _collinear(candidate, current):
if not _collinear(candidate, segment):
continue
if float(candidate.get("cm") or 0) != float(current.get("cm") or 0):
if float(candidate.get("cm") or 0) != base_cm:
continue
visited.append(candidate)
visited.add(id(candidate))
total += _length(candidate["a"], candidate["b"])
walk(candidate, _key(candidate["b"])
if _key(candidate["a"]) == node else _key(candidate["a"]))
return
walk(segment, _key(segment["a"]))
walk(segment, _key(segment["b"]))
frontier.append(candidate["a"])
frontier.append(candidate["b"])
return total
def check_segment_lengths(
segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N,
) -> list[tuple[str, str, float, float]]:
@@ -263,6 +281,10 @@ def check_node_distances(
if node_key >= other_key:
continue
distance = _length(point, other)
# #331 §2.1: raw debris within the incidence quantum is ONE
# node on two neighbouring keys — never a near miss.
if distance <= _INCIDENT_EPS:
continue
if distance < min_units - 1e-9:
violations.append((
"distance", f"{node_key} ↔ {other_key}",
@@ -297,7 +319,7 @@ def space_violations(space: dict) -> list[tuple[str, str, float, float]]:
]
def _migrated_spaces(config: dict | None) -> dict[str, dict]:
def _migrated_spaces(config: dict | None, *, side: str = "previous") -> dict[str, dict]:
"""Spaces of one document AFTER the wall-segment migration, by id.
The limits read `wall_segments`, so a document that predates the catalogue
@@ -325,7 +347,20 @@ def _migrated_spaces(config: dict | None) -> dict[str, dict]:
else:
try:
migrated, _ = commit_wall_segment_model(config)
except Exception: # noqa: BLE001 — see the docstring: not our verdict
except (WallSegmentMigrationError, ValueError) as error:
# The wall-model barrier owns this verdict and reports it with its
# own code; here it merely means "no catalogue to read".
_LOGGER.debug("junction limits: %s migration fell back: %s", side, error)
migrated = config
except Exception:
# #331 §2.6 (AC6): a genuine migration bug on the CANDIDATE side
# must surface as an honest WS error, not a silent "no
# violations". The PREVIOUS side keeps the wide fallback — an
# unprovable inheritance is no reason to block an unrelated write
# (the exact P1 symptom this task fixes).
if side == "candidate":
raise
_LOGGER.debug("junction limits: previous-side migration bug swallowed by design")
migrated = config
return {
str(space.get("id")): space
@@ -371,7 +406,7 @@ def validate_junction_limits(
old_counts = baseline_counts
else:
old_counts = space_violation_counts(_migrated_spaces(previous))
new_spaces = _migrated_spaces(config)
new_spaces = _migrated_spaces(config, side="candidate")
candidate_counts: dict[str, dict[str, int]] = {}
for space_id, space in new_spaces.items():
# A brand-new space has nothing to inherit from — but neither is a
+25
View File
@@ -95,6 +95,31 @@ const out = await page.evaluate(async () => {
result.thicknessRefusedByLimit = ![...catalogue, ...legacy].includes(100);
result.thicknessToastShown = toasts.some((text) => text.includes('25'));
// #331 AC5: исключение при проверке КАНДИДАТА — отказ с тостом
// limit_check_failed (fail-closed, как гард #278), baseline остаётся
// fail-open. Ломаем только вызовы с кандидатом: baseline — это _serverCfg.
await reset();
// Ломаем ТОЛЬКО кандидата. Порядок вызовов в барьере детерминирован:
// №1 — baseline (кэш пуст после reset: _serverCfg — новый объект),
// №2 — кандидат. Падение baseline легально пропускает запись целиком
// (недоказуемое наследование не повод для отказа — иначе унаследованные
// нарушения читались бы «новыми»), поэтому асимметрию §2.5 доказывает
// именно падение второго вызова.
const originalViolations = card._junctionLimitViolations.bind(card);
let violationCalls = 0;
card._junctionLimitViolations = (...args) => {
violationCalls += 1;
if (violationCalls >= 2) throw new Error('synthetic check failure');
return originalViolations(...args);
};
toasts.length = 0;
result.checkFailureRefusesWrite = !(await drawRoom([
[400, 600], [400 + cm(300), 600], [400 + cm(300), 600 - cm(300)],
[400, 600 - cm(300)],
], 'failClosed'));
result.checkFailureToastShown = toasts.some((text) => /не удалось|could not run/i.test(text));
card._junctionLimitViolations = originalViolations;
// §3: унаследованное нарушение не блокирует несвязанную запись.
card._serverCfg = { spaces: [{
id: 'limits', title: 'Limits', cell_cm: CELL, view_box: [0, 0, 1, 1],
+76 -76
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- The wall-junction checks are honest at the boundaries: false refusals on
microscopic coordinate noise are gone, a wall duplicated along one line is
now visible, very long walls no longer crash the check, and an internal
check error refuses the write instead of waving it through
([#331](https://github.com/Matysh/houseplan-card/issues/331)).
- 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
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- Проверки стыков стен стали честными на границах: ложные отказы на
микроскопических погрешностях координат устранены, дубль стены по одной
линии теперь виден, очень длинные стены не роняют проверку, а внутренняя
ошибка проверки больше не пропускает запись
([#331](https://github.com/Matysh/houseplan-card/issues/331)).
- Сохранение и изменение размеров на больших планах больше не подвешивают
Home Assistant: проверка ограничений стыков (#329) ускорена на порядки —
тяжёлая часть ушла из основного цикла сервера, повторные вычисления
+4
View File
@@ -401,6 +401,10 @@ filler segment that compensates a thickness step is legal as long as the whole
wall is longer than 20 cm. A T-joint (a wall end landing on the middle of
another wall) is not forbidden by the distance rule.
If the check itself cannot run (an internal error), the change is not saved
either — a "The junction check could not run" toast appears: the editor
never waves a write through on faith.
The check runs on writes only. An already saved plan is never re-judged:
migration, import and backup restore are never blocked, and an edit that does
not touch the offending element passes as usual. The refusal appears where you
+4
View File
@@ -436,6 +436,10 @@ Undo оптимизации.
стена целиком длиннее 20 см. Т-стык (конец стены упирается в середину чужой)
правилом дистанции не запрещён.
Если проверку выполнить не удалось (внутренняя ошибка), изменение тоже не
сохраняется — появляется тост «Проверку стыков выполнить не удалось»:
редактор никогда не пропускает запись «на авось».
Проверка работает только на записи. Уже сохранённый план не переоценивается:
миграция, импорт и восстановление из бэкапа не блокируются никогда, а правка,
не трогающая нарушающий элемент, проходит как обычно. Отказ виден там, где вы
+11 -11
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "dbac4a84d0eb558e66066f5072363977ff4a78c0a032e6f2b9a5d0848cd20f0f",
"sourceFingerprint": "7143ec154b3a4340ba7c23c47bc6f72fa68b81a03980a44b0eedc3f043f7305b",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,7 +14,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dbac4a84d0eb558e66066f5072363977ff4a78c0a032e6f2b9a5d0848cd20f0f",
"sourceSha256": "7143ec154b3a4340ba7c23c47bc6f72fa68b81a03980a44b0eedc3f043f7305b",
"imageSha256": "0ca982495a7749907c48ebbddfd089c54385a4347f79f64e94af2c5a8d5c03eb"
},
"view-touch": {
@@ -25,7 +25,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dbac4a84d0eb558e66066f5072363977ff4a78c0a032e6f2b9a5d0848cd20f0f",
"sourceSha256": "7143ec154b3a4340ba7c23c47bc6f72fa68b81a03980a44b0eedc3f043f7305b",
"imageSha256": "1c8e28ff34736b936ba7c9ec047d1da27aaf6fbb01411155f2d5c05282c02c41"
},
"space-create": {
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dbac4a84d0eb558e66066f5072363977ff4a78c0a032e6f2b9a5d0848cd20f0f",
"sourceSha256": "7143ec154b3a4340ba7c23c47bc6f72fa68b81a03980a44b0eedc3f043f7305b",
"imageSha256": "a55b463055a565892ca139014e314a8f63fb5301c87fff2e48d7e69210fe9b6c"
},
"room-contour-close": {
@@ -47,7 +47,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dbac4a84d0eb558e66066f5072363977ff4a78c0a032e6f2b9a5d0848cd20f0f",
"sourceSha256": "7143ec154b3a4340ba7c23c47bc6f72fa68b81a03980a44b0eedc3f043f7305b",
"imageSha256": "dfad9a27371cfa397a3fd8dce7005f42d24649743f5322105ee894067d3a9f16"
},
"plan-context-tray": {
@@ -58,7 +58,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dbac4a84d0eb558e66066f5072363977ff4a78c0a032e6f2b9a5d0848cd20f0f",
"sourceSha256": "7143ec154b3a4340ba7c23c47bc6f72fa68b81a03980a44b0eedc3f043f7305b",
"imageSha256": "b75f348c632296da5c2fc142d53df613f757e1516acac315be50f5b4557729cc"
},
"device-editor": {
@@ -69,7 +69,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dbac4a84d0eb558e66066f5072363977ff4a78c0a032e6f2b9a5d0848cd20f0f",
"sourceSha256": "7143ec154b3a4340ba7c23c47bc6f72fa68b81a03980a44b0eedc3f043f7305b",
"imageSha256": "6b9cd281450e7782cff292f11efc58d227bedd0df86afc3be987feb2edec9dc3"
},
"device-display-preview": {
@@ -80,7 +80,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dbac4a84d0eb558e66066f5072363977ff4a78c0a032e6f2b9a5d0848cd20f0f",
"sourceSha256": "7143ec154b3a4340ba7c23c47bc6f72fa68b81a03980a44b0eedc3f043f7305b",
"imageSha256": "6f628abe6b89d7c4b352e6d73ac0b126526f6fdba5927bb774c5b884e484ca19"
},
"background-editor": {
@@ -91,7 +91,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dbac4a84d0eb558e66066f5072363977ff4a78c0a032e6f2b9a5d0848cd20f0f",
"sourceSha256": "7143ec154b3a4340ba7c23c47bc6f72fa68b81a03980a44b0eedc3f043f7305b",
"imageSha256": "a1d1a905f1410f73231d2ea5d1dfbd8ae79c2fe8b0a79cff87ebb977ac7a3103"
},
"room-card": {
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dbac4a84d0eb558e66066f5072363977ff4a78c0a032e6f2b9a5d0848cd20f0f",
"sourceSha256": "7143ec154b3a4340ba7c23c47bc6f72fa68b81a03980a44b0eedc3f043f7305b",
"imageSha256": "48a5685039a5e0de2a28190d857b193c86e2e63606e3c7a8c40fc9d2b190da4e"
},
"device-info": {
@@ -113,7 +113,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "dbac4a84d0eb558e66066f5072363977ff4a78c0a032e6f2b9a5d0848cd20f0f",
"sourceSha256": "7143ec154b3a4340ba7c23c47bc6f72fa68b81a03980a44b0eedc3f043f7305b",
"imageSha256": "29a342811aaedaebc54ba337792d60cb454a7cf4c3063b210f6c4bafe0a4d87b"
}
}
+5
View File
@@ -56,6 +56,11 @@
чужой стены остаётся T-стыком и валидно — правило про «почти-касание» без
инцидентности). Порог абсолютный, в сантиметрах, и НЕ зависит от
`cell_cm` пространства.
- **Точность границ (#331):** ключ узла квантуется к 1e-7 формулой
канонизации (`sign·floor(|v|·1e7+0.5)/1e7`, −0 → 0); пары точек ближе
2e-7 (сырые координаты) — один узел, а не «почти-касание»; клин ровно 0°
(дубль/наложение стены) — нарушение П1 с actual≈0; длина П3 меряется по
коллинеарной (±1° к БАЗЕ прогона) компоненте связности одной толщины.
- **П5. Просвет комнаты:** после вычета тел стен внутренняя область комнаты
непуста и её площадь ≥ **25 см²**. Порог абсолютный и не зависит от
`cell_cm`.
+54
View File
@@ -535,6 +535,60 @@ export const MUTANTS = [
replace: ' // mutant: pointer capture removed\n const plan = resolution.plan;',
}],
},
{
id: 'junction-limit-zero-wedge-invisible',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#331 AC2" test/junction-limits.test.mjs',
because: 'restoring the degrees > EPS filter makes an exact duplicate wall invisible to '
+ 'П1 again — the worst degenerate junction passes while a 0.5° wedge is refused (#331 §2.2)',
patches: [{
file: 'src/junction-limits.ts',
find: ' if (degrees < smallest) smallest = degrees;',
replace: ' if (degrees > EPS && degrees < smallest) smallest = degrees;',
}],
},
{
id: 'junction-limit-key-precision-lost',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#331 AC1" test/junction-limits.test.mjs',
because: 'toFixed(6) keys split one node into two on floating debris and forked on the '
+ 'sign of zero — two false П4 refusals on a legitimate resize (#331 §2.1)',
patches: [{
file: 'src/junction-limits.ts',
find: 'const key = (point: number[]): string =>\n'
+ ' `${quantizeKeyCoord(point[0])},${quantizeKeyCoord(point[1])}`;',
replace: 'const key = (point: number[]): string => '
+ '`${point[0].toFixed(6)},${point[1].toFixed(6)}`;',
}],
},
{
id: 'junction-limit-branch-dropped',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#331 AC3" test/junction-limits.test.mjs',
because: 'taking only the first collinear continuation silently drops every fork and '
+ 'understates the wall run — a legitimate thickness-step filler gets refused (#331 §2.3)',
patches: [{
file: 'src/junction-limits.ts',
find: ' visited.add(candidate);\n'
+ ' total += length(candidate.a, candidate.b);\n'
+ ' frontier.push(candidate.a, candidate.b);',
replace: ' visited.add(candidate);\n'
+ ' total += length(candidate.a, candidate.b);\n'
+ ' frontier.push(candidate.a, candidate.b);\n'
+ ' break;',
}],
},
{
id: 'junction-limit-candidate-fail-open',
guard: 'node demo/smoke_junction_limits.mjs',
because: 'an exception while judging the candidate must refuse the write — a silent pass '
+ 'reopens the fail-open hole the #278 guard closed for geometry (#331 §2.5)',
patches: [{
file: 'src/houseplan-card.ts',
find: " return [{ rule: 'check_failed', subject: spaceId, actual: 0, limit: 0 }];",
replace: ' return [];',
}],
},
{
id: 'junction-limit-p3-quadratic-again',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+7 -1
View File
@@ -7553,7 +7553,13 @@ class HouseplanCard extends LitElement {
}
let next: JunctionLimitViolation[] = [];
try { next = this._junctionLimitViolations(candidate, spaceId, candidateGeometry); }
catch { return []; }
catch {
// #331 §2.5: an exception while judging the CANDIDATE is a refusal,
// not a pass — the same fail-closed stance as the #278 geometry guard.
// The baseline branch above stays fail-open on purpose: an unprovable
// inheritance is no reason to block an unrelated write.
return [{ rule: 'check_failed', subject: spaceId, actual: 0, limit: 0 }];
}
return increasedViolations(next, inherited);
}
+1
View File
@@ -589,6 +589,7 @@
"junction.limit_length": "The wall segment is too short: {actual} cm, minimum {limit} cm.",
"junction.limit_distance": "Walls and nodes are too close: {actual} cm, minimum {limit} cm.",
"junction.limit_clearance": "No room interior is left: {actual} cm², minimum {limit} cm².",
"junction.limit_check_failed": "The junction check could not run — the change was not saved",
"toast.wall_model_migration_blocked": "The wall model could not be updated: {reason}. The plan was not changed. Run “Optimize plans”; if the error repeats, fix the conflicting wall geometry.",
"toast.wall_model_client_outdated": "Update the card and reload the page before editing the plan.",
"wall_model.reason.invalid-room": "invalid room contour",
+1
View File
@@ -589,6 +589,7 @@
"junction.limit_length": "Слишком короткий участок стены: {actual} см, минимум {limit} см.",
"junction.limit_distance": "Стены и узлы слишком близко: {actual} см, минимум {limit} см.",
"junction.limit_clearance": "Внутри комнаты не остаётся места: {actual} см², минимум {limit} см².",
"junction.limit_check_failed": "Проверку стыков выполнить не удалось — изменение не сохранено",
"toast.wall_model_migration_blocked": "Не удалось обновить модель стен: {reason}. План не изменён. Запустите «Оптимизировать планы»; если ошибка повторится, исправьте конфликтующую геометрию стен.",
"toast.wall_model_client_outdated": "Обновите карточку и перезагрузите страницу перед редактированием плана.",
"wall_model.reason.invalid-room": "некорректный контур комнаты",
+49 -20
View File
@@ -19,7 +19,9 @@ export const MIN_NODE_DISTANCE_CM = 5;
export const MIN_ROOM_CLEARANCE_CM2 = 25;
export type JunctionLimitRule =
| 'angle' | 'valence' | 'length' | 'distance' | 'clearance';
| 'angle' | 'valence' | 'length' | 'distance' | 'clearance'
/** #331 §2.5: the check itself failed — the write is refused, not waved through. */
| 'check_failed';
export interface JunctionLimitViolation {
rule: JunctionLimitRule;
@@ -40,9 +42,26 @@ export interface LimitSegment {
}
const EPS = 1e-9;
/** Below this a node is ON the wall (T-joint), not near it. */
const INCIDENT_EPS = 1e-9;
const key = (point: number[]): string => `${point[0].toFixed(6)},${point[1].toFixed(6)}`;
/**
* #331 §2.1: below this two points are ONE node / a node is ON the wall —
* floating debris of pre-canonicalisation arithmetic, not a near miss. Two
* orders above the storage grid (1e-9), orders below any meaningful plan
* gap (the smallest rule threshold is 5 cm ≈ 4e-4).
*/
const INCIDENT_EPS = 2e-7;
const KEY_FACTOR = 1e7;
/**
* #331 §2.1: quantised with the repository's canonicalisation formula —
* native Math.round and Python round() part ways on .5 ticks (banker's
* rounding), the exact parity lesson coordinate-canonicalization encodes.
* `-0` normalises to `0` so the string key cannot fork on the sign of zero.
*/
const quantizeKeyCoord = (value: number): number => {
const rounded = Math.sign(value) * Math.floor(Math.abs(value) * KEY_FACTOR + 0.5) / KEY_FACTOR;
return Object.is(rounded, -0) ? 0 : rounded;
};
const key = (point: number[]): string =>
`${quantizeKeyCoord(point[0])},${quantizeKeyCoord(point[1])}`;
const length = (a: number[], b: number[]): number => Math.hypot(b[0] - a[0], b[1] - a[1]);
/** Normalised units per centimetre for a space (`cell_cm` on a grid pitch). */
@@ -84,10 +103,11 @@ export function checkNodes(
const next = sorted[(index + 1) % sorted.length];
let delta = next - sorted[index];
if (index === sorted.length - 1) delta += Math.PI * 2;
// Collinear rays of one straight wall passing through the node are a
// 180° pair, not a violation; only a genuine narrow wedge counts.
// #331 §2.2: a ~0° delta IS a violation — two rays leaving one node in
// the same direction are a duplicated or overlaid wall (a collinear
// butt joint of one straight wall yields a 180° pair, never 0°).
const degrees = (delta * 180) / Math.PI;
if (degrees > EPS && degrees < smallest) smallest = degrees;
if (degrees < smallest) smallest = degrees;
}
if (smallest < minAngleDeg - 1e-9) {
violations.push({ rule: 'angle', subject: node, actual: smallest, limit: minAngleDeg });
@@ -139,21 +159,27 @@ export function collinearRunLengthUnits(
// (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);
// #331 §2.3/§2.4: an iterative edge walk over the collinear component —
// no recursion (a 10 000-atom chain must answer, not overflow the stack),
// no combinatorial DFS (every atom joins the run at most once, O(E)), and
// no silently dropped branch (the old `.find` lost every fork but the
// first). Collinearity is measured against the BASE segment's axis, not
// the previous atom's, so an arc of 0.9°-per-atom pieces cannot creep
// around a corner while posing as one straight wall.
const visited = new Set<LimitSegment>([segment]);
let total = length(segment.a, segment.b);
const walk = (from: LimitSegment, node: number[]): void => {
const next = (byNode.get(key(node)) || []).find((candidate) => (
!visited.has(candidate)
&& collinear(candidate, from)
&& Number(candidate.cm || 0) === Number(from.cm || 0)
));
if (!next) return;
visited.add(next);
total += length(next.a, next.b);
walk(next, key(next.a) === key(node) ? next.b : next.a);
};
walk(segment, segment.a);
walk(segment, segment.b);
const frontier: number[][] = [segment.a, segment.b];
while (frontier.length) {
const node = frontier.pop() as number[];
for (const candidate of byNode.get(key(node)) || []) {
if (visited.has(candidate)) continue;
if (!collinear(candidate, segment)) continue;
if (Number(candidate.cm || 0) !== Number(segment.cm || 0)) continue;
visited.add(candidate);
total += length(candidate.a, candidate.b);
frontier.push(candidate.a, candidate.b);
}
}
return total;
}
@@ -248,6 +274,9 @@ export function checkNodeDistances(
// i<j of the all-pairs loop, so the verdict set is identical.
if (nodeKey >= otherKey) continue;
const distance = length(point, other);
// #331 §2.1: raw-coordinate debris within the incidence quantum is
// ONE node that landed on two neighbouring keys — never a near miss.
if (distance <= INCIDENT_EPS) continue;
if (distance < minUnits - 1e-9) {
violations.push({
rule: 'distance', subject: `${nodeKey} ↔ ${otherKey}`,
+121
View File
@@ -438,3 +438,124 @@ test('#330 M2: §4.6 на границах — v9 как есть == v9 чере
`${name}: вердикт «как есть» разошёлся с «через миграцию»`);
}
});
// --- #331: пограничная точность ---
test('#331 AC1: плавающий мусор — один узел, реальные 4 см — нарушение', async () => {
const { checkNodeDistances } = await import('../test-build/junction-limits.js');
// −1e-8 vs 0: ни узел↔узел, ни узел→сегмент не ругаются.
const debris = [
{ id: 'a', a: [-1e-8, 0], b: [cm(300), 0], cm: 15 },
{ id: 'b', a: [0, 0], b: [0, cm(300)], cm: 15 },
];
assert.equal(checkNodeDistances(debris, CELL, PITCH).length, 0);
// через границу кванта: сырая дистанция 1.02e-7 ≤ 2e-7 — один узел.
const straddle = [
{ id: 'a', a: [-5.1e-8, 0], b: [cm(300), 0], cm: 15 },
{ id: 'b', a: [5.1e-8, 0], b: [5.1e-8, cm(300)], cm: 15 },
];
assert.equal(checkNodeDistances(straddle, CELL, PITCH).length, 0);
// настоящие 4 см — по-прежнему нарушение; ровно 5 — нет.
const four = [
{ id: 'a', a: [0, 0], b: [cm(300), 0], cm: 15 },
{ id: 'b', a: [0, cm(4)], b: [cm(300), cm(4)], cm: 15 },
];
assert.ok(checkNodeDistances(four, CELL, PITCH).length > 0);
const five = [
{ id: 'a', a: [0, 0], b: [cm(300), 0], cm: 15 },
{ id: 'b', a: [0, cm(5)], b: [cm(300), cm(5)], cm: 15 },
];
assert.equal(checkNodeDistances(five, CELL, PITCH).length, 0);
});
test('#331 AC2: дубль стены — нарушение угла 0°; 180°-пара и T-стык чисты', async () => {
const { checkNodes } = await import('../test-build/junction-limits.js');
const dup = [
{ id: 'a', a: [0, 0], b: [cm(300), 0], cm: 15 },
{ id: 'b', a: [0, 0], b: [cm(300), 0], cm: 15 },
];
const violations = checkNodes(dup).filter((item) => item.rule === 'angle');
assert.ok(violations.length > 0, 'дубль обязан быть виден');
assert.ok(violations[0].actual < 0.001, `actual=${violations[0].actual}`);
// Прямая стена из двух атомов встык: в узле 180°-пара — чисто.
const butt = [
{ id: 'a', a: [0, 0], b: [cm(150), 0], cm: 15 },
{ id: 'b', a: [cm(150), 0], b: [cm(300), 0], cm: 15 },
];
assert.equal(checkNodes(butt).filter((item) => item.rule === 'angle').length, 0);
// T-стык: 90° — чисто.
const tee = [
{ id: 'a', a: [0, 0], b: [cm(300), 0], cm: 15 },
{ id: 'b', a: [cm(150), 0], b: [cm(150), cm(300)], cm: 15 },
];
assert.equal(checkNodes(tee).filter((item) => item.rule === 'angle').length, 0);
});
test('#331 AC3: 10 000 атомов без переполнения, развилка не теряется', async () => {
const { collinearRunLengthUnits, checkSegmentLengths } =
await import('../test-build/junction-limits.js');
const chain = [];
for (let index = 0; index < 10000; index++) {
chain.push({ id: `c${index}`, a: [cm(index * 25), 0], b: [cm((index + 1) * 25), 0], cm: 15 });
}
const total = collinearRunLengthUnits(chain[0], chain);
assert.ok(Math.abs(total - cmToUnits(250000, CELL, PITCH)) < 1e-6);
// Развилка: два коллинеарных продолжения из одного узла — компонента
// включает ОБЕ ветви (раньше .find терял вторую).
const fork = [
{ id: 'main', a: [0, 0], b: [cm(100), 0], cm: 15 },
{ id: 'b1', a: [cm(100), 0], b: [cm(160), 0], cm: 15 },
{ id: 'b2', a: [cm(100), 0], b: [cm(220), 1e-9], cm: 15 },
];
const run = collinearRunLengthUnits(fork[0], fork);
assert.ok(run >= cmToUnits(160, CELL, PITCH) - 1e-9,
'компонента не короче суммы main+обе ветви коллинеарной части');
// 100 развилок подряд — линейно и быстро.
const many = [];
for (let index = 0; index < 100; index++) {
const x = cm(index * 30);
many.push({ id: `m${index}`, a: [x, 0], b: [cm((index + 1) * 30), 0], cm: 15 });
many.push({ id: `f${index}`, a: [x, 0], b: [cm(index * 30 + 25), 1e-9], cm: 15 });
}
const started = performance.now();
checkSegmentLengths(many, CELL, PITCH);
assert.ok(performance.now() - started < 500, '100 развилок — не комбинаторика');
});
test('#331 AC4: дуга не выдаёт себя за одну стену, один излом 0.5° — стена', async () => {
const { collinearRunLengthUnits } = await import('../test-build/junction-limits.js');
// Дуга: 30 атомов по 3 см с поворотом 0.9° каждый — суммарно 27°.
const arc = [];
let angle = 0; let x = 0; let y = 0;
for (let index = 0; index < 30; index++) {
const nx = x + Math.cos(angle) * cm(3);
const ny = y + Math.sin(angle) * cm(3);
arc.push({ id: `a${index}`, a: [x, y], b: [nx, ny], cm: 15 });
x = nx; y = ny; angle += (0.9 * Math.PI) / 180;
}
const run = collinearRunLengthUnits(arc[0], arc);
// К базе первого атома коллинеарны лишь первые ~2 атома (±1°).
assert.ok(run < cmToUnits(10, CELL, PITCH), `дуга слиплась в стену: ${run}`);
// Один излом 0.5° — по-прежнему одна стена.
const bent = [
{ id: 'a', a: [0, 0], b: [cm(150), 0], cm: 15 },
{ id: 'b', a: [cm(150), 0],
b: [cm(150) + Math.cos((0.5 * Math.PI) / 180) * cm(150),
Math.sin((0.5 * Math.PI) / 180) * cm(150)], cm: 15 },
];
const bentRun = collinearRunLengthUnits(bent[0], bent);
assert.ok(Math.abs(bentRun - cmToUnits(300, CELL, PITCH)) < cmToUnits(1, CELL, PITCH));
});
test('#331 AC1: квантование .5-тика одинаково в обоих зеркалах (формула канонизации)', async () => {
const { checkNodes } = await import('../test-build/junction-limits.js');
// 2.5e-7·1e7 = 2.5 → floor(2.5+0.5)=3 → ключ 3e-7 в ОБЕИХ реализациях.
// Прямая проверка формулы через поведение: узел на 2.5e-7 и узел на 3e-7
// получают ОДИН ключ (дубль виден через П1-0°), а 2.5e-7 и 2e-7 — разные.
const same = [
{ id: 'a', a: [2.5e-7, 0], b: [cm(300), 0], cm: 15 },
{ id: 'b', a: [3e-7, 0], b: [cm(300), 0], cm: 15 },
];
assert.ok(checkNodes(same).filter((item) => item.rule === 'angle').length > 0,
'2.5e-7 квантуется вверх (floor(+0.5)), сливаясь с 3e-7 — дубль виден');
});
+70 -2
View File
@@ -236,6 +236,14 @@ def test_parity_with_the_frontend_checks():
([cm(354), 0.0], [cm(554), 0.0], 20)],
"distance-5-exact": [([0.0, 0.0], [cm(300), 0.0], 15),
([0.0, cm(5)], [cm(300), cm(5)], 15)],
# #331: пограничные классы точности — вердикт зеркал обязан совпасть.
"debris-node": [([-1e-8, 0.0], [cm(300), 0.0], 15),
([0.0, 0.0], [0.0, cm(300)], 15)],
"duplicate-wall": [([0.0, 0.0], [cm(300), 0.0], 15),
([0.0, 0.0], [cm(300), 0.0], 15)],
"collinear-fork": [([0.0, 0.0], [cm(100), 0.0], 15),
([cm(100), 0.0], [cm(160), 0.0], 15),
([cm(100), 0.0], [cm(220), 1e-9], 15)],
}
payload = {name: space(segments, space_id=name)
for name, segments in fixtures.items()}
@@ -334,9 +342,9 @@ def test_330_baseline_counts_replace_the_previous_document():
# baseline_counts действительно замещает previous: считаем обращения.
calls = []
original = jl._migrated_spaces
def spy(config):
def spy(config, **kwargs):
calls.append(config)
return original(config)
return original(config, **kwargs)
jl._migrated_spaces = spy
try:
jl.validate_junction_limits(
@@ -439,3 +447,63 @@ def test_330_as_is_equals_migrated_on_boundary_fixtures():
forced, _ = jl.commit_wall_segment_model(json.loads(json.dumps(migrated)))
through = jl.space_violation_counts(jl._migrated_spaces(forced))
assert as_is == through, f"{name}: as-is != migrated"
def test_331_debris_is_one_node_and_duplicate_is_visible():
"""AC1 + AC2 на python-зеркале."""
debris = space([(([-1e-8, 0.0]), [cm(300), 0.0], 15),
([0.0, 0.0], [0.0, cm(300)], 15)])
assert "distance" not in rules(debris)
dup = space([([0.0, 0.0], [cm(300), 0.0], 15),
([0.0, 0.0], [cm(300), 0.0], 15)])
segs = jl.limit_segments(dup)
angle = [v for v in jl.check_nodes(segs) if v[0] == "angle"]
assert angle and angle[0][2] < 0.001
butt = space([([0.0, 0.0], [cm(150), 0.0], 15),
([cm(150), 0.0], [cm(300), 0.0], 15)])
assert "angle" not in rules(butt)
def test_331_iterative_run_and_forks():
"""AC3: 10 000 атомов без переполнения, развилка не теряется."""
chain = space([
([cm(i * 25), 0.0], [cm((i + 1) * 25), 0.0], 15) for i in range(10000)
])
segs = jl.limit_segments(chain)
total = jl.collinear_run_length_units(segs[0], segs)
assert abs(total - jl.cm_to_units(250000, CELL)) < 1e-6
fork = space([([0.0, 0.0], [cm(100), 0.0], 15),
([cm(100), 0.0], [cm(160), 0.0], 15),
([cm(100), 0.0], [cm(220), 1e-9], 15)])
fsegs = jl.limit_segments(fork)
run = jl.collinear_run_length_units(fsegs[0], fsegs)
assert run >= jl.cm_to_units(160, CELL) - 1e-9
def test_331_ac6_candidate_bug_raises_previous_bug_falls_back():
"""AC6, два случая (spec r2 M-r2-1): стороны различимы."""
box = square()
good = {"spaces": [room_space(
[{"id": "r1", "name": "box", "area": None, "poly": box}],
walls_of(box, "b"),
)]}
class Boom(TypeError):
pass
original = jl.commit_wall_segment_model
def bomb(cfg):
raise Boom("migration bug")
jl.commit_wall_segment_model = bomb
try:
# (а) баг на стороне КАНДИДАТА — честная ошибка, не «нарушений нет».
with pytest.raises(Boom):
jl.validate_junction_limits(
json.loads(json.dumps(good)), None, baseline_counts={},
)
# (б) баг на стороне PREVIOUS — запись проходит фолбэком «нет базы»:
# кандидат уже v9 (мигрирован заранее настоящей функцией) и чист.
migrated, _ = original(json.loads(json.dumps(good)))
jl.validate_junction_limits(
json.loads(json.dumps(migrated)), json.loads(json.dumps(good)),
)
finally:
jl.commit_wall_segment_model = original