fix: the initial wall-model migration resolves every opening conflict itself (#316)

Implements spec revision 4 (green r4). §3.1 — a legacy open_spans/open_to cut
never zeroes the atom that carries an existing contour opening: the opening's
edges become atom boundaries, the door keeps its real wall and the zero run
continues on both sides. §3.2 — an ambiguous carrier resolves
deterministically: current host, then distance, thicker cm, smaller id.
§3.3 — an opening with no usable carrier persists unhosted: a valid degraded
v9 state, inert in the physics, rendered by its own x/y, kept by later writes
and re-placeable in the editor (backend schema accepts it). §3.4 — the
initial migration never throws over an opening; a post-v9 write that LOST its
carrier keeps the fail-closed opening-host refusal. The Python migration
mirror implements the same rules with byte-identical output (verified on the
span+door fixture including the segment id).

The new smoke replays #316 end to end: a conflicted space no longer blocks
drawing on an empty plan. The golden scene span-over-door-migrated-dark
renders the migrated fixture pinned byte-for-byte to the real writer; two
gate mutants revert §3.1 and §3.4 and are red by execution.

Issue: #316
User-Visible: yes
This commit is contained in:
Codex
2026-08-26 22:06:43 +03:00
parent 33a5b46990
commit ffb232398a
18 changed files with 878 additions and 135 deletions
+258
View File
@@ -0,0 +1,258 @@
{
"comment": "stored: a pre-migration v8 document — a room whose top wall carries a door and is fully covered by a legacy open_span; migrated: the same document after the current initial migration (#316 §3.1) — the door keeps its carrying atom, the border is zero on both sides. The golden scene renders `migrated`; the unit test pins it to the real writer byte for byte.",
"stored": {
"model_version": 8,
"spaces": [
{
"id": "golden-span-over-door",
"title": "Span over door",
"cell_cm": 5,
"view_box": [
0,
0,
1,
0.7
],
"settings": {
"fill_mode": "none",
"show_borders": true,
"show_names": true
},
"rooms": [
{
"id": "sod-room",
"name": "Room",
"area": null,
"poly": [
[
0.2,
0.3
],
[
0.6,
0.3
],
[
0.6,
0.6
],
[
0.2,
0.6
]
]
}
],
"walls": [
{
"key": "0.400000,0.300000@0.0000",
"a": [
0.2,
0.3
],
"b": [
0.6,
0.3
],
"cm": 15
}
],
"open_spans": [
{
"a": [
0.2,
0.3
],
"b": [
0.6,
0.3
]
}
],
"openings": [
{
"id": "sod-door",
"type": "door",
"x": 0.4,
"y": 0.3,
"angle": 0,
"length": 0.09,
"cm": 90,
"flip_v": true
}
]
}
],
"markers": [],
"settings": {}
},
"migrated": {
"model_version": 9,
"spaces": [
{
"id": "golden-span-over-door",
"title": "Span over door",
"cell_cm": 5,
"view_box": [
0,
0,
1,
0.7
],
"settings": {
"fill_mode": "none",
"show_borders": true,
"show_names": true
},
"rooms": [
{
"id": "sod-room",
"name": "Room",
"area": null,
"poly": [
[
0.2,
0.3
],
[
0.355,
0.3
],
[
0.445,
0.3
],
[
0.6,
0.3
],
[
0.6,
0.6
],
[
0.2,
0.6
]
],
"wall_ids": [
"wall-s2g2iw3nbbxkym6sodv4",
"wall-qmhko777ctlb3hey6c3v",
"wall-odwfy5lyxkwsum2jewma",
"wall-l3r6mytgo2l4so24dljp",
"wall-mzbb6mwcqxj67rhw7mc5",
"wall-uyhvjlqcorfj3orzvhhj"
]
}
],
"walls": [
{
"key": "0.400000,0.300000@0.0000",
"cm": 15,
"a": [
0.355,
0.3
],
"b": [
0.445,
0.3
]
}
],
"openings": [
{
"id": "sod-door",
"type": "door",
"x": 0.4,
"y": 0.3,
"angle": 0,
"length": 0.09,
"cm": 90,
"flip_v": true,
"host": {
"kind": "wall",
"id": "wall-qmhko777ctlb3hey6c3v",
"t": 0.5
}
}
],
"wall_segments": [
{
"id": "wall-uyhvjlqcorfj3orzvhhj",
"a": [
0.2,
0.3
],
"b": [
0.2,
0.6
],
"cm": 0
},
{
"id": "wall-s2g2iw3nbbxkym6sodv4",
"a": [
0.2,
0.3
],
"b": [
0.355,
0.3
],
"cm": 0
},
{
"id": "wall-mzbb6mwcqxj67rhw7mc5",
"a": [
0.2,
0.6
],
"b": [
0.6,
0.6
],
"cm": 0
},
{
"id": "wall-qmhko777ctlb3hey6c3v",
"a": [
0.355,
0.3
],
"b": [
0.445,
0.3
],
"cm": 15
},
{
"id": "wall-odwfy5lyxkwsum2jewma",
"a": [
0.445,
0.3
],
"b": [
0.6,
0.3
],
"cm": 0
},
{
"id": "wall-l3r6mytgo2l4so24dljp",
"a": [
0.6,
0.3
],
"b": [
0.6,
0.6
],
"cm": 0
}
]
}
],
"markers": [],
"settings": {}
}
}
+1 -1
View File
@@ -367,7 +367,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 46);
assert.equal(GOLDEN_MATRIX_VERSION, 47);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
+108 -6
View File
@@ -262,18 +262,23 @@ test('room opening host resolver updates t and rejects ambiguous or zero walls',
]), null);
});
test('an ambiguous room opening blocks the complete candidate without mutating input', () => {
test('a corner opening on bodyless walls migrates unhosted instead of blocking (#316 §3.3/§3.4)', () => {
// Until #316 the initial migration refused this candidate outright; the
// reviewed contract degrades it instead and never blocks the write.
const input = configOf({
id: 'floor', title: 'Floor',
rooms: [rectangle('room')],
openings: [{ id: 'door', type: 'door', x: 0, y: 0, angle: 0, length: 0.2 }],
});
const before = structuredClone(input);
assert.throws(
() => commitWallSegmentModel(input),
(error) => error instanceof WallSegmentModelError && error.reason === 'opening-host',
);
assert.deepEqual(input, before);
const out = commitWallSegmentModel(input).config;
assert.deepEqual(input, before, 'the input candidate is never mutated');
// Since #306 an unconfigured contour is explicitly bodyless (cm: 0), and a
// zero wall is never an opening carrier — the corner door therefore
// migrates UNHOSTED (§3.3) instead of blocking the whole candidate.
assert.equal('host' in out.spaces[0].openings[0], false);
const again = commitWallSegmentModel(structuredClone(out)).config;
assert.equal(JSON.stringify(again), JSON.stringify(out), 'the outcome is stable');
});
test('draft segment ids materialise once and remain stable', () => {
@@ -391,3 +396,100 @@ test('every frontend structural transaction crosses the wall identity barrier',
assert.doesNotMatch(commitMethod, /this\._writeConfig\(/,
'the common barrier must finish before the existing persistence path runs');
});
// --- #316: the initial migration auto-resolves opening-host conflicts ---
const spanDoorConfig = () => ({
model_version: 8,
spaces: [{
id: 'B', title: 'B', cell_cm: 5, view_box: [0, 0, 1, 0.7],
rooms: [{ id: 'rB', poly: [[0.2, 0.2], [0.6, 0.2], [0.6, 0.5], [0.2, 0.5]] }],
walls: [{ key: wallKey([0.2, 0.2], [0.6, 0.2], GRID_STEP_N),
a: [0.2, 0.2], b: [0.6, 0.2], cm: 15 }],
open_spans: [{ a: [0.2, 0.2], b: [0.6, 0.2] }],
openings: [{ id: 'op1', type: 'door', x: 0.4, y: 0.2, angle: 0, length: 0.09, cm: 90 }],
}],
markers: [], settings: {},
});
test('an opening keeps its wall through a legacy span cut (#316 §3.1) and the run stays idempotent', () => {
const out = commitWallSegmentModel(spanDoorConfig()).config;
const space = out.spaces[0];
const top = space.wall_segments
.filter((s) => Math.abs(s.a[1] - 0.2) < 1e-9 && Math.abs(s.b[1] - 0.2) < 1e-9)
.sort((x, y) => x.a[0] - y.a[0]);
assert.equal(top.length, 3, 'the opening edges split the span run into three atoms');
assert.equal(top[0].cm, 0, 'left of the door the border stays zero');
assert.ok(top[1].cm > 0, 'the atom under the door keeps its real thickness');
assert.equal(top[2].cm, 0, 'right of the door the border stays zero');
assert.deepEqual(space.openings[0].host, { kind: 'wall', id: top[1].id, t: 0.5 });
assert.equal('open_spans' in space, false);
const again = commitWallSegmentModel(structuredClone(out)).config;
assert.equal(JSON.stringify(again), JSON.stringify(out), 'commit(commit(x)) == commit(x)');
});
test('an ambiguous carrier is resolved deterministically at a thickness boundary (#316 §3.2)', () => {
// Two contour atoms of one wall meet at x=0.4 (thickness change); a short
// opening centred exactly on the junction fits inside either atom.
const config = {
model_version: 8,
spaces: [{
id: 'S', title: 'S', cell_cm: 5, view_box: [0, 0, 1, 0.7],
rooms: [{ id: 'r', poly: [[0.2, 0.2], [0.6, 0.2], [0.6, 0.5], [0.2, 0.5]] }],
walls: [
{ key: wallKey([0.2, 0.2], [0.4, 0.2], GRID_STEP_N),
a: [0.2, 0.2], b: [0.4, 0.2], cm: 10 },
{ key: wallKey([0.4, 0.2], [0.6, 0.2], GRID_STEP_N),
a: [0.4, 0.2], b: [0.6, 0.2], cm: 20 },
],
openings: [{ id: 'op', type: 'door', x: 0.4, y: 0.2, angle: 0, length: 0, cm: 0 }],
}],
markers: [], settings: {},
};
const out = commitWallSegmentModel(structuredClone(config)).config;
const space = out.spaces[0];
const host = space.openings[0].host;
assert.equal(host?.kind, 'wall');
const carrier = space.wall_segments.find((s) => s.id === host.id);
assert.ok(carrier, 'the picked carrier exists in the catalogue');
// §3.2: equal distance → the thicker atom wins.
assert.equal(carrier.cm, 20);
const again = commitWallSegmentModel(structuredClone(out)).config;
assert.equal(JSON.stringify(again), JSON.stringify(out), 'the pick is stable');
});
test('an opening with no usable carrier migrates unhosted and survives later writes (#316 §3.3/§3.4)', () => {
const config = spanDoorConfig();
// The opening is far from every wall and across the grain — no carrier.
config.spaces[0].openings[0] = {
id: 'orphan', type: 'door', x: 0.9, y: 0.65, angle: 90, length: 0.05, cm: 90,
};
const out = commitWallSegmentModel(structuredClone(config)).config;
const opening = out.spaces[0].openings[0];
assert.equal('host' in opening, false, 'the orphan persists unhosted');
// A later structural write keeps it and does not throw (§3.3).
const again = commitWallSegmentModel(structuredClone(out)).config;
assert.equal('host' in again.spaces[0].openings[0], false);
assert.equal(JSON.stringify(again), JSON.stringify(out));
});
test('a post-v9 write that LOST its carrier keeps the fail-closed refusal (#316 AC5)', () => {
const migrated = commitWallSegmentModel(spanDoorConfig()).config;
const broken = structuredClone(migrated);
// Point the hosted opening at a segment id that no longer exists.
broken.spaces[0].openings[0].host = { kind: 'wall', id: 'wall-gone', t: 0.5 };
broken.spaces[0].openings[0].x = 0.9; broken.spaces[0].openings[0].y = 0.65;
broken.spaces[0].openings[0].angle = 90;
assert.throws(
() => commitWallSegmentModel(broken),
(error) => error instanceof WallSegmentModelError && error.reason === 'opening-host',
);
});
test('the #316 golden fixture matches the current initial migration byte for byte', () => {
const fixture = JSON.parse(readFileSync(
new URL('./fixtures/316-span-over-door-migrated.json', import.meta.url), 'utf8',
));
const migrated = commitWallSegmentModel(structuredClone(fixture.stored)).config;
assert.deepEqual(migrated, fixture.migrated);
});