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
+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}`,