feat: implement isometric stage 3

Issue: #160
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-06 07:06:54 +03:00
parent 3bf9e2d6d3
commit ee7d486924
87 changed files with 7676 additions and 1375 deletions
+15
View File
@@ -15,6 +15,19 @@ the same profile. The dedicated
plus absolute noise/ceiling checks. Only the exact-SHA Linux workflow is gate
evidence; a local report is diagnostic.
Issue #160 keeps that historical profile and budget unchanged, then adds the
separate `isometric-stage3-dense-v1` profile. Its derived performance-only
fixture puts all 200 devices close to walls or corners and adds deterministic
value, LQI and new-device badges plus contact/lock-bound door, window and gate
examples. The base invocation explicitly allows a Stage 2 renderer so an older
`dev` SHA remains measurable; the candidate invocation fails closed unless it
reports effective Iso, the Stage 3 revision, all required raised overlay kinds,
at least one real nudge, a bounded shared material-definition set and zero
structural rebuilds during HA, opening and hover/focus updates. The two extra
timing windows use limits no softer than the historical HA-update budget. This
does not change the fixture, measured windows or budget of
`large-house-isometric-v1`, which remains the #124 regression witness.
Issue #137 adds `large-house-plan-snap-v1` without changing the meaning or
budgets of the original profile. The same 60-room/60-partition fixture gains
six saved open outlines, renders the Plan snap overlay, and sends 120 real
@@ -164,6 +177,8 @@ Build and copy a fresh demo bundle first, then run:
```bash
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
npm run benchmark:large-house-isometric -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-local.json
npm run benchmark:isometric-stage3-dense -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-stage3-local.json
npm run benchmark:large-house-plan-snap -- --samples=7 --warmups=1 --output=artifacts/performance/plan-snap-local.json
npm run benchmark:large-house-interaction -- --samples=7 --warmups=1 --output=artifacts/performance/interaction-local.json
```
@@ -0,0 +1,52 @@
{
"schema": 1,
"profile": "isometric-stage3-dense-v1",
"minimumSamples": 7,
"timings": {
"modelReadyMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 200, "hardMaxMs": 3000 },
"firstStableRenderMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 3500 },
"viewToggleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1500 },
"openingUpdateMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 },
"overlayInteractionMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 },
"spaceSwitchMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1800 },
"stateUpdateMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 },
"resizePreviewMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 150, "hardMaxMs": 2200 },
"panZoomMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 60, "hardMaxMs": 600 },
"settingsDialogMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1200 },
"switchCycleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 8000 }
},
"longTasks": {
"maxSingleMs": 3000,
"maxSingleRegressionRatio": 0.2,
"maxSingleNoiseAllowanceMs": 250,
"maxCountP95": 30,
"maxCountRegressionRatio": 0.2,
"countNoiseAllowance": 3,
"maxTotalP95Ms": 12000,
"maxTotalRegressionRatio": 0.2,
"noiseAllowanceMs": 150
},
"heap": {
"required": true,
"hardMaxGrowthBytes": 67108864,
"maxRegressionRatio": 0.2,
"noiseAllowanceBytes": 16777216
},
"cacheEntries": {
"cleanFloor": 100,
"glowClip": 200,
"wallUnion": 1,
"openingTunnel": 1,
"openingWallIndex": 1,
"isoGeometry": 8
},
"cacheGrowth": {
"cleanFloor": 0,
"glowClip": 0,
"wallUnion": 0,
"openingTunnel": 0,
"openingWallIndex": 0,
"isoGeometry": 0
},
"renderedDevices": 200
}
+10 -3
View File
@@ -54,15 +54,18 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
'_modeTransitionBusy',
'_path',
'_serverCfg',
'_space',
'_settingsDialog',
'_tool',
]),
// A comparison SHA before #89 is intentionally flat; the isometric runner
// checks these members only when the target source tree supports Stage 1.
optionalFields: Object.freeze([
'_activeDraftId', '_draftSegmentCms', '_isoGeometryCache', '_offerWallFaces',
'_liveEditorPaintCount', '_onLabsSnapshot', '_planSnapGeometryCache', '_roomDialog', '_setProjection',
'_wallFaceBatch', '_wallFaceGraphCache',
'_activeDraftId', '_draftSegmentCms', '_effectiveProjection', '_ensureIsoSceneRuntime',
'_isoGeometryCache', '_isoStructuralBuildCount', '_offerWallFaces',
'_liveEditorPaintCount', '_onLabsSnapshot',
'_planSnapGeometryCache', '_roomDialog', '_setProjection', '_wallFaceBatch',
'_wallFaceGraphCache',
]),
// #380: v1.68.1 owns the same resize session directly on the card; newer
// bundles moved it into ResizeController. A comparison target must expose
@@ -81,6 +84,8 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
_decorList: 'array',
_decorTool: 'string',
_draftSegmentCms: 'array',
_effectiveProjection: 'function',
_ensureIsoSceneRuntime: 'function',
_glowClipCache: 'map',
_gridPitch: 'number',
_hassSequence: 'number',
@@ -91,11 +96,13 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
_offerWallFaces: 'function',
_path: 'array',
_isoGeometryCache: 'map',
_isoStructuralBuildCount: 'number',
_planSnapGeometryCache: 'object',
_roomDialog: 'boolean',
_resize: 'object',
_setProjection: 'function',
_serverCfg: 'object',
_space: 'string',
_tool: 'string',
_wallFaceGraphCache: 'array',
}),
+2
View File
@@ -16,6 +16,8 @@ const evaluation = evaluatePerformanceBudget({
baseline: absoluteOnly ? null : readJson(baselinePath),
budgets: readJson(budgetsPath),
absoluteOnly,
candidateSha: valueArg('candidate-sha') || null,
baselineSha: valueArg('baseline-sha') || null,
});
mkdirSync(dirname(outputPath), { recursive: true });
+33 -4
View File
@@ -37,13 +37,37 @@ export const summarizeLongTasks = (rows) => {
};
const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const exactGitSha = (value) => typeof value === 'string' && /^[0-9a-f]{40}$/i.test(value);
const isIsometricProfile = (profile) => profile === 'large-house-isometric-v1'
|| profile === 'isometric-stage3-dense-v1';
const requireReport = (report, budgets, label) => {
const requireReport = (report, budgets, label, expectedSha = null) => {
if (!report || report.schema !== 2) throw new Error(`${label}: unsupported report schema`);
if (report.profile !== budgets.profile) throw new Error(`${label}: unexpected benchmark profile`);
if (!Array.isArray(report.rows) || report.rows.length < budgets.minimumSamples) {
throw new Error(`${label}: expected at least ${budgets.minimumSamples} measured samples`);
}
if (!isIsometricProfile(report.profile)) return;
if (!exactGitSha(report.sourceSha)) throw new Error(`${label}: missing exact sourceSha`);
if (expectedSha != null && report.sourceSha !== expectedSha) {
throw new Error(`${label}: sourceSha does not match requested checkout`);
}
if (!sameJson(report.effectiveProjection, ['iso'])
|| report.rows.some((row) => row.effectiveProjection !== 'iso')) {
throw new Error(`${label}: effectiveProjection is not exclusively iso`);
}
if (label === 'candidate' && report.rows.some((row) =>
row.isoStructuralBuilds?.supported !== true
|| row.isoStructuralBuilds?.haUpdateDelta !== 0)) {
throw new Error(`${label}: Iso structural build count is missing or changed on HA-only update`);
}
if (label === 'candidate' && report.profile === 'isometric-stage3-dense-v1') {
if (report.stage3Required !== true) throw new Error(`${label}: Stage 3 was not required`);
if (!sameJson(report.isoStageRevision, ['3'])
|| report.rows.some((row) => row.isoStageRevision !== '3')) {
throw new Error(`${label}: Stage 3 revision metadata is incomplete`);
}
}
};
const relativeLimit = (baseline, ratio, allowance) => Math.max(
@@ -64,10 +88,13 @@ const makeCheck = (id, actual, limit, details = {}) => ({
* additionally compare a base-SHA report from the same runner; fast smoke
* captures deliberately enforce only the hard candidate limits.
*/
export const evaluatePerformanceBudget = ({ candidate, baseline, budgets, absoluteOnly = false }) => {
requireReport(candidate, budgets, 'candidate');
export const evaluatePerformanceBudget = ({
candidate, baseline, budgets, absoluteOnly = false,
candidateSha = null, baselineSha = null,
}) => {
requireReport(candidate, budgets, 'candidate', candidateSha);
if (!absoluteOnly) {
requireReport(baseline, budgets, 'baseline');
requireReport(baseline, budgets, 'baseline', baselineSha);
if (!sameJson(candidate.fixture, baseline.fixture)) throw new Error('fixture mismatch');
for (const key of ['node', 'chromium', 'platform', 'arch']) {
if (candidate.runtime?.[key] !== baseline.runtime?.[key]) {
@@ -214,6 +241,8 @@ export const evaluatePerformanceBudget = ({ candidate, baseline, budgets, absolu
mode: absoluteOnly ? 'absolute' : 'relative',
candidateFingerprint: candidate.buildFingerprint,
baselineFingerprint: baseline?.buildFingerprint ?? null,
candidateSha: candidate.sourceSha ?? null,
baselineSha: baseline?.sourceSha ?? null,
checks,
failures,
};
@@ -0,0 +1,150 @@
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
export const ISOMETRIC_STAGE3_DENSE_PROFILE = 'isometric-stage3-dense-v1';
export const ISOMETRIC_STAGE3_MATERIAL_DEFINITION_LIMIT = 16;
const CONTACT_ENTITY = 'binary_sensor.perf_stage3_opening';
const LOCK_ENTITY = 'lock.perf_stage3_opening';
const round = (value) => Number(value.toFixed(6));
const mergeMarker = (markers, deviceId, patch) => {
const current = markers.get(deviceId) ?? {
id: deviceId,
binding: `device:${deviceId}`,
};
markers.set(deviceId, { ...current, ...patch });
};
/**
* Performance-only Stage 3 stress fixture.
*
* Keep the historical large-house fixture immutable: its isometric profile is
* the #124 regression witness. This derived fixture deliberately puts every
* device close to a room wall (and every fourth one close to a corner), then
* adds a bounded representative set of wide/value/LQI/new presentations and
* live opening bindings. Both comparison bundles receive this exact object;
* only the candidate is required to expose the Stage 3 DOM contract.
*/
export const makeIsometricStage3DenseFixture = () => {
const fixture = makeLargeHouseFixture();
const roomsByArea = new Map(fixture.config.spaces.flatMap((space) =>
space.rooms.map((room) => [room.area, room])));
const markers = new Map((fixture.config.markers ?? []).map((marker) => [marker.id, marker]));
const deviceIds = Object.keys(fixture.devices).sort((a, b) => {
const number = (id) => Number(id.slice(id.lastIndexOf('-') + 1));
return number(a) - number(b);
});
const decoratedDeviceIds = [];
const pulseDeviceIdsBySpace = {};
for (const [index, deviceId] of deviceIds.entries()) {
const room = roomsByArea.get(fixture.devices[deviceId]?.area_id);
if (!room) throw new Error(`Stage 3 dense fixture has no room for ${deviceId}`);
const [topLeft, , bottomRight] = room.poly;
const epsilon = 0.002;
const midX = (topLeft[0] + bottomRight[0]) / 2;
const midY = (topLeft[1] + bottomRight[1]) / 2;
const position = index % 4;
fixture.layout[deviceId] = {
...fixture.layout[deviceId],
x: round(position === 0 || position === 3 ? bottomRight[0] - epsilon : midX),
y: round(position === 1 ? bottomRight[1] - epsilon
: position === 2 || position === 3 ? topLeft[1] + epsilon : midY),
};
const entity = Object.values(fixture.entities)
.find((candidate) => candidate.device_id === deviceId);
const state = entity ? fixture.states[entity.entity_id] : null;
const spaceId = fixture.layout[deviceId]?.s;
if (state && spaceId && !pulseDeviceIdsBySpace[spaceId]
&& entity.entity_id.startsWith('fan.') && state.state === 'on') {
pulseDeviceIdsBySpace[spaceId] = deviceId;
mergeMarker(markers, deviceId, { display: 'icon_ripple' });
}
if (state && index % 3 === 0) {
fixture.states[entity.entity_id] = {
...state,
attributes: { ...state.attributes, lqi: 40 + (index * 17) % 141 },
};
decoratedDeviceIds.push(deviceId);
mergeMarker(markers, deviceId, {
value_badge: {
enabled: true,
source: { kind: 'entity_state', entity_id: entity.entity_id },
position: ['right', 'bottom', 'left', 'top'][index % 4],
},
});
} else if (state && index % 5 === 0) {
decoratedDeviceIds.push(deviceId);
mergeMarker(markers, deviceId, { display: 'value' });
}
}
for (const space of fixture.config.spaces) {
space.settings = {
...space.settings,
show_lqi: true,
label_temp: true,
label_hum: true,
label_lqi: true,
label_light: true,
};
const windowOpening = space.openings.find((opening) => opening.type === 'window');
const doorOpening = space.openings.find((opening) => opening.type === 'door');
const gateOpening = space.openings.find((opening) => opening.type === 'gate');
if (!windowOpening || !doorOpening || !gateOpening)
throw new Error(`Stage 3 dense fixture lacks an opening kind on ${space.id}`);
windowOpening.contact = CONTACT_ENTITY;
doorOpening.contact = CONTACT_ENTITY;
doorOpening.lock = LOCK_ENTITY;
gateOpening.contact = CONTACT_ENTITY;
gateOpening.lock = LOCK_ENTITY;
}
fixture.config.markers = [...markers.values()];
const perSpaceNewDeviceIds = fixture.config.spaces.map((space) =>
decoratedDeviceIds.find((id) => fixture.layout[id]?.s === space.id));
if (perSpaceNewDeviceIds.some((id) => !id))
throw new Error('Stage 3 dense fixture has no decorated new-device source on every floor');
const newDeviceIds = [...new Set([...perSpaceNewDeviceIds, ...decoratedDeviceIds])].slice(0, 12);
fixture.config.settings = {
...fixture.config.settings,
filter_seeded: true,
known_devices: deviceIds,
new_device_ids: newDeviceIds,
};
fixture.states[CONTACT_ENTITY] = {
entity_id: CONTACT_ENTITY,
state: 'off',
attributes: { device_class: 'door', friendly_name: 'Stage 3 opening contact' },
};
fixture.states[LOCK_ENTITY] = {
entity_id: LOCK_ENTITY,
state: 'locked',
attributes: { friendly_name: 'Stage 3 opening lock' },
};
const stateEntityId = Object.values(fixture.entities).find((entity) =>
fixture.layout[entity.device_id]?.s === 'perf-floor-2')?.entity_id;
if (!stateEntityId) throw new Error('Stage 3 dense fixture has no floor-2 state source');
const pulseDeviceIds = fixture.config.spaces.map((space) => pulseDeviceIdsBySpace[space.id]);
if (pulseDeviceIds.some((id) => !id))
throw new Error('Stage 3 dense fixture has no active pulse source on every floor');
fixture.stage3Dense = {
contactEntityId: CONTACT_ENTITY,
lockEntityId: LOCK_ENTITY,
stateEntityId,
pulseDeviceId: pulseDeviceIds[0],
pulseDeviceIdsBySpace,
decoratedDeviceIds,
expectedOverlayKinds: ['device', 'room-label', 'opening-lock'],
expectedOpeningKinds: ['door', 'window', 'gate'],
materialDefinitionLimit: ISOMETRIC_STAGE3_MATERIAL_DEFINITION_LIMIT,
};
fixture.counts = {
...fixture.counts,
denseMarkers: deviceIds.length,
decoratedMarkers: decoratedDeviceIds.length,
boundOpenings: fixture.config.spaces.length * 3,
};
return fixture;
};