diff --git a/extension/peerd-runtime/web-pace/reducer.js b/extension/peerd-runtime/web-pace/reducer.js new file mode 100644 index 00000000..c6389141 --- /dev/null +++ b/extension/peerd-runtime/web-pace/reducer.js @@ -0,0 +1,449 @@ +// @ts-check +// web-pace/reducer.js — the PURE policy core of adaptive per-origin action +// pacing. Issue #234 is the decision record. +// +// This module is not wired into storage, hooks, dispatch, or UI. It changes no +// extension behavior on its own. A future integration must keep rules in the +// trusted service-worker control plane, feed only fixed trusted classifiers +// into these transitions, and keep rule history out of model context. Arbitrary +// page text is never a pacing instruction. +// +// THE SHAPE. The default interval is zero. A trusted observer may pass a block +// signal through nextRuleOnBlock, producing an interval for that exact origin. +// Quiet time decays it toward zero. A probe may test one action at a lower +// interval, then adopt that interval only after a clean outcome and settle +// period. The intended loop is observe -> learn -> enforce -> decay/probe. +// +// why a pure core: every transition here is a function of (rule, signal, now). +// No storage, no clock, no Math.random — the caller injects them. That makes the +// policy deterministic under Bun (see web-pace-reducer.test.ts) and keeps the +// enforcement/observation hooks as thin imperative shells, per the module's +// functional-core rule. +// +// THE FENCE (why this file holds no page strings). The integration must treat a +// rule as control-plane data, not prose for the model. This reducer does not +// establish that boundary by itself. +// +// THE DESCENT RULE (the load-bearing safety property). A rule rises only from +// trusted block signals, and it may come down only through automatic time-based +// decay or a bounded probe that snaps back on a block. There is deliberately no +// clearRule() or model-controlled set operation. LOWERING is the direction an +// injected page benefits from: retire the rule, peerd hammers the site, and the +// user's logged-in account absorbs the risk. An argument check cannot validate +// intent. Descent is therefore limited to time-based decay and the one-action +// probe state machine below. + +import { clamp } from '/shared/util.js'; + +/** + * One learned rule for one origin. The FIELD SET is the contract (the numbers + * are seeds, below); `minIntervalMs: 0` means "no rule" — the default state of + * every origin peerd has never been blocked by. + * + * @typedef {Object} PaceRule + * @property {1} version persisted shape version + * @property {string} origin the key this rule binds to + * @property {number} minIntervalMs enforced gap between action tools; 0 == no rule + * @property {number} jitterFrac ± fraction applied to the gap (a UNIFORM gap is itself a tell) + * @property {number} observations how many blocks have fed this rule + * @property {number} lastBlockAt epoch ms of the last observed block; drives decay eligibility + * @property {number} lastDecayAt epoch ms of the last decay/adopt + * @property {number} createdAt epoch ms + * @property {number} updatedAt epoch ms + * @property {'learned'|'retry-after'} lastEscalationSource source of the last increase + * @property {null | { + * trialMs: number, + * startedAt: number, + * until: number, + * prevMinIntervalMs: number, + * actionStartedAt: number | null, + * cleanObservedAt: number | null, + * }} probe a one-action staleness test + */ + +/** + * The tunables bundle (`K`). These are starting values to tune through the eval + * harness and field reports. Injection lets tests pin exact arithmetic. + * + * @typedef {Object} PaceTunables + * @property {number} growth multiplier on a REPEAT block (compounding escalation) + * @property {number} slowdownMult "we were going at X and got blocked -> go slowdownMult * X" + * @property {number} seedMs floor for a FIRST block when no cadence sample exists + * @property {number} decay multiplier applied per elapsed quiet period (< 1) + * @property {number} quietMs block-free time that earns one decay step + * @property {number} maxPaceMs ceiling; at/over it the future caller must hand off rather than wait + * @property {number} jitterFrac default ± fraction for a new rule + * @property {number} retireFloorMs decayed below this, the rule is retired (deleted) + * @property {number} minProbeWindowMs shortest useful observation window + * @property {number} maxProbeWindowMs longest a probe may suppress normal decay + * @property {number} probeSettleMs quiet time after a clean observed result + */ + +/** @type {PaceTunables} */ +export const PACE_TUNABLES = Object.freeze({ + growth: 2, + slowdownMult: 2, + // why a seed: a block is evidence even when we have no cadence sample to size + // from (an SW restart lost the interval ring, or the block landed on the first + // action). Without a floor, `max()` of unknowns is 0 and we would "learn" + // no rule from a real block. + seedMs: 1_000, + decay: 0.5, + quietMs: 30 * 60_000, + // why a ceiling at all: pacing is for shaving seconds. A site that wants us + // minutes slower is not a pacing problem. needsHandOff() tells the future + // controller to use a challenge hand-back or assist-only posture instead of + // silently sleeping a turn. + maxPaceMs: 30_000, + jitterFrac: 0.3, + retireFloorMs: 250, + minProbeWindowMs: 5_000, + maxProbeWindowMs: 5 * 60_000, + probeSettleMs: 2_000, +}); + +export const PACE_RULE_VERSION = 1; + +/** @param {unknown} value @returns {value is number} */ +const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value); + +/** + * Validate data at the persistence boundary before any action uses it. An + * unreadable rule must make a future write path fail closed, not turn pacing + * off. The optional origin check prevents a valid record being replayed under + * another key. + * + * @param {unknown} value + * @param {string | undefined} [expectedOrigin] + * @param {PaceTunables} [K] + * @returns {value is PaceRule} + */ +export const isValidRule = (value, expectedOrigin, K = PACE_TUNABLES) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const rule = /** @type {Partial} */ (value); + if (rule.version !== PACE_RULE_VERSION) return false; + if (typeof rule.origin !== 'string' || rule.origin.length === 0) return false; + if (expectedOrigin !== undefined && rule.origin !== expectedOrigin) return false; + if (!isFiniteNumber(rule.minIntervalMs) + || rule.minIntervalMs < 0 + || rule.minIntervalMs > K.maxPaceMs) return false; + if (!isFiniteNumber(rule.jitterFrac) + || rule.jitterFrac < 0 + || rule.jitterFrac > K.jitterFrac) return false; + const observations = rule.observations; + if (!isFiniteNumber(observations) || !Number.isInteger(observations) || observations < 0) return false; + const { lastBlockAt, lastDecayAt, createdAt, updatedAt } = rule; + if (!isFiniteNumber(lastBlockAt) || lastBlockAt < 0 + || !isFiniteNumber(lastDecayAt) || lastDecayAt < 0 + || !isFiniteNumber(createdAt) || createdAt < 0 + || !isFiniteNumber(updatedAt) || updatedAt < 0) return false; + if (updatedAt < createdAt || lastDecayAt < createdAt || lastDecayAt > updatedAt) return false; + if (observations === 0) { + if (lastBlockAt !== 0 || rule.minIntervalMs !== 0) return false; + } else if (lastBlockAt < createdAt || lastBlockAt > updatedAt) return false; + if (rule.lastEscalationSource !== 'learned' && rule.lastEscalationSource !== 'retry-after') return false; + if (observations === 0 && rule.lastEscalationSource !== 'learned') return false; + if (rule.probe === null) return true; + if (!rule.probe || typeof rule.probe !== 'object') return false; + const probe = rule.probe; + if (!isFiniteNumber(probe.trialMs) + || probe.trialMs < 0 + || probe.trialMs > rule.minIntervalMs) return false; + if (!isFiniteNumber(probe.prevMinIntervalMs) + || probe.prevMinIntervalMs !== rule.minIntervalMs) return false; + const { startedAt, until, actionStartedAt, cleanObservedAt } = probe; + if (!isFiniteNumber(startedAt) || !isFiniteNumber(until)) return false; + const windowMs = until - startedAt; + if (windowMs < K.minProbeWindowMs || windowMs > K.maxProbeWindowMs) return false; + if (startedAt < createdAt || startedAt > updatedAt) return false; + if (actionStartedAt !== null + && (!isFiniteNumber(actionStartedAt) + || actionStartedAt < startedAt + || actionStartedAt >= until + || actionStartedAt > updatedAt)) return false; + if (cleanObservedAt !== null + && (actionStartedAt === null + || !isFiniteNumber(cleanObservedAt) + || cleanObservedAt < actionStartedAt + || cleanObservedAt !== updatedAt)) return false; + return true; +}; + +/** + * The default state of an origin: known, but costing nothing. Callers mint this + * lazily on the first signal for an origin — a rule that never leaves + * `minIntervalMs: 0` is indistinguishable from having no rule at all. + * + * @param {string} origin @param {number} now @param {PaceTunables} [K] + * @returns {PaceRule} + */ +export const newRule = (origin, now, K = PACE_TUNABLES) => ({ + version: PACE_RULE_VERSION, + origin, + minIntervalMs: 0, + jitterFrac: K.jitterFrac, + observations: 0, + lastBlockAt: 0, + lastDecayAt: now, + createdAt: now, + updatedAt: now, + lastEscalationSource: 'learned', + probe: null, +}); + +/** + * A block was observed -> escalate. Sized from the speed that ACTUALLY got + * blocked (times a slowdown multiplier) rather than a blind constant, so a first + * block lands near the right cadence in one step instead of ramping through + * many. An explicit server ask (Retry-After) always wins if it is larger — that + * is the site telling us its own number, and honoring it is the whole compliance + * story. + * + * @param {PaceRule} rule + * @param {{ recentIntervalMs?: number, retryAfterMs?: number, now: number }} signal + * recentIntervalMs: our observed gap between recent actions on this origin. + * @param {PaceTunables} [K] + * @returns {PaceRule} + */ +export const nextRuleOnBlock = (rule, { recentIntervalMs, retryAfterMs, now }, K = PACE_TUNABLES) => { + if (!isValidRule(rule, undefined, K) || !Number.isFinite(now)) return rule; + const effectiveNow = Math.max(now, rule.createdAt, rule.updatedAt, rule.lastBlockAt, rule.lastDecayAt); + const fromCadence = Number.isFinite(recentIntervalMs) && /** @type {number} */ (recentIntervalMs) >= 0 + ? /** @type {number} */ (recentIntervalMs) * K.slowdownMult + : 0; + const fromServer = Number.isFinite(retryAfterMs) && /** @type {number} */ (retryAfterMs) >= 0 + ? /** @type {number} */ (retryAfterMs) + : 0; + const fromExisting = rule.minIntervalMs * K.growth; + const desired = Math.max( + fromExisting, // compounding: repeats mean we are still too fast + fromCadence, + fromServer, + K.seedMs, // a block always yields SOME rule + ); + return { + ...rule, + minIntervalMs: clamp(desired, 0, K.maxPaceMs), + observations: rule.observations + 1, + lastBlockAt: effectiveNow, + updatedAt: effectiveNow, + // A block during a probe is the probe's answer; resolveProbe() owns that + // transition, so a bare block here just drops the trial. + probe: null, + // Attribute the VALUE that won, not merely the presence of a header. A + // smaller Retry-After did not set this rule and must not get credit for it. + lastEscalationSource: fromServer > 0 && fromServer >= Math.max(fromExisting, fromCadence, K.seedMs) + ? 'retry-after' + : 'learned', + }; +}; + +/** + * Block-free time relaxes the rule toward zero, so a one-off block during a + * burst does not tax an origin forever. LAZY + multi-period by design (spec + * open question C): callers recompute on next access rather than running a + * timer, so a rule untouched for five quiet periods must decay five steps at + * once — not one. Never decays mid-probe (the probe owns the value). + * + * @param {PaceRule} rule @param {number} now @param {PaceTunables} [K] + * @returns {PaceRule} + */ +export const decay = (rule, now, K = PACE_TUNABLES) => { + if (!isValidRule(rule, undefined, K)) return rule; + if (rule.probe) return rule; + if (rule.minIntervalMs <= 0) return rule; + const since = now - Math.max(rule.lastBlockAt, rule.lastDecayAt); + if (!Number.isFinite(since) || since < 0) return rule; // clock skew — never relax on a bad clock + const periods = Math.floor(since / K.quietMs); + if (periods < 1) return rule; + return { + ...rule, + minIntervalMs: rule.minIntervalMs * (K.decay ** periods), + lastDecayAt: now, + updatedAt: now, + }; +}; + +/** + * Decayed into irrelevance — the caller deletes the record. Keeping a rule at + * a sub-floor interval would be a permanent row for a site that stopped caring. + * + * @param {PaceRule} rule @param {PaceTunables} [K] + */ +export const isRetired = (rule, K = PACE_TUNABLES) => + isValidRule(rule, undefined, K) + && !rule.probe + && rule.minIntervalMs < K.retireFloorMs + && rule.observations > 0; + +/** + * The site wants us slower than pacing is willing to go. Not a nap — the caller + * escalates to the posture ladder (challenge hand-back, or assist-only for this + * origin). Pacing shaves seconds; it does not stall a turn for minutes. + * + * @param {PaceRule} rule @param {PaceTunables} [K] + */ +export const needsHandOff = (rule, K = PACE_TUNABLES) => + !isValidRule(rule, undefined, K) || rule.minIntervalMs >= K.maxPaceMs; + +/** + * The per-action delay: a CATCH-UP, not a fixed tax. The first action on an + * origin (no prior timestamp) is always free, and an action that naturally + * followed a slow read waits little or nothing — we only ever make up the + * shortfall. Jitter de-regularizes the tempo (a metronome is its own tell). + * While probing, the trial interval is the one enforced. + * + * @param {PaceRule | null | undefined} rule + * @param {number | undefined} lastActionAt epoch ms of the previous paced action on this origin + * @param {number} now + * @param {() => number} [rng] [0,1) source; injected for tests + * @param {PaceTunables} [K] + * @returns {number | null} ms to sleep, or null when the rule cannot authorize an action + */ +export const waitForAction = (rule, lastActionAt, now, rng = Math.random, K = PACE_TUNABLES) => { + if (!rule) return 0; + if (!isValidRule(rule, undefined, K)) return null; + if (needsHandOff(rule, K)) return null; + if (rule.probe && !probeAllowsAction(rule, now, K)) return null; + if (lastActionAt === undefined) return rule.probe ? null : 0; + if (!Number.isFinite(lastActionAt) || lastActionAt < 0) return null; + const base = rule.probe ? rule.probe.trialMs : rule.minIntervalMs; + if (!(base > 0)) return 0; + const elapsed = now - /** @type {number} */ (lastActionAt); + // A backwards or broken wall clock is not evidence that the interval elapsed. + // Require the full base rather than turning clock skew into a pacing bypass. + if (!Number.isFinite(elapsed) || elapsed < 0) return Math.min(base, K.maxPaceMs); + const rawRandom = rng(); + const random = Number.isFinite(rawRandom) ? clamp(rawRandom, 0, 1) : 0.5; + const jitterFrac = rule.jitterFrac; + const jitter = base * jitterFrac * (random * 2 - 1); // ± jitterFrac + // The ceiling governs the ACTUAL wait, not only the pre-jitter interval. + return clamp((base + jitter) - elapsed, 0, K.maxPaceMs); +}; + +/** + * Start a staleness test: drop to a trial interval for a bounded window and see + * whether the site still minds. This is the ONLY way a rule comes down other + * than automatic decay — and it is safe precisely because it is reversible: + * resolveProbe() restores prevMinIntervalMs and re-escalates on a block. + * + * The trial is clamped to at most the current interval — a "probe" that made us + * SLOWER would be a contradiction, and one that could set any value would be + * the very lever this design denies the agent. + * + * @param {PaceRule} rule @param {number} trialMs @param {number} windowMs @param {number} now + * @returns {PaceRule} + */ +export const startProbe = (rule, trialMs, windowMs, now, K = PACE_TUNABLES) => { + if (!isValidRule(rule, undefined, K)) return rule; + if (rule.probe) return rule; // already probing — don't restart the window + if (rule.minIntervalMs <= 0) return rule; // nothing to probe + if (!Number.isFinite(trialMs) || trialMs < 0) return rule; + if (!Number.isFinite(windowMs) || windowMs <= 0) return rule; + if (!Number.isFinite(now) || now < rule.updatedAt) return rule; + const boundedWindow = clamp(windowMs, K.minProbeWindowMs, K.maxProbeWindowMs); + return { + ...rule, + probe: { + trialMs: clamp(trialMs, 0, rule.minIntervalMs), + startedAt: now, + until: now + boundedWindow, + prevMinIntervalMs: rule.minIntervalMs, + actionStartedAt: null, + cleanObservedAt: null, + }, + updatedAt: now, + }; +}; + +/** The probe window has run out; the caller resolves it. @param {PaceRule} rule @param {number} now */ +export const probeExpired = (rule, now, K = PACE_TUNABLES) => + isValidRule(rule, undefined, K) && Number.isFinite(now) && !!rule.probe && now >= rule.probe.until; + +/** + * A probe authorizes exactly one action. The future controller must reserve it + * immediately before dispatch so a second call cannot run at the trial pace. + * + * @param {PaceRule} rule @param {number} now + * @param {PaceTunables} [K] + * @returns {boolean} + */ +export const probeAllowsAction = (rule, now, K = PACE_TUNABLES) => + isValidRule(rule, undefined, K) + && !!rule.probe + && Number.isFinite(now) + && now >= rule.probe.startedAt + && now < rule.probe.until + && rule.probe.actionStartedAt === null; + +/** + * @param {PaceRule} rule @param {number | undefined} lastActionAt + * @param {number} now @param {PaceTunables} [K] + * @returns {PaceRule} + */ +export const beginProbeAction = (rule, lastActionAt, now, K = PACE_TUNABLES) => { + if (!Number.isFinite(lastActionAt) + || /** @type {number} */ (lastActionAt) < 0 + || !probeAllowsAction(rule, now, K) + || !rule.probe) return rule; + return { + ...rule, + probe: { ...rule.probe, actionStartedAt: now }, + updatedAt: now, + }; +}; + +/** + * Record a completed non-blocking outcome from the trusted post-action + * classifier. The action may finish after the probe window, but it must be the + * single action reserved inside that window. + * + * @param {PaceRule} rule @param {number} actionStartedAt @param {number} observedAt + * @param {PaceTunables} [K] + * @returns {PaceRule} + */ +export const noteCleanProbeAction = (rule, actionStartedAt, observedAt, K = PACE_TUNABLES) => { + if (!isValidRule(rule, undefined, K) || !rule.probe) return rule; + if (!Number.isFinite(actionStartedAt) + || !Number.isFinite(observedAt) + || rule.probe.cleanObservedAt !== null + || rule.probe.actionStartedAt !== actionStartedAt + || observedAt < actionStartedAt) return rule; + return { + ...rule, + probe: { ...rule.probe, cleanObservedAt: observedAt }, + updatedAt: observedAt, + }; +}; + +/** + * End a probe. Blocked during the trial -> the site still cares: restore the + * previous interval, re-escalate from the trial cadence that just got blocked, + * and reset the quiet timer (so decay does not immediately undo the lesson). + * Clean -> adopt the lower trial value; if that is under the retire floor the + * rule is now retirable and the caller drops it entirely. + * + * @param {PaceRule} rule @param {'clean'|'blocked'|'inconclusive'} outcome + * @param {number} now @param {PaceTunables} [K] + * @returns {PaceRule} + */ +export const resolveProbe = (rule, outcome, now, K = PACE_TUNABLES) => { + if (!isValidRule(rule, undefined, K) || !rule.probe || !Number.isFinite(now)) return rule; + const { trialMs, prevMinIntervalMs, startedAt, actionStartedAt, cleanObservedAt } = rule.probe; + if (now < startedAt) return rule; + if (outcome === 'blocked') { + if (actionStartedAt === null || now < actionStartedAt) return rule; + const restored = { ...rule, minIntervalMs: prevMinIntervalMs, probe: null }; + return nextRuleOnBlock(restored, { recentIntervalMs: trialMs, now }, K); + } + if (outcome === 'inconclusive') { + if (now < rule.updatedAt || (actionStartedAt !== null && now < actionStartedAt)) return rule; + return { ...rule, minIntervalMs: prevMinIntervalMs, probe: null, updatedAt: now }; + } + if (outcome !== 'clean' || actionStartedAt === null || cleanObservedAt === null) return rule; + // A quiet timer is not a test. Adopt only after the one authorized action + // completed cleanly and a bounded post-result settle period also stayed clean. + const readyAt = Math.max(rule.probe.until, cleanObservedAt + K.probeSettleMs); + if (now < readyAt) return rule; + return { ...rule, minIntervalMs: trialMs, probe: null, lastDecayAt: now, updatedAt: now }; +}; diff --git a/packaging/check-tscheck.ts b/packaging/check-tscheck.ts index f8399e70..a4a4bac3 100644 --- a/packaging/check-tscheck.ts +++ b/packaging/check-tscheck.ts @@ -143,7 +143,9 @@ import { computeCoverage } from './tscheck-coverage.ts'; // 633 → 635: App asset classification and the full binary runner test. // 635 → 636: the recoverable publish transaction is shared by dweb hosts. // 636 → 639: dweb reseed, content ownership, and share rollback are checked. -const COVERED_FLOOR = 647; +// 647 → 648: the adaptive per-origin pacing reducer is checked from its first +// commit, keeping the pure policy core inside the extension typecheck ratchet. +const COVERED_FLOOR = 648; // The scan (walk + // @ts-check detection + the ES5-injected exemption set) // lives in tscheck-coverage.ts so the badge generator reports the same number. diff --git a/tests/peerd-runtime/web-pace-reducer.test.ts b/tests/peerd-runtime/web-pace-reducer.test.ts new file mode 100644 index 00000000..3b8275a7 --- /dev/null +++ b/tests/peerd-runtime/web-pace-reducer.test.ts @@ -0,0 +1,343 @@ +// The pure policy core of adaptive per-origin pacing (issue #234). Every +// transition is a function of (rule, signal, now) with the clock +// and RNG injected, so the arithmetic is pinned exactly rather than approximated. +// +// The load-bearing properties, in order of how much a regression would cost: +// 1. DESCENT IS CONSTRAINED — the only ways down are automatic decay and a +// one-action reversible probe. There is no model-controlled set or clear. +// 2. A block always yields a rule (a block with no cadence sample still learns). +// 3. Decay is lazy + multi-period (rules self-heal; no permanent tax). +// 4. First action is free; pacing is a catch-up, not a fixed tax. + +import { describe, test, expect } from 'bun:test'; +import { + PACE_RULE_VERSION, PACE_TUNABLES, isValidRule, newRule, nextRuleOnBlock, + decay, isRetired, needsHandOff, waitForAction, startProbe, probeExpired, + probeAllowsAction, beginProbeAction, noteCleanProbeAction, resolveProbe, +} from '../../extension/peerd-runtime/web-pace/reducer.js'; + +const K = PACE_TUNABLES; +const T0 = 1_000_000; +// A deterministic RNG: 0.5 → the jitter term is exactly 0 (rng()*2-1 === 0), so +// the catch-up arithmetic is assertable without a band. +const noJitter = () => 0.5; + +describe('web-pace — the default is OFF', () => { + test('a fresh rule costs nothing: no interval, no wait', () => { + const r = newRule('https://x.com', T0); + expect(r.minIntervalMs).toBe(0); + expect(r.version).toBe(PACE_RULE_VERSION); + expect(r.observations).toBe(0); + expect(r.probe).toBe(null); + expect(isRetired(r, K)).toBe(false); + // even with a prior action a moment ago, a rule-less origin runs full speed + expect(waitForAction(r, T0 - 1, T0, noJitter)).toBe(0); + }); + + test('waitForAction on a null rule is 0 (an origin peerd never got blocked by)', () => { + expect(waitForAction(null, T0 - 1, T0, noJitter)).toBe(0); + }); +}); + +describe('web-pace — learning from a block', () => { + test('sizes the interval from the cadence that ACTUALLY got blocked', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + expect(r.minIntervalMs).toBe(4_000 * K.slowdownMult); // "we were doing X → go slower than X" + expect(r.observations).toBe(1); + expect(r.lastBlockAt).toBe(T0); + expect(r.lastEscalationSource).toBe('learned'); + }); + + test('a block with NO cadence sample still learns a rule (the seed floor)', () => { + // An SW restart lost the interval ring, or the block landed on action #1. + // max() over unknowns would be 0 — i.e. "learn nothing from a real block". + const r = nextRuleOnBlock(newRule('https://x.com', T0), { now: T0 }, K); + expect(r.minIntervalMs).toBe(K.seedMs); + }); + + test("an explicit Retry-After wins when it is larger, and is recorded as the site's own ask", () => { + const r = nextRuleOnBlock( + newRule('https://x.com', T0), + { recentIntervalMs: 100, retryAfterMs: 9_000, now: T0 }, + K, + ); + expect(r.minIntervalMs).toBe(9_000); + expect(r.lastEscalationSource).toBe('retry-after'); + }); + + test('a smaller Retry-After does not claim credit for a cadence-sized rule', () => { + const r = nextRuleOnBlock( + newRule('https://x.com', T0), + { recentIntervalMs: 4_000, retryAfterMs: 1_500, now: T0 }, + K, + ); + expect(r.minIntervalMs).toBe(8_000); + expect(r.lastEscalationSource).toBe('learned'); + }); + + test('negative and non-finite samples cannot create or rewrite a rule', () => { + const fresh = newRule('https://x.com', T0); + expect(nextRuleOnBlock(fresh, { recentIntervalMs: -1, retryAfterMs: -2, now: T0 }, K).minIntervalMs) + .toBe(K.seedMs); + expect(nextRuleOnBlock(fresh, { now: NaN }, K)).toEqual(fresh); + expect(nextRuleOnBlock(fresh, { now: Infinity }, K)).toEqual(fresh); + }); + + test('repeat blocks compound (still too fast) and stay bounded by the ceiling', () => { + let r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 2_000, now: T0 }, K); + const first = r.minIntervalMs; + r = nextRuleOnBlock(r, { now: T0 + 1 }, K); + expect(r.minIntervalMs).toBe(first * K.growth); + expect(r.observations).toBe(2); + + // hammer it well past the ceiling — pacing must never promise a multi-minute nap + for (let i = 0; i < 20; i++) r = nextRuleOnBlock(r, { now: T0 + 2 + i }, K); + expect(r.minIntervalMs).toBe(K.maxPaceMs); + expect(needsHandOff(r, K)).toBe(true); // → posture ladder, not a sleep + }); + + test('malformed persisted state is rejected and cannot fail open', () => { + const valid = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 2_000, now: T0 }, K); + const wrongOrigin = { ...valid, origin: 'https://attacker.example' }; + const invalidInterval = { ...valid, minIntervalMs: NaN }; + const invalidJitter = { ...valid, jitterFrac: 1 }; + const incoherentTime = { + ...valid, + createdAt: T0 + 10_000, + updatedAt: T0 + 10_000, + lastBlockAt: 0, + lastDecayAt: 0, + }; + expect(isValidRule(valid, 'https://x.com', K)).toBe(true); + expect(isValidRule(wrongOrigin, 'https://x.com', K)).toBe(false); + expect(isValidRule(invalidInterval, 'https://x.com', K)).toBe(false); + expect(isValidRule(invalidJitter, 'https://x.com', K)).toBe(false); + expect(isValidRule(incoherentTime, 'https://x.com', K)).toBe(false); + expect(waitForAction(invalidInterval, T0, T0 + 1, noJitter, K)).toBe(null); + expect(waitForAction(invalidJitter, T0, T0 + 1, () => 0, K)).toBe(null); + expect(needsHandOff(invalidInterval, K)).toBe(true); + expect(nextRuleOnBlock(invalidInterval, { now: T0 + 1 }, K)).toEqual(invalidInterval); + }); + + test('a block on a regressed clock preserves a valid monotonic rule', () => { + const prior = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 2_000, now: T0 }, K); + const next = nextRuleOnBlock(prior, { now: -1 }, K); + expect(isValidRule(next, 'https://x.com', K)).toBe(true); + expect(next.lastBlockAt).toBe(prior.updatedAt); + expect(next.updatedAt).toBe(prior.updatedAt); + }); +}); + +describe('web-pace — decay self-heals a one-off block', () => { + test('quiet time relaxes the rule; not yet quiet is a no-op', () => { + const blocked = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + expect(decay(blocked, T0 + K.quietMs - 1, K).minIntervalMs).toBe(blocked.minIntervalMs); + expect(decay(blocked, T0 + K.quietMs + 1, K).minIntervalMs).toBe(blocked.minIntervalMs * K.decay); + }); + + test('decay is LAZY: five quiet periods later it decays five steps, not one', () => { + // Rules are recomputed on next access rather than on a timer, so a single + // decay() call must account for all the quiet time that actually elapsed. + const blocked = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + const d = decay(blocked, T0 + K.quietMs * 5 + 1, K); + expect(d.minIntervalMs).toBeCloseTo(blocked.minIntervalMs * (K.decay ** 5), 6); + }); + + test('a site that stopped caring eventually retires its own rule', () => { + let r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + expect(isRetired(r, K)).toBe(false); + r = decay(r, T0 + K.quietMs * 40, K); // a long quiet stretch + expect(r.minIntervalMs).toBeLessThan(K.retireFloorMs); + expect(isRetired(r, K)).toBe(true); // → caller deletes the record; no permanent tax + }); + + test('decay underflow still retires a learned rule', () => { + const blocked = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + const decayed = decay(blocked, T0 + K.quietMs * 10_000, K); + expect(decayed.minIntervalMs).toBe(0); + expect(isRetired(decayed, K)).toBe(true); + }); + + test('never decays mid-probe, and never on a backwards clock', () => { + const blocked = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + const probing = startProbe(blocked, 500, 60_000, T0); + expect(decay(probing, T0 + K.quietMs * 5, K)).toEqual(probing); // the probe owns the value + expect(decay(blocked, T0 - K.quietMs * 5, K)).toEqual(blocked); // clock skew → don't relax + }); +}); + +describe('web-pace — the per-action wait is a catch-up, not a tax', () => { + test('first action on an origin is free even under a rule', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + expect(waitForAction(r, undefined, T0, noJitter)).toBe(0); + }); + + test('only undefined means no prior action; malformed cadence fails closed', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + for (const invalid of [NaN, Infinity, -Infinity, -1, null]) { + expect(waitForAction(r, invalid as unknown as number, T0, noJitter, K)).toBe(null); + } + }); + + test('only the shortfall is waited — natural latency already counts', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 1_000, now: T0 }, K); + expect(r.minIntervalMs).toBe(2_000); + // 1500ms already elapsed since the last action → wait the remaining 500 + expect(waitForAction(r, T0, T0 + 1_500, noJitter)).toBe(500); + // an action that followed a slow read waits nothing at all + expect(waitForAction(r, T0, T0 + 5_000, noJitter)).toBe(0); + }); + + test('jitter stays within ±jitterFrac of the interval (a metronome is its own tell)', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 1_000, now: T0 }, K); + const lo = waitForAction(r, T0, T0, () => 0); // jitter = -jitterFrac + const hi = waitForAction(r, T0, T0, () => 1); // jitter = +jitterFrac + if (lo === null || hi === null) throw new Error('valid rules must produce a wait'); + expect(lo).toBeCloseTo(r.minIntervalMs * (1 - r.jitterFrac), 6); + expect(hi).toBeCloseTo(r.minIntervalMs * (1 + r.jitterFrac), 6); + expect(lo).toBeLessThan(hi); + }); + + test('clock skew cannot turn a learned interval into a bypass', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + expect(waitForAction(r, T0 + 10_000, T0, noJitter)).toBe(r.minIntervalMs); + expect(waitForAction(r, T0, NaN, noJitter)).toBe(r.minIntervalMs); + }); + + test('invalid RNG is neutral and the actual wait never exceeds the ceiling', () => { + const nearCeiling = { + ...nextRuleOnBlock(newRule('https://x.com', T0), { retryAfterMs: K.maxPaceMs - 1, now: T0 }, K), + }; + expect(waitForAction(nearCeiling, T0, T0, () => Infinity, K)).toBe(nearCeiling.minIntervalMs); + expect(waitForAction(nearCeiling, T0, T0, () => 1, K)).toBe(K.maxPaceMs); + }); + + test('a handoff rule never authorizes an action or a wait', () => { + const ceiling = nextRuleOnBlock( + newRule('https://x.com', T0), + { retryAfterMs: K.maxPaceMs, now: T0 }, + K, + ); + expect(needsHandOff(ceiling, K)).toBe(true); + expect(waitForAction(ceiling, undefined, T0, noJitter, K)).toBe(null); + expect(waitForAction(ceiling, T0, T0, noJitter, K)).toBe(null); + }); +}); + +describe('web-pace — probe is the reversible way down', () => { + test('a probe offers one action at a clamped trial interval', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + const p = startProbe(r, 500, 60_000, T0); + expect(p.probe).toEqual({ + trialMs: 500, + startedAt: T0, + until: T0 + 60_000, + prevMinIntervalMs: r.minIntervalMs, + actionStartedAt: null, + cleanObservedAt: null, + }); + expect(waitForAction(p, T0, T0, noJitter)).toBe(500); + expect(waitForAction(p, undefined, T0, noJitter)).toBe(null); + expect(probeAllowsAction(p, T0, K)).toBe(true); + expect(beginProbeAction(p, -1, T0 + 1, K)).toEqual(p); + const begun = beginProbeAction(p, T0, T0 + 1, K); + expect(begun.probe?.actionStartedAt).toBe(T0 + 1); + expect(probeAllowsAction(begun, T0 + 2, K)).toBe(false); + expect(waitForAction(begun, T0, T0 + 2, noJitter, K)).toBe(null); + expect(beginProbeAction(begun, T0, T0 + 2, K)).toEqual(begun); + // a "probe" that asked to go slower is not a probe — clamped to the current value + expect(startProbe(r, 99_999, 60_000, T0).probe?.trialMs).toBe(r.minIntervalMs); + expect(startProbe(p, 10, 60_000, T0)).toEqual(p); // already probing → window not restarted + expect(probeExpired(p, T0 + 59_999)).toBe(false); + expect(probeExpired(p, T0 + 60_000)).toBe(true); + }); + + test('a clean observed action adopts the lower value only after the window and settle time', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + const p = startProbe(r, 100, 60_000, T0); + const begun = beginProbeAction(p, T0, T0 + 1, K); + const observed = noteCleanProbeAction(begun, T0 + 1, T0 + 2, K); + expect(resolveProbe(observed, 'clean', T0 + 59_999, K)).toEqual(observed); + expect(resolveProbe(observed, 'clean', T0 + 60_000, K).probe).toBe(null); + const done = resolveProbe(observed, 'clean', T0 + 60_000, K); + expect(done.minIntervalMs).toBe(100); + expect(done.probe).toBe(null); + expect(isRetired(done, K)).toBe(true); // 100 < retireFloorMs → the site stopped caring + }); + + test('a probe cannot resolve clean without a reserved and observed action', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + const idle = startProbe(r, 100, 60_000, T0); + expect(resolveProbe(idle, 'clean', T0 + 60_000, K)).toEqual(idle); + expect(beginProbeAction(idle, T0, T0 - 1, K)).toEqual(idle); + expect(beginProbeAction(idle, T0, T0 + 60_000, K)).toEqual(idle); + const begun = beginProbeAction(idle, T0, T0 + 1, K); + expect(noteCleanProbeAction(begun, T0, T0 + 2, K)).toEqual(begun); + expect(resolveProbe(begun, 'clean', T0 + 60_000, K)).toEqual(begun); + }); + + test('a result observed after the window can settle, while a delayed block still wins', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + const begun = beginProbeAction(startProbe(r, 500, 60_000, T0), T0, T0 + 59_999, K); + const observedAt = T0 + 60_500; + const observed = noteCleanProbeAction(begun, T0 + 59_999, observedAt, K); + expect(resolveProbe(observed, 'clean', observedAt + K.probeSettleMs - 1, K)).toEqual(observed); + const blocked = resolveProbe(observed, 'blocked', observedAt + 1, K); + expect(blocked.probe).toBe(null); + expect(blocked.minIntervalMs).toBeGreaterThan(r.minIntervalMs); + }); + + test('probe inputs are finite, non-negative, and window-bounded', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + for (const bad of [NaN, Infinity, -Infinity, -1]) { + expect(startProbe(r, bad, 60_000, T0, K)).toEqual(r); + } + for (const bad of [NaN, Infinity, -Infinity, 0, -1]) { + expect(startProbe(r, 500, bad, T0, K)).toEqual(r); + } + expect(startProbe(r, 500, 1, T0, K).probe?.until).toBe(T0 + K.minProbeWindowMs); + expect(startProbe(r, 500, K.maxProbeWindowMs * 2, T0, K).probe?.until) + .toBe(T0 + K.maxProbeWindowMs); + expect(startProbe(r, 500, 60_000, NaN, K)).toEqual(r); + expect(startProbe(r, 500, 60_000, T0 - 1, K)).toEqual(r); + }); + + test('a blocked probe snaps back, re-escalates, and resets the quiet timer', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + const p = beginProbeAction(startProbe(r, 500, 60_000, T0), T0, T0 + 1, K); + const after = resolveProbe(p, 'blocked', T0 + 60_000, K); + expect(after.probe).toBe(null); + // it must NOT keep the trial value it just got blocked at… + expect(after.minIntervalMs).toBeGreaterThan(500); + // …and must not merely restore the old one either — the trial taught us more + expect(after.minIntervalMs).toBe(r.minIntervalMs * K.growth); + expect(after.lastBlockAt).toBe(T0 + 60_000); // quiet timer reset → decay can't undo the lesson + expect(after.observations).toBe(r.observations + 1); + }); + + test('an inconclusive or zero-interval probe cannot leave a dead rule behind', () => { + const r = nextRuleOnBlock(newRule('https://x.com', T0), { recentIntervalMs: 4_000, now: T0 }, K); + const idle = startProbe(r, 0, 60_000, T0); + expect(resolveProbe(idle, 'inconclusive', T0 + 1, K)).toEqual({ + ...r, + updatedAt: T0 + 1, + }); + const begun = beginProbeAction(idle, T0, T0 + 1, K); + const observed = noteCleanProbeAction(begun, T0 + 1, T0 + 2, K); + const done = resolveProbe(observed, 'clean', T0 + 60_000, K); + expect(done.minIntervalMs).toBe(0); + expect(isRetired(done, K)).toBe(true); + }); +}); + +describe('web-pace — the export surface has no model-controlled rule mutation', () => { + test('descent remains decay or the one-action probe state machine', async () => { + const mod = await import('../../extension/peerd-runtime/web-pace/reducer.js'); + expect(Object.keys(mod).sort()).toEqual([ + 'PACE_RULE_VERSION', 'PACE_TUNABLES', 'beginProbeAction', 'decay', + 'isRetired', 'isValidRule', 'needsHandOff', 'newRule', + 'nextRuleOnBlock', 'noteCleanProbeAction', 'probeAllowsAction', + 'probeExpired', 'resolveProbe', 'startProbe', 'waitForAction', + ].sort()); + }); +});