Files
gods-eye-view/src/data/labelArbiterDifferential.test.mjs
T

406 lines
14 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
ALLOCATION_ELASTIC,
ALLOCATION_WEIGHTED,
LabelArbiter,
} from './labelArbiter.js';
// Compact rows retain the full identity/corner/rect contract:
// key@corner@x@y@w@h (fade rows append @alpha@selected).
const GOLDEN = {
elasticTie12: {
selected: 'firms:000,firms:008,firms:010,vessels:003,vessels:007,vessels:009',
placements: 'firms:000@NE@-229@-190@38@16|firms:008@NE@451@-65@38@16|firms:010@NE@153@-304@38@16|vessels:003@NE@94@-74@38@16|vessels:007@NE@-418@-73@38@16|vessels:009@NE@-210@180@38@16',
fades: [],
selectedCount: 6,
quotas: 'firms:3,vessels:3',
labelsByLayer: 'firms:3,vessels:3',
queue: [2, 6],
spatialCellCount: 27,
},
weightedFew24: {
selected: 'cctv:003,cctv:009,cctv:015,cctv:018,firms:010,firms:016,vessels:008,vessels:011,vessels:020',
placements: 'cctv:003@NE@-163@-259@38@16|cctv:009@NE@188@-51@38@16|cctv:015@NE@-230@100@38@16|cctv:018@NE@-431@-109@38@16|firms:010@NE@-199@-290@38@16|firms:016@NE@232@232@38@16|vessels:008@NE@52@-209@38@16|vessels:011@NE@-68@-54@38@16|vessels:020@NE@-31@199@38@16',
fades: [],
selectedCount: 9,
quotas: 'cctv:4,firms:2,vessels:3',
labelsByLayer: 'cctv:4,firms:2,vessels:3',
queue: [3, 9],
spatialCellCount: 28,
},
duplicateKeys48: {
selected: 'a-blocker:fixed,z-duplicates:shared',
placements: 'a-blocker:fixed@only@0@0@20@20|z-duplicates:shared@free@300@300@20@20',
fades: [],
selectedCount: 2,
quotas: 'a-blocker:1,z-duplicates:1',
labelsByLayer: 'a-blocker:1,z-duplicates:1',
queue: [2, 2],
spatialCellCount: 5,
},
stable96: [
{
selected: 'flights:003,flights:009,flights:033,flights:057,flights:087,satellites:055,satellites:067,satellites:073,satellites:079,satellites:085,vessels:017,vessels:029,vessels:035,vessels:083,vessels:095',
placements: 'flights:003@NE@-214@22@38@16|flights:009@NE@190@-267@38@16|flights:033@NE@-437@237@38@16|flights:057@NE@43@241@38@16|flights:087@NE@384@3@38@16|satellites:055@NE@-343@-236@38@16|satellites:067@NE@420@187@38@16|satellites:073@NE@-380@40@38@16|satellites:079@NE@256@32@38@16|satellites:085@NE@-80@207@38@16|vessels:017@NE@-212@-132@38@16|vessels:029@NE@12@-14@38@16|vessels:035@NE@130@-106@38@16|vessels:083@NE@-43@-289@38@16|vessels:095@NE@439@-262@38@16',
fades: [],
selectedCount: 15,
quotas: 'flights:5,satellites:5,vessels:5',
labelsByLayer: 'flights:5,satellites:5,vessels:5',
queue: [3, 15],
spatialCellCount: 52,
},
{
selected: 'flights:003,flights:009,flights:033,flights:057,flights:087,satellites:055,satellites:067,satellites:073,satellites:079,satellites:085,vessels:017,vessels:029,vessels:035,vessels:083,vessels:095',
placements: 'flights:003@NE@-214@22@38@16|flights:009@NE@190@-267@38@16|flights:033@NE@-437@237@38@16|flights:057@NE@43@241@38@16|flights:087@NE@384@3@38@16|satellites:055@NE@-343@-236@38@16|satellites:067@NE@420@187@38@16|satellites:073@NE@-380@40@38@16|satellites:079@NE@256@32@38@16|satellites:085@NE@-80@207@38@16|vessels:017@NE@-212@-132@38@16|vessels:029@NE@12@-14@38@16|vessels:035@NE@130@-106@38@16|vessels:083@NE@-43@-289@38@16|vessels:095@NE@439@-262@38@16',
fades: [],
selectedCount: 15,
quotas: 'flights:5,satellites:5,vessels:5',
labelsByLayer: 'flights:5,satellites:5,vessels:5',
queue: [0, 0],
spatialCellCount: 52,
},
],
clustered200: {
selected: 'firms:000,firms:001',
placements: 'firms:000@above@70@66@60@20|firms:001@below@70@114@60@20',
fades: [],
selectedCount: 2,
quotas: 'firms:200',
labelsByLayer: 'firms:2',
queue: [2, 4],
spatialCellCount: 9,
},
packedCells400: {
selected: 'air:000,air:003,air:051,air:066,air:171,air:210,air:330,air:351,ground:013,ground:124,ground:157,ground:379,sea:146,sea:230,sea:281,sea:347,sea:383,sea:386',
placements: 'air:000@packed@-639@-127@10@10|air:003@packed@-607@-639@10@10|air:051@NE@-4288@546@38@16|air:066@NE@-245@-2390@38@16|air:171@NE@5493@2134@38@16|air:210@NE@2943@2389@38@16|air:330@NE@3289@-1454@38@16|air:351@NE@5423@-3072@38@16|ground:013@NE@2371@-2822@38@16|ground:124@NE@-20@3579@38@16|ground:157@NE@-1642@-3390@38@16|ground:379@NE@5196@-1036@38@16|sea:146@NE@-3559@-3258@38@16|sea:230@NE@-1938@-1868@38@16|sea:281@NE@5212@-1783@38@16|sea:347@NE@-2185@2564@38@16|sea:383@NE@2177@-1527@38@16|sea:386@NE@4250@1117@38@16',
fades: [],
selectedCount: 18,
quotas: 'air:8,ground:4,sea:6',
labelsByLayer: 'air:8,ground:4,sea:6',
queue: [3, 18],
spatialCellCount: 55,
},
fadeSequence12: [
{
selected: 'tracks:000,tracks:005,tracks:007,tracks:009',
placements: 'tracks:000@NE@-51@-134@38@16|tracks:005@NE@-179@-256@38@16|tracks:007@NE@347@200@38@16|tracks:009@NE@446@22@38@16',
fades: [
'7000:',
'7075:tracks:000@NE@-51@-134@38@[email protected]@true|tracks:005@NE@-179@-256@38@[email protected]@true|tracks:007@NE@347@200@38@[email protected]@true|tracks:009@NE@446@22@38@[email protected]@true',
'7150:tracks:000@NE@-51@-134@38@16@1@true|tracks:005@NE@-179@-256@38@16@1@true|tracks:007@NE@347@200@38@16@1@true|tracks:009@NE@446@22@38@16@1@true',
],
selectedCount: 4,
quotas: 'tracks:4',
labelsByLayer: 'tracks:4',
queue: [1, 4],
spatialCellCount: 15,
},
{
selected: 'tracks:007,tracks:009',
placements: 'tracks:007@NE@347@200@38@16|tracks:009@NE@446@22@38@16',
fades: [
'7200:tracks:000@NE@-51@-134@38@16@1@false|tracks:005@NE@-179@-256@38@16@1@false|tracks:007@NE@347@200@38@16@1@true|tracks:009@NE@446@22@38@16@1@true',
'7275:tracks:000@NE@-51@-134@38@[email protected]@false|tracks:005@NE@-179@-256@38@[email protected]@false|tracks:007@NE@347@200@38@16@1@true|tracks:009@NE@446@22@38@16@1@true',
'7350:tracks:000@NE@-51@-134@38@[email protected]@false|tracks:005@NE@-179@-256@38@[email protected]@false|tracks:007@NE@347@200@38@16@1@true|tracks:009@NE@446@22@38@16@1@true',
'7501:tracks:007@NE@347@200@38@16@1@true|tracks:009@NE@446@22@38@16@1@true',
],
selectedCount: 2,
quotas: 'tracks:2',
labelsByLayer: 'tracks:2',
queue: [1, 2],
spatialCellCount: 15,
},
],
};
function seededRandom(seed) {
let state = seed >>> 0;
return () => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
return state / 0x100000000;
};
}
function rect(x, y, w = 38, h = 16) {
return { x, y, w, h };
}
function placementsFor(x, y, shape = 'full') {
if (shape === 'verticalOnly') {
return [
{ corner: 'above', rect: rect(x - 30, y - 34, 60, 20) },
{ corner: 'below', rect: rect(x - 30, y + 14, 60, 20) },
];
}
if (shape === 'few') {
return [
{ corner: 'NE', rect: rect(x + 8, y - 24) },
{ corner: 'SW', rect: rect(x - 46, y + 8) },
];
}
return [
{ corner: 'NE', rect: rect(x + 8, y - 24) },
{ corner: 'NW', rect: rect(x - 46, y - 24) },
{ corner: 'SE', rect: rect(x + 8, y + 8) },
{ corner: 'SW', rect: rect(x - 46, y + 8) },
];
}
function makeCohort(count, {
seed,
layers,
shape = 'full',
tieHeavy = false,
spreadX = 900,
spreadY = 560,
} = {}) {
const random = seededRandom(seed);
return Array.from({ length: count }, (_, index) => {
const x = Math.floor(random() * spreadX) - Math.floor(spreadX / 2);
const y = Math.floor(random() * spreadY) - Math.floor(spreadY / 2);
const layerId = layers[index % layers.length];
return {
key: `${layerId}:${String(index).padStart(3, '0')}`,
layerId,
sourceId: index,
priority: tieHeavy ? index % 2 : Math.floor(random() * 7),
centerDistance: tieHeavy ? 100 : Math.hypot(x, y),
keyholeAlpha: tieHeavy ? 1 : 0.55 + Math.floor(random() * 4) * 0.1,
screenX: x,
screenY: y,
placements: placementsFor(x, y, shape),
};
});
}
function roundedAlpha(value) {
return Number(value.toFixed(6));
}
function snapshotSolve(arbiter, candidates, options, fadeTimes = []) {
const diagnostics = arbiter.solve(candidates, options);
const selected = Array.from(arbiter.selectedKeys).sort();
const placements = selected.map((key) => {
const state = arbiter.states.get(key);
const { x, y, w, h } = state.lastPlacement.rect;
return [key, state.corner, x, y, w, h];
});
const current = new Map(candidates.map((candidate) => [candidate.key, candidate]));
const fades = fadeTimes.map((now) => [
now,
...arbiter.renderEntries(current, now)
.map((entry) => {
const { x, y, w, h } = entry.placement.rect;
return [
entry.candidate.key,
entry.placement.corner,
x,
y,
w,
h,
roundedAlpha(entry.temporalAlpha),
entry.selected,
];
})
.sort(([a], [b]) => a.localeCompare(b)),
]);
return {
selected: selected.join(','),
placements: placements.map((row) => row.join('@')).join('|'),
fades: fades.map(([now, ...entries]) => `${now}:${entries.map((row) => row.join('@')).join('|')}`),
selectedCount: diagnostics.selectedCount,
quotas: Object.entries(diagnostics.quotas).map((row) => row.join(':')).join(','),
labelsByLayer: Object.entries(diagnostics.labelsByLayer).map((row) => row.join(':')).join(','),
queue: [diagnostics.spatialQueueBuildCount, diagnostics.spatialQueueNextCount],
spatialCellCount: arbiter._spatial.cells.size,
};
}
function buildGoldenBattery() {
const elasticTie12 = makeCohort(12, {
seed: 0x12e1a57,
layers: ['firms', 'vessels'],
tieHeavy: true,
});
const elasticArbiter = new LabelArbiter();
const weightedFew24 = makeCohort(24, {
seed: 0x24fe771,
layers: ['cctv', 'firms', 'vessels'],
shape: 'few',
});
const weightedArbiter = new LabelArbiter();
const duplicateTail = makeCohort(45, {
seed: 0x48d0b1e,
layers: ['z-duplicates'],
shape: 'verticalOnly',
}).map((candidate, index) => ({
...candidate,
key: `z-duplicates:tail-${String(index).padStart(2, '0')}`,
priority: 0,
}));
const duplicateCandidates = [
{
key: 'a-blocker:fixed',
layerId: 'a-blocker',
sourceId: 'blocker',
priority: 100,
centerDistance: 0,
keyholeAlpha: 1,
screenX: 0,
screenY: 0,
placements: [{ corner: 'only', rect: rect(0, 0, 20, 20) }],
},
{
key: 'z-duplicates:shared',
layerId: 'z-duplicates',
sourceId: 'blocked-copy',
priority: 99,
centerDistance: 1,
keyholeAlpha: 1,
screenX: 4,
screenY: 4,
placements: [{ corner: 'blocked', rect: rect(0, 0, 20, 20) }],
},
{
key: 'z-duplicates:shared',
layerId: 'z-duplicates',
sourceId: 'free-copy',
priority: 99,
centerDistance: 1,
keyholeAlpha: 1,
screenX: 300,
screenY: 300,
placements: [{ corner: 'free', rect: rect(300, 300, 20, 20) }],
},
...duplicateTail,
];
const duplicateArbiter = new LabelArbiter();
const stable96 = makeCohort(96, {
seed: 0x96c0ffee,
layers: ['flights', 'satellites', 'vessels'],
shape: 'few',
tieHeavy: true,
});
const stableArbiter = new LabelArbiter();
const clustered200 = makeCohort(200, {
seed: 0x200f1e1d,
layers: ['firms'],
shape: 'verticalOnly',
}).map((candidate, index) => ({
...candidate,
priority: 200 - index,
centerDistance: index,
screenX: 100,
screenY: 100,
placements: placementsFor(100, 100, 'verticalOnly'),
}));
const clusteredArbiter = new LabelArbiter();
const packed400 = makeCohort(400, {
seed: 0x400b17c5,
layers: ['air', 'ground', 'sea'],
shape: 'few',
spreadX: 12000,
spreadY: 8000,
});
const packedCell = (candidate, cellX, cellY, priority) => ({
...candidate,
priority,
keyholeAlpha: 1,
centerDistance: 0,
screenX: cellX * 32 + 1,
screenY: cellY * 32 + 1,
placements: [{
corner: 'packed',
rect: rect(cellX * 32 + 1, cellY * 32 + 1, 10, 10),
}],
});
packed400[0] = packedCell(packed400[0], -20, -4, 1000);
packed400[3] = packedCell(packed400[3], -19, -20, 999);
const packedArbiter = new LabelArbiter();
const fade12 = makeCohort(12, {
seed: 0xfade0012,
layers: ['tracks'],
shape: 'few',
});
const fadeArbiter = new LabelArbiter();
return {
elasticTie12: snapshotSolve(elasticArbiter, elasticTie12, {
capacity: 6,
strategy: ALLOCATION_ELASTIC,
now: 1000,
preserveIncumbents: false,
}),
weightedFew24: snapshotSolve(weightedArbiter, weightedFew24, {
capacity: 9,
strategy: ALLOCATION_WEIGHTED,
layerWeights: { cctv: 2, firms: 0.75, vessels: 1.25 },
now: 2000,
preserveIncumbents: false,
}),
duplicateKeys48: snapshotSolve(duplicateArbiter, duplicateCandidates, {
capacity: 2,
strategy: ALLOCATION_ELASTIC,
demandByLayer: { 'a-blocker': 1, 'z-duplicates': 47 },
now: 3000,
preserveIncumbents: false,
}),
stable96: [
snapshotSolve(stableArbiter, stable96, {
capacity: 15,
strategy: ALLOCATION_WEIGHTED,
now: 4000,
preserveIncumbents: true,
}),
snapshotSolve(stableArbiter, stable96.slice().reverse(), {
capacity: 15,
strategy: ALLOCATION_WEIGHTED,
now: 4300,
preserveIncumbents: true,
}),
],
clustered200: snapshotSolve(clusteredArbiter, clustered200, {
capacity: 200,
strategy: ALLOCATION_ELASTIC,
now: 5000,
preserveIncumbents: false,
}),
packedCells400: snapshotSolve(packedArbiter, packed400, {
capacity: 18,
strategy: ALLOCATION_WEIGHTED,
layerWeights: { air: 1.5, ground: 0.75, sea: 1 },
now: 6000,
preserveIncumbents: false,
}),
fadeSequence12: [
snapshotSolve(fadeArbiter, fade12, {
capacity: 4,
strategy: ALLOCATION_ELASTIC,
now: 7000,
preserveIncumbents: false,
}, [7000, 7075, 7150]),
snapshotSolve(fadeArbiter, fade12.slice(4), {
capacity: 2,
strategy: ALLOCATION_ELASTIC,
now: 7200,
preserveIncumbents: false,
}, [7200, 7275, 7350, 7501]),
],
};
}
if (process.env.GEV_PRINT_ARBITER_GOLDEN === '1') {
process.stdout.write(`${JSON.stringify(buildGoldenBattery(), null, 2)}\n`);
} else {
test('seeded arbiter scenario battery matches the current golden behavior', () => {
assert.deepEqual(buildGoldenBattery(), GOLDEN);
});
}