fix: the baseline cache keys on geometry, and the smoke tells all three worlds apart (#330 r2-M1)

The reviewer proved my behavioural claim false by running the stale-cache
mutant against the smoke: 11 vs 12 total calls — indistinguishable. Two real
defects hid behind that finding:

1. The cache keyed on _cfgEpoch, which ticks on every ACCEPTED PREVIEW —
   the cache missed on every pointermove and the baseline was recomputed
   ~4 times per gesture (measured). The key is now the document identity
   plus spacePhysicalGeometryFingerprint of its space: content, not a
   counter. A preview overlay leaves the fingerprint alone; an in-place
   structural commit changes it and honestly invalidates.

2. The smoke now counts BASELINE computations only (calls whose document is
   _serverCfg) across two gestures with a commit in between, expecting
   exactly 1 then exactly 2. A disabled cache lands near 20, an eternal
   cache stays at 1 — every mutant class turns the smoke red, and the smoke
   is now the mutant's guard alongside the source-contract unit.

Issue: #330
User-Visible: no
This commit is contained in:
Codex
2026-08-28 03:35:40 +03:00
parent 294d7047f3
commit 04bb54aef3
6 changed files with 79 additions and 25 deletions
File diff suppressed because one or more lines are too long
+47 -9
View File
@@ -183,14 +183,16 @@ 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.
// #330 AC4 (код-ревью r2-M1: общий счётчик не различал кэш — 11 против 12
// вызовов тонули в шуме). Считаем ОТДЕЛЬНО вычисления baseline: вызовы
// _junctionLimitViolations с документом === _serverCfg. С кэшем их ровно
// один на жест; без кэша — по одному на каждый pointermove.
await page.evaluate(() => {
const card = window.__card;
window.__jlCalls = 0;
window.__jlBaselineCalls = 0;
const original = card._junctionLimitViolations.bind(card);
card._junctionLimitViolations = (...args) => {
window.__jlCalls += 1;
if (args[0] === card._serverCfg) window.__jlBaselineCalls += 1;
return original(...args);
};
});
@@ -209,11 +211,47 @@ 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;
const baselineCalls = await page.evaluate(() => window.__jlBaselineCalls);
// Ровно одно вычисление baseline на жест: второй и дальнейшие move обязаны
// попадать в кэш (#330 §4.4). Без кэша здесь было бы ~10 — порог различает
// рабочий кэш от отключённого без права на шум.
resize.resizeBaselineComputedOncePerGesture = baselineCalls === 1;
// Обратная сторона (r2-M1): кэш обязан ЧЕСТНО инвалидироваться. Первый
// жест закоммитил план — геометрия baseline изменилась, и второй жест
// обязан пересчитать его ровно один раз. Вечный кэш оставил бы счётчик на
// 1, отключённый — унёс к ~20; ожидание строго 2 различает все три мира.
const second = await page.evaluate(() => {
const card = window.__card;
const handles = [...card.renderRoot.querySelectorAll('.rszhandle[aria-disabled="false"]')];
const rooms = card._curSpaceCfg.rooms;
const aRight = Math.max(...rooms.find((room) => room.name === 'A').poly.map((p) => p[0])) * 1000;
const found = handles.find((entry) => Math.abs(Number(entry.getAttribute('cx')) - aRight) < 1.5);
if (!found) return null;
const svg = found.ownerSVGElement;
const map = (x, y) => {
const point = svg.createSVGPoint();
point.x = x; point.y = y;
const mapped = point.matrixTransform(found.getScreenCTM());
return [mapped.x, mapped.y];
};
const cx = Number(found.getAttribute('cx'));
const cy = Number(found.getAttribute('cy'));
// Один шаг назад: законное движение, границы П4 не задевает.
return { start: map(cx, cy), end: map(cx - (2 / 2) * (1000 / 240), cy) };
});
resize.resizeSecondHandleFound = !!second;
if (second) {
await page.mouse.move(...second.start);
await page.mouse.down();
await page.mouse.move(...second.end, { steps: 6 });
await settle();
await page.mouse.up();
await settle();
await page.waitForTimeout(700);
const afterSecond = await page.evaluate(() => window.__jlBaselineCalls);
resize.resizeBaselineRecomputedAfterCommit = afterSecond === 2;
}
}
checkAll({ ...out, ...resize }, {
+2 -2
View File
File diff suppressed because one or more lines are too long
+7 -2
View File
@@ -563,13 +563,18 @@ export const MUTANTS = [
},
{
id: 'junction-limit-baseline-cache-stale',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
// r2-M1: the behavioural guard — the smoke counts BASELINE computations
// (exactly one per gesture with a working cache, one per pointermove
// without) — plus the source-contract unit stays as the cheap first line.
guard: 'node demo/smoke_junction_limits.mjs '
+ '&& 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) {",
find: " if (cached && fingerprint && cached.fingerprint === fingerprint\n"
+ ' && cached.spaceId === spaceId) {',
replace: ' if (cached && cached.spaceId === spaceId) {',
}],
},
+17 -8
View File
@@ -7498,9 +7498,9 @@ class HouseplanCard extends LitElement {
});
}
/** #330 §4.4: baseline violations per (document identity, epoch, space). */
/** #330 §4.4: baseline violations per (document identity, space, geometry). */
private _junctionBaselineCache = new WeakMap<object, {
epoch: number; spaceId: string; violations: JunctionLimitViolation[];
spaceId: string; fingerprint: string; violations: JunctionLimitViolation[];
}>();
/** #329: violations this write would ADD; inherited ones stay untouched. */
@@ -7517,12 +7517,21 @@ class HouseplanCard extends LitElement {
// 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.
// pointermove. The cache key is the document's identity plus the
// geometry fingerprint of ITS space — content, not a counter: the
// preview epoch (_cfgEpoch) ticks on every accepted preview overlay
// without the stored document changing (that miss cost one full baseline
// pass per pointermove, review 330-r2), while an in-place structural
// commit changes the fingerprint and honestly invalidates.
const previousSpace = (previousConfig?.spaces || [])
.find((space: any) => space?.id === spaceId);
let fingerprint = '';
try { fingerprint = spacePhysicalGeometryFingerprint(previousSpace); }
catch { fingerprint = ''; }
const cached = previousConfig && typeof previousConfig === 'object'
? this._junctionBaselineCache.get(previousConfig) : undefined;
if (cached && cached.epoch === this._cfgEpoch && cached.spaceId === spaceId) {
if (cached && fingerprint && cached.fingerprint === fingerprint
&& cached.spaceId === spaceId) {
inherited = cached.violations;
}
if (!inherited) {
@@ -7536,9 +7545,9 @@ class HouseplanCard extends LitElement {
// refuse the write on that basis.
return [];
}
if (previousConfig && typeof previousConfig === 'object') {
if (previousConfig && typeof previousConfig === 'object' && fingerprint) {
this._junctionBaselineCache.set(previousConfig, {
epoch: this._cfgEpoch, spaceId, violations: inherited,
spaceId, fingerprint, violations: inherited,
});
}
}
+4 -2
View File
@@ -390,8 +390,10 @@ test('#330 AC4: кэш baseline инвалидируется по конфиг-
source.indexOf('private _junctionLimitsIntroduced('),
source.indexOf('private _commitPhysicalGeometry('),
);
assert.match(method, /cached\.epoch === this\._cfgEpoch/,
'кэш baseline обязан сверять конфиг-эпоху — иначе он переживает write и судит план, которого больше нет');
assert.match(method, /cached\.fingerprint === fingerprint/,
'кэш baseline обязан сверять геометрический fingerprint — счётчик превью тикает без изменения документа, а in-place commit меняет геометрию без смены identity');
assert.match(method, /spacePhysicalGeometryFingerprint\(previousSpace\)/,
'fingerprint берётся от пространства baseline-документа');
assert.match(method, /_junctionBaselineCache\.set\(previousConfig/,
'кэш ключуется идентичностью документа');
assert.match(method, />= WALL_SEGMENT_MODEL_VERSION\s*\n?\s*\? previousConfig/,