openvibers/openvibe.media Commits
Additive step 2 of F1b: replay simulator (#12)
Changes
nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -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');