openvibers/openvibe.media Commits

Additive step 2 of F1b: replay simulator (#12)

OP OpenVibers committed 2 Oct 2026 · e9495e4225 · 2 files, +159 −0

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Media/pull/12

Changes

ddiff --git a/server/placement/replay-sim.js b/server/placement/replay-sim.js
nnew file mode 100644
iindex 0000000..29dc0dc
---- /dev/null
++++ b/server/placement/replay-sim.js
@@@ -0,0 +1,83 @@
++'use strict';
++/**
++ * Placement replay simulator (F1b, docs/media-fabric.md §10): replays recorded tier decisions
++ * (rows of media_object_tier_decisions) against a candidate policy and reports what it would have done.
++ * Pure: no database, providers or bytes; the input rows are never modified.
++ *
++ * policy is {maxPromotionsPerSweep, maxDemotionsPerSweep, minResidencyMs} (a missing or non-finite limit
++ * means unlimited / none), either flat for every class or keyed by class: {video: {...}, default: {...}}.
++ * A row's class is row.class, else inputs.class, else inputs.kind, else 'default'.
++ *
++ * Only 'done' and 'dry_run' rows are proposals; refused, failed and already rows moved nothing and are
++ * counted in `ignored`. Proposals replay in time order (decided_at, then id). A sweep is row.sweep when
++ * given, else the sweepMs-wide time bucket (default one hour). For each proposal:
++ * 1. hysteresis: an object that moved less than minResidencyMs ago stays put (suppressedByHysteresis);
++ * 2. budget: a sweep spends at most maxPromotionsPerSweep / maxDemotionsPerSweep per class and
++ * the rest is suppressedByBudget (a suppressed proposal spends nothing and starts no residency).
++ * A flap is an applied move that reverses the object's previous applied move within flapWindowMs (24 h).
++ *
++ * → {moves, suppressedByBudget, suppressedByHysteresis, flaps, ignored}
++ */
++
++const DEFAULT_SWEEP_MS = 3600 * 1000;
++const DEFAULT_FLAP_WINDOW_MS = 24 * 3600 * 1000;
++const LIMIT_KEYS = ['maxPromotionsPerSweep', 'maxDemotionsPerSweep', 'minResidencyMs'];
++
++function toMs(v) {
++ if (v instanceof Date) return v.getTime();
++ if (typeof v === 'number') return v;
++ return Date.parse(v);
++}
++
++function parseInputs(v) {
++ if (v && typeof v === 'object') return v;
++ try { return JSON.parse(v || '{}') || {}; } catch (_) { return {}; }
++}
++
++function limit(v) {
++ return Number.isFinite(Number(v)) && v !== null && v !== '' ? Number(v) : Infinity;
++}
++
++function policyFor(policy, cls) {
++ const p = policy || {};
++ const flat = LIMIT_KEYS.some((k) => k in p);
++ const src = flat ? p : (p[cls] || p.default || {});
++ return {
++ promotions: limit(src.maxPromotionsPerSweep),
++ demotions: limit(src.maxDemotionsPerSweep),
++ residencyMs: Number.isFinite(Number(src.minResidencyMs)) ? Math.max(0, Number(src.minResidencyMs)) : 0,
++ };
++}
++
++function simulate({ decisions = [], policy = {}, sweepMs = DEFAULT_SWEEP_MS, flapWindowMs = DEFAULT_FLAP_WINDOW_MS } = {}) {
++ const out = { moves: [], suppressedByBudget: 0, suppressedByHysteresis: 0, flaps: 0, ignored: 0 };
++ const proposals = [];
++ for (const row of decisions) {
++ const at = toMs(row && row.decided_at);
++ if (!row || !Number.isFinite(at) || (row.action !== 'promote' && row.action !== 'demote')
++ || (row.outcome !== 'done' && row.outcome !== 'dry_run')) { out.ignored++; continue; }
++ const inputs = parseInputs(row.inputs);
++ proposals.push({ row, at, cls: row.class || inputs.class || inputs.kind || 'default' });
++ }
++ proposals.sort((a, b) => a.at - b.at || (Number(a.row.id) || 0) - (Number(b.row.id) || 0));
++
++ const last = new Map(); // object_id → {action, at} of its last applied move
++ const spent = new Map(); // `${sweep}|${cls}|${action}` → moves applied
++ for (const { row, at, cls } of proposals) {
++ const p = policyFor(policy, cls);
++ const prev = last.get(row.object_id);
++ if (prev && at - prev.at < p.residencyMs) { out.suppressedByHysteresis++; continue; }
++ const sweep = row.sweep != null ? String(row.sweep) : String(Math.floor(at / sweepMs));
++ const key = `${sweep}|${cls}|${row.action}`;
++ const n = spent.get(key) || 0;
++ if (n >= (row.action === 'promote' ? p.promotions : p.demotions)) { out.suppressedByBudget++; continue; }
++ spent.set(key, n + 1);
++ if (prev && prev.action !== row.action && at - prev.at < flapWindowMs) out.flaps++;
++ last.set(row.object_id, { action: row.action, at });
++ out.moves.push({ object_id: row.object_id, action: row.action, class: cls, sweep,
++ at: new Date(at).toISOString(), from: row.from_provider || null, to: row.to_provider || null });
++ }
++ return out;
++}
++
++module.exports = { simulate };
ddiff --git a/test/placement-replay-sim.test.js b/test/placement-replay-sim.test.js
nnew file mode 100644
iindex 0000000..378fd8b
---- /dev/null
++++ b/test/placement-replay-sim.test.js
@@@ -0,0 +1,76 @@
++'use strict';
++// Placement replay simulator (F1b): budgets per sweep, minimum residency, flap counting. Pure, no database.
++const assert = require('assert');
++const { simulate } = require('../server/placement/replay-sim');
++
++const H = 3600 * 1000;
++const T0 = Date.parse('2026-09-30T00:00:00.000Z');
++let id = 0;
++const row = (obj, action, hours, extra = {}) => ({
++ id: ++id, decided_at: new Date(T0 + hours * H).toISOString(), object_id: obj, action, outcome: 'done',
++ from_provider: action === 'promote' ? 'b2' : 'r2', to_provider: action === 'promote' ? 'r2' : 'b2',
++ inputs: JSON.stringify({ kind: 'video' }), ...extra,
++});
++
++// No policy: every proposal applies, in time order, and only done/dry_run rows count.
++{
++ const rows = [row('b', 'promote', 2), row('a', 'promote', 1), row('c', 'promote', 1, { outcome: 'refused' }), row('d', 'demote', 3, { outcome: 'dry_run' })];
++ const before = JSON.stringify(rows);
++ const r = simulate({ decisions: rows, policy: {} });
++ assert.deepStrictEqual(r.moves.map((m) => m.object_id), ['a', 'b', 'd']);
++ assert.strictEqual(r.ignored, 1);
++ assert.strictEqual(r.suppressedByBudget + r.suppressedByHysteresis + r.flaps, 0);
++ assert.strictEqual(JSON.stringify(rows), before, 'input rows are not modified');
++}
++
++// Budget: two promotions per sweep; the third in the same hour is suppressed, the next hour has a fresh budget.
++{
++ const rows = [row('a', 'promote', 1.1), row('b', 'promote', 1.2), row('c', 'promote', 1.3), row('d', 'promote', 2.1), row('e', 'demote', 1.4)];
++ const r = simulate({ decisions: rows, policy: { maxPromotionsPerSweep: 2, maxDemotionsPerSweep: 1 } });
++ assert.deepStrictEqual(r.moves.map((m) => m.object_id), ['a', 'b', 'e', 'd']);
++ assert.strictEqual(r.suppressedByBudget, 1);
++ // An explicit sweep id wins over the time bucket.
++ const s = simulate({ decisions: [row('a', 'promote', 1, { sweep: 7 }), row('b', 'promote', 5, { sweep: 7 })], policy: { maxPromotionsPerSweep: 1 } });
++ assert.strictEqual(s.moves.length, 1);
++ assert.strictEqual(s.suppressedByBudget, 1);
++}
++
++// Flapping object: promoted and demoted every hour for six hours.
++{
++ const rows = [];
++ for (let h = 0; h < 6; h++) rows.push(row('flappy', h % 2 ? 'demote' : 'promote', h));
++ const loose = simulate({ decisions: rows, policy: { minResidencyMs: 0 } });
++ assert.strictEqual(loose.moves.length, 6);
++ assert.strictEqual(loose.flaps, 5);
++
++ // A 3 h minimum residency lets hours 0 and 3 through (a demote after a promote: one flap), and holds the rest.
++ const held = simulate({ decisions: rows, policy: { minResidencyMs: 3 * H } });
++ assert.deepStrictEqual(held.moves.map((m) => m.action), ['promote', 'demote']);
++ assert.strictEqual(held.suppressedByHysteresis, 4);
++ assert.strictEqual(held.flaps, 1);
++
++ // A residency longer than the whole run leaves one move and no flaps.
++ const frozen = simulate({ decisions: rows, policy: { minResidencyMs: 24 * H } });
++ assert.strictEqual(frozen.moves.length, 1);
++ assert.strictEqual(frozen.suppressedByHysteresis, 5);
++ assert.strictEqual(frozen.flaps, 0);
++}
++
++// A move held by hysteresis spends no budget and does not restart the residency clock.
++{
++ const rows = [row('a', 'promote', 0.1), row('a', 'demote', 0.2), row('b', 'promote', 0.3), row('a', 'demote', 2.1)];
++ const r = simulate({ decisions: rows, policy: { maxPromotionsPerSweep: 2, minResidencyMs: 2 * H } });
++ assert.deepStrictEqual(r.moves.map((m) => m.object_id + ':' + m.action), ['a:promote', 'b:promote', 'a:demote']);
++ assert.strictEqual(r.suppressedByHysteresis, 1);
++ assert.strictEqual(r.suppressedByBudget, 0);
++}
++
++// Per-class policy: video is capped, everything else falls to the default (unlimited).
++{
++ const rows = [row('v1', 'promote', 1.1), row('v2', 'promote', 1.2), row('i1', 'promote', 1.3, { inputs: JSON.stringify({ kind: 'image' }) })];
++ const r = simulate({ decisions: rows, policy: { video: { maxPromotionsPerSweep: 1 }, default: {} } });
++ assert.deepStrictEqual(r.moves.map((m) => m.object_id), ['v1', 'i1']);
++ assert.strictEqual(r.suppressedByBudget, 1);
++}
++
++console.log('placement-replay-sim ok');