proactive-gate 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +83 -14
  2. package/README.tr.md +55 -3
  3. package/dist/src/checks.js +3 -1
  4. package/dist/src/cli.d.ts +2 -0
  5. package/dist/src/cli.js +92 -1
  6. package/dist/src/demo-week.d.ts +38 -0
  7. package/dist/src/demo-week.js +115 -0
  8. package/dist/src/explain.js +8 -2
  9. package/dist/src/presets.js +16 -2
  10. package/dist/src/simulate-report.d.ts +27 -0
  11. package/dist/src/simulate-report.js +128 -0
  12. package/dist/src/simulate.d.ts +118 -0
  13. package/dist/src/simulate.js +269 -0
  14. package/package.json +4 -3
  15. package/spec/CONFORMANCE.md +49 -3
  16. package/spec/SPEC.md +3 -0
  17. package/spec/SPEC_VERSION +1 -1
  18. package/spec/fixtures/adaptive-timing/placeholder.json +1 -1
  19. package/spec/fixtures/budget/bypass-priority.json +1 -1
  20. package/spec/fixtures/budget/daily-atomic-commit.json +1 -1
  21. package/spec/fixtures/budget/near-limit.json +1 -1
  22. package/spec/fixtures/budget/race-second-commit-loses.json +1 -1
  23. package/spec/fixtures/budget/weekly-iso-week.json +1 -1
  24. package/spec/fixtures/consent/required.json +1 -1
  25. package/spec/fixtures/cooldown/three-dismissals.json +1 -1
  26. package/spec/fixtures/dedupe/already-delivered.json +1 -1
  27. package/spec/fixtures/dedupe/no-key-skips.json +1 -1
  28. package/spec/fixtures/defer/snooze-as-defer.json +1 -1
  29. package/spec/fixtures/mode/allow-list.json +1 -1
  30. package/spec/fixtures/ordering/kill-switch.json +1 -1
  31. package/spec/fixtures/ordering/short-circuit.json +1 -1
  32. package/spec/fixtures/policy/unknown-check-is-an-error.json +1 -1
  33. package/spec/fixtures/presets/cn-minor-mode.json +1 -1
  34. package/spec/fixtures/presets/in-tcccp.json +258 -0
  35. package/spec/fixtures/presets/kakao-brand-message.json +1 -1
  36. package/spec/fixtures/presets/kr-network-act-50.json +1 -1
  37. package/spec/fixtures/presets/telegram-bot.json +1 -1
  38. package/spec/fixtures/presets/us-tcpa.json +1 -1
  39. package/spec/fixtures/quiet-hours/apia.json +1 -1
  40. package/spec/fixtures/quiet-hours/caller-supplied-dates.json +1 -1
  41. package/spec/fixtures/quiet-hours/crosses-midnight-by-day.json +1 -1
  42. package/spec/fixtures/quiet-hours/dst-new-york.json +1 -1
  43. package/spec/fixtures/quiet-hours/istanbul.json +1 -1
  44. package/spec/fixtures/quiet-hours/wall-clock.json +1 -1
  45. package/spec/fixtures/quiet-hours/weekday-schedule.json +1 -1
  46. package/spec/fixtures/shadow/reject-continues.json +1 -1
  47. package/spec/fixtures/trust-ramp/first-week.json +1 -1
  48. package/spec/fixtures/utility/bounded-deferral-cap.json +1 -1
  49. package/spec/fixtures/utility/bounded-deferral.json +1 -1
  50. package/spec/fixtures/utility/floor.json +1 -1
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The simulation as a table. Rendering only: every number here was computed in simulate.ts,
3
+ * and nothing in this file recounts anything, so the terminal output and `--json` cannot
4
+ * disagree with each other.
5
+ */
6
+ import { DEMO_WEEK_NOTE } from "./demo-week.js";
7
+ import type { SimResult } from "./simulate.js";
8
+ export interface ReportOptions {
9
+ /** How many timeline rows to print. 0 prints all of them. */
10
+ limit?: number;
11
+ /** Print only the candidates the policies disagreed about. */
12
+ disagreementsOnly?: boolean;
13
+ /** Print each held candidate's sentence under its row. */
14
+ why?: boolean;
15
+ /** Which run the sentences and the stopped-by table describe; the last policy by default. */
16
+ reasonsFor?: number;
17
+ night?: {
18
+ start: number;
19
+ end: number;
20
+ };
21
+ /** Appended under the tables, for a generated stream that has to say so. */
22
+ note?: string;
23
+ /** One line per user, for a generated stream whose people were chosen for a reason. */
24
+ people?: string[];
25
+ }
26
+ export declare function formatSimulation(result: SimResult, options?: ReportOptions): string;
27
+ export { DEMO_WEEK_NOTE };
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The simulation as a table. Rendering only: every number here was computed in simulate.ts,
3
+ * and nothing in this file recounts anything, so the terminal output and `--json` cannot
4
+ * disagree with each other.
5
+ */
6
+ import { DEMO_WEEK_NOTE } from "./demo-week.js";
7
+ const VERDICT = {
8
+ sent: "sent",
9
+ held: "held",
10
+ deferred: "deferred",
11
+ expired: "expired",
12
+ lostAtCommit: "lost at commit",
13
+ spentNotDelivered: "spent, not delivered",
14
+ };
15
+ /** Outcome plus the check that produced it, which is the whole verdict in one cell. */
16
+ const verdictOf = (cell) => {
17
+ if (!cell)
18
+ return "";
19
+ const word = VERDICT[cell.outcome] ?? cell.outcome;
20
+ return cell.check && cell.outcome !== "sent" ? `${word} ${cell.check}` : word;
21
+ };
22
+ const pad = (s, w) => (s.length > w ? `${s.slice(0, Math.max(0, w - 1))}…` : s.padEnd(w));
23
+ /** The widest cell in a column, so nothing is truncated that would have fitted. */
24
+ const widthOf = (values, min) => Math.max(min, ...values.map((v) => v.length));
25
+ function timelineTable(result, rows, why) {
26
+ const labels = result.runs.map((r) => r.label);
27
+ const cols = labels.map((label, i) => ({
28
+ label,
29
+ width: widthOf([label, ...rows.map((r) => verdictOf(r.cells[i]))], 8),
30
+ }));
31
+ const userW = widthOf(["user", ...rows.map((r) => r.userId)], 4);
32
+ const typeW = widthOf(["type", ...rows.map((r) => r.type)], 4);
33
+ const priW = widthOf(["pri", ...rows.map((r) => r.priority)], 3);
34
+ const head = `${pad("when (local)", 13)} ${pad("user", userW)} ${pad("type", typeW)} ${pad("pri", priW)} ${cols.map((c) => pad(c.label, c.width)).join(" ")}`;
35
+ const out = [head, "-".repeat(head.length)];
36
+ for (const row of rows) {
37
+ const cells = cols.map((c, i) => pad(verdictOf(row.cells[i]), c.width)).join(" ");
38
+ out.push(`${pad(row.localTime, 13)} ${pad(row.userId, userW)} ${pad(row.type, typeW)} ${pad(row.priority, priW)} ${cells}`.trimEnd());
39
+ // --why prints the sentence explain() already produces, under the row it belongs to.
40
+ const cell = why === null ? undefined : row.cells[why];
41
+ if (cell && cell.outcome !== "sent" && cell.sentence)
42
+ out.push(` ${cell.sentence}`);
43
+ }
44
+ return out;
45
+ }
46
+ function summaryTable(runs, night) {
47
+ const labels = runs.map((r) => r.label);
48
+ const nightLabel = `delivered ${String(night.start).padStart(2, "0")}:00-${String(night.end).padStart(2, "0")}:00 local`;
49
+ const quietLabel = "delivered inside their own quiet hours";
50
+ const rows = [
51
+ ["delivered", runs.map((r) => String(r.counts.sent))],
52
+ ["held", runs.map((r) => String(r.counts.held))],
53
+ ["deferred, then delivered", runs.map((r) => String(r.counts.sentAfterDeferral))],
54
+ ["moved to a later moment", runs.map((r) => String(r.counts.sentAtALaterMoment))],
55
+ ["deferred, then expired", runs.map((r) => String(r.counts.expired))],
56
+ ["lost at commit", runs.map((r) => String(r.counts.lostAtCommit))],
57
+ ["spent, not delivered", runs.map((r) => String(r.counts.spentNotDelivered))],
58
+ [quietLabel, runs.map((r) => String(r.counts.sentInQuietHours))],
59
+ [" of those, critical (what the floor lets through)", runs.map((r) => String(r.counts.sentInQuietHoursByFloor))],
60
+ [`${nightLabel} (a fixed window)`, runs.map((r) => String(r.counts.sentAtNight))],
61
+ ["most one person got in a day", runs.map((r) => String(r.counts.busiestUserDay))],
62
+ ];
63
+ const labelW = widthOf(["", ...rows.map((r) => r[0])], 12);
64
+ const colW = labels.map((l, i) => widthOf([l, ...rows.map((r) => r[1][i] ?? "")], 6));
65
+ const out = [
66
+ `${pad("", labelW)} ${labels.map((l, i) => pad(l, colW[i] ?? 6)).join(" ")}`,
67
+ "-".repeat(labelW + 2 + colW.reduce((n, w) => n + w + 2, 0)),
68
+ ];
69
+ for (const [label, values] of rows) {
70
+ if (values.every((v) => v === "0"))
71
+ continue;
72
+ out.push(`${pad(label, labelW)} ${values.map((v, i) => pad(v, colW[i] ?? 6)).join(" ")}`.trimEnd());
73
+ }
74
+ return out;
75
+ }
76
+ function stoppedTable(run) {
77
+ if (!run.counts.stoppedBy.length)
78
+ return [];
79
+ const checkW = widthOf(["check", ...run.counts.stoppedBy.map((s) => s.check)], 5);
80
+ const out = [
81
+ `${pad("check", checkW)} ${pad("stopped", 7)} example`,
82
+ "-".repeat(checkW + 2 + 7 + 2 + 40),
83
+ ];
84
+ for (const s of run.counts.stoppedBy)
85
+ out.push(`${pad(s.check, checkW)} ${String(s.count).padStart(7)} ${s.example}`);
86
+ return out;
87
+ }
88
+ export function formatSimulation(result, options = {}) {
89
+ const limit = options.limit ?? 20;
90
+ const night = options.night ?? { start: 22, end: 8 };
91
+ const users = new Set(result.timeline.map((r) => r.userId)).size;
92
+ const source = options.disagreementsOnly ? result.disagreements : result.timeline;
93
+ const rows = limit > 0 ? source.slice(0, limit) : source;
94
+ const reasonsFor = options.reasonsFor ?? result.runs.length - 1;
95
+ const out = [
96
+ `proactive-gate simulate · seed ${result.seed} · ${result.events} candidates, ${users} users`,
97
+ `policies: ${result.runs.map((r) => r.label).join(" | ")}`,
98
+ "rows are in the order a server produced them; the time is the recipient's own clock",
99
+ "",
100
+ ...timelineTable(result, rows, options.why ? reasonsFor : null),
101
+ ];
102
+ if (rows.length < source.length) {
103
+ out.push(`${source.length - rows.length} more ${options.disagreementsOnly ? "disagreements" : "candidates"} not shown; --limit 0 prints every row, --json prints everything`);
104
+ }
105
+ out.push("", `the policies disagreed about ${result.disagreements.length} of ${result.events} candidates`, "");
106
+ out.push(...summaryTable(result.runs, night));
107
+ const subject = result.runs[reasonsFor];
108
+ if (subject && subject.counts.stoppedBy.length) {
109
+ out.push("", `why ${subject.label} did not send`, "");
110
+ out.push(...stoppedTable(subject));
111
+ }
112
+ const budget = subject?.budget ?? [];
113
+ if (budget.length) {
114
+ const busiest = [...budget].sort((a, b) => b.used - a.used || a.userId.localeCompare(b.userId)).slice(0, 5);
115
+ out.push("", `budget spent, read back out of the store (top ${busiest.length} of ${budget.length} user-days)`, "");
116
+ for (const row of busiest)
117
+ out.push(` ${pad(row.userId, 10)} ${row.localDay} ${row.used}`);
118
+ }
119
+ if (options.people?.length) {
120
+ out.push("", "who is in the week, and why each one is here", "");
121
+ for (const line of options.people)
122
+ out.push(` ${line}`);
123
+ }
124
+ if (options.note)
125
+ out.push("", options.note);
126
+ return out.join("\n");
127
+ }
128
+ export { DEMO_WEEK_NOTE };
@@ -0,0 +1,118 @@
1
+ import type { Check, EvaluateInput, Policy } from "./types.js";
2
+ /** What became of one candidate under one policy. */
3
+ export type SimOutcome = "sent" | "held" | "deferred" | "expired" | "lostAtCommit" | "spentNotDelivered";
4
+ export interface SimRecord {
5
+ candidateId: string;
6
+ userId: string;
7
+ /** The instant this attempt was evaluated at, which for a retry is the deferral's own time. */
8
+ at: string;
9
+ /** 1 for the first evaluation, 2 and up for re-evaluations after a deferral. */
10
+ attempt: number;
11
+ outcome: SimOutcome;
12
+ rejectedBy?: string;
13
+ deferredBy?: string;
14
+ retryAt?: string;
15
+ /** Set when a non-rejecting check asked for the send to happen at a later moment. */
16
+ deliverAt?: string;
17
+ /** The check's own reason, as the library words it. */
18
+ reason?: string;
19
+ /** The whole decision as one sentence, from `explain()`. */
20
+ sentence?: string;
21
+ }
22
+ export interface SimCounts {
23
+ candidates: number;
24
+ /**
25
+ * Every count below is over final outcomes, one per candidate: a candidate deferred and then
26
+ * sent is one send, not a deferral and a send. `deferredAtLeastOnce` and `sentAfterDeferral`
27
+ * are how the holding shows up, and they are the argument that a deferral is not a drop.
28
+ */
29
+ sent: number;
30
+ held: number;
31
+ expired: number;
32
+ lostAtCommit: number;
33
+ spentNotDelivered: number;
34
+ deferredAtLeastOnce: number;
35
+ sentAfterDeferral: number;
36
+ /** Sends a non-rejecting check moved to a later moment, which is neither a hold nor a drop. */
37
+ sentAtALaterMoment: number;
38
+ /**
39
+ * Sends that landed inside the recipient's **own** quiet hours. This is the number that says
40
+ * whether a stated preference was honoured; a fixed curfew would punish `hana`, who asked for
41
+ * no quiet hours at all, and would miss a user whose window is 18:00 to 09:00.
42
+ */
43
+ sentInQuietHours: number;
44
+ /** Of those, the ones the documented priority floor lets through on purpose. */
45
+ sentInQuietHoursByFloor: number;
46
+ /** Sends inside a fixed 22:00 to 08:00 local window, which is nobody's preference. */
47
+ sentAtNight: number;
48
+ /** The most sends one person received in one of their own local days. */
49
+ busiestUserDay: number;
50
+ /** How many candidates each check stopped, deferrals included, highest first. */
51
+ stoppedBy: Array<{
52
+ check: string;
53
+ count: number;
54
+ example: string;
55
+ }>;
56
+ }
57
+ export interface SimRun {
58
+ label: string;
59
+ records: SimRecord[];
60
+ counts: SimCounts;
61
+ /** Budget units spent, read back out of the store rather than counted in a local variable. */
62
+ budget: Array<{
63
+ userId: string;
64
+ localDay: string;
65
+ used: number;
66
+ }>;
67
+ }
68
+ export interface SimTimelineRow {
69
+ candidateId: string;
70
+ userId: string;
71
+ at: string;
72
+ /** Month-day and time in the recipient's own zone, which is the clock that judges a message. */
73
+ localTime: string;
74
+ type: string;
75
+ priority: string;
76
+ /** One cell per policy, in the order the policies were given. */
77
+ cells: Array<{
78
+ outcome: SimOutcome;
79
+ check?: string;
80
+ sentence?: string;
81
+ retryAt?: string;
82
+ }>;
83
+ }
84
+ export interface SimResult {
85
+ seed: number;
86
+ events: number;
87
+ runs: SimRun[];
88
+ timeline: SimTimelineRow[];
89
+ /** The candidates the policies disagreed about: the whole argument, in one list. */
90
+ disagreements: SimTimelineRow[];
91
+ }
92
+ /** A policy to run. Leave both `policy` and `checks` out for the no-gate baseline. */
93
+ export interface SimPolicy {
94
+ label: string;
95
+ policy?: Policy;
96
+ checks?: Check[];
97
+ }
98
+ export interface SimOptions {
99
+ events: EvaluateInput[];
100
+ policies: SimPolicy[];
101
+ /** Seeds the simulated transport, and nothing else. */
102
+ seed?: number;
103
+ /** Fraction of simulated sends the fake transport fails. 0 keeps the run about the policy. */
104
+ transportFailureRate?: number;
105
+ /** How long a deferred candidate stays worth sending before this scheduler drops it. */
106
+ expirySeconds?: number;
107
+ /** The local window counted as night in `sentAtNight`. */
108
+ night?: {
109
+ start: number;
110
+ end: number;
111
+ };
112
+ }
113
+ export declare const DEFAULT_NIGHT: {
114
+ start: number;
115
+ end: number;
116
+ };
117
+ export declare const DEFAULT_EXPIRY_SECONDS: number;
118
+ export declare function simulate(options: SimOptions): Promise<SimResult>;
@@ -0,0 +1,269 @@
1
+ /**
2
+ * One stream of candidates, two or more policies, every figure recomputed from the run.
3
+ *
4
+ * This adds no check and no store and does not touch the decision path: it runs the gate that
5
+ * already exists once per policy over the same events and records what happened to each
6
+ * candidate. The point is the difference between the columns, which is the only honest way this
7
+ * repository can answer "what would installing this change for me" without asking anyone to
8
+ * take a number on trust.
9
+ *
10
+ * Three distinctions the output keeps apart, because collapsing them is how a simulation lies:
11
+ *
12
+ * - **allowed is not sent.** `evaluate` says a message may go, `commit` takes the budget unit
13
+ * and can still refuse when a concurrent delivery took the last one, and only then does a
14
+ * transport run. `lostAtCommit` and `spentNotDelivered` are counted apart from `sent`.
15
+ * - **deferred is not rejected.** A deferral carries `retryAt`, and the candidate is
16
+ * re-evaluated from scratch at that instant against the same store, so consent, the clock and
17
+ * the budget are read again. An old allowed decision is never a standing permission to send.
18
+ * A deferral nobody could still act on is counted as `expired` rather than quietly dropped.
19
+ * - **no gate is not a policy.** The baseline bypasses the gate entirely: every candidate is
20
+ * sent. A policy with no checks is not expressible, and a rival built to lose would measure
21
+ * nothing. The baseline is what a product does before this library is installed.
22
+ *
23
+ * Nothing here reads a wall clock. Every event carries its own `now`, the deferral scheduler
24
+ * runs on those same instants, and the simulated transport draws from a seeded generator, so a
25
+ * replay gives the same result on any machine on any day.
26
+ */
27
+ import { createGate } from "./gate.js";
28
+ import { budgetKey, localClock, quietAt } from "./checks.js";
29
+ import { explain } from "./explain.js";
30
+ import { MemoryStore } from "./stores.js";
31
+ export const DEFAULT_NIGHT = { start: 22, end: 8 };
32
+ export const DEFAULT_EXPIRY_SECONDS = 4 * 60 * 60;
33
+ /** Small deterministic PRNG, so a seed is the whole definition of a run. */
34
+ function mulberry32(seed) {
35
+ let a = seed >>> 0;
36
+ return () => {
37
+ a = (a + 0x6d2b79f5) >>> 0;
38
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
39
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
40
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
41
+ };
42
+ }
43
+ const iso = (d) => d.toISOString();
44
+ /** The recipient's own clock, which is the only one a notification is judged by. */
45
+ function localOf(timezone, at) {
46
+ const clock = localClock(at, timezone ?? "UTC");
47
+ const hour = Math.floor(clock.minutes / 60);
48
+ const minute = clock.minutes % 60;
49
+ return { hour, minute, day: clock.day, text: `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}` };
50
+ }
51
+ const inNight = (hour, night) => night.start <= night.end ? hour >= night.start && hour < night.end : hour >= night.start || hour < night.end;
52
+ function countsOf(records, events, night) {
53
+ const zones = new Map(events.map((e) => [e.user.id, e.user.timezone]));
54
+ const quiet = new Map(events.map((e) => [e.user.id, e.user.quietHours]));
55
+ const floor = new Map(events.map((e) => [e.candidate.id, e.candidate.priority ?? "normal"]));
56
+ const perUserDay = new Map();
57
+ const stopped = new Map();
58
+ // One row per candidate, the last attempt, because that is what became of it. Counting every
59
+ // attempt would report a candidate that was held until morning as both a deferral and a send.
60
+ const finals = new Map();
61
+ for (const r of records)
62
+ finals.set(r.candidateId, r);
63
+ const deferredIds = new Set(records.filter((r) => r.outcome === "deferred").map((r) => r.candidateId));
64
+ let sentAtNight = 0;
65
+ let sentInQuietHours = 0;
66
+ let sentInQuietHoursByFloor = 0;
67
+ for (const r of finals.values()) {
68
+ if (r.outcome === "sent") {
69
+ const at = new Date(r.at);
70
+ const local = localOf(zones.get(r.userId), at);
71
+ if (inNight(local.hour, night))
72
+ sentAtNight += 1;
73
+ const window = quiet.get(r.userId);
74
+ if (window && quietAt(window, local.day, local.hour * 60 + local.minute)) {
75
+ sentInQuietHours += 1;
76
+ if (floor.get(r.candidateId) === "critical")
77
+ sentInQuietHoursByFloor += 1;
78
+ }
79
+ const key = `${r.userId}:${local.day}`;
80
+ perUserDay.set(key, (perUserDay.get(key) ?? 0) + 1);
81
+ continue;
82
+ }
83
+ const by = r.rejectedBy ?? r.deferredBy;
84
+ if (!by)
85
+ continue;
86
+ const seen = stopped.get(by);
87
+ if (seen)
88
+ seen.count += 1;
89
+ else
90
+ stopped.set(by, { count: 1, example: r.reason ?? r.sentence ?? "" });
91
+ }
92
+ const finalRows = [...finals.values()];
93
+ const count = (outcome) => finalRows.filter((r) => r.outcome === outcome).length;
94
+ return {
95
+ candidates: finals.size,
96
+ sent: count("sent"),
97
+ held: count("held"),
98
+ expired: count("expired"),
99
+ lostAtCommit: count("lostAtCommit"),
100
+ spentNotDelivered: count("spentNotDelivered"),
101
+ deferredAtLeastOnce: deferredIds.size,
102
+ sentAfterDeferral: finalRows.filter((r) => r.outcome === "sent" && r.attempt > 1).length,
103
+ sentAtALaterMoment: finalRows.filter((r) => r.outcome === "sent" && r.deliverAt).length,
104
+ sentInQuietHours,
105
+ sentInQuietHoursByFloor,
106
+ sentAtNight,
107
+ busiestUserDay: perUserDay.size ? Math.max(...perUserDay.values()) : 0,
108
+ stoppedBy: [...stopped.entries()]
109
+ .map(([check, v]) => ({ check, count: v.count, example: v.example }))
110
+ .sort((a, b) => b.count - a.count || a.check.localeCompare(b.check)),
111
+ };
112
+ }
113
+ /**
114
+ * One stream through one gate, with a scheduler for deferrals that belongs to this simulation
115
+ * rather than to the library: the library never runs a queue. The queue is drained per user just
116
+ * before that user's next event and again at the end, because budgets are keyed per user and
117
+ * local day, so each user's own order is what keeps their counter honest.
118
+ */
119
+ async function runGate(label, gate, store, events, send, expirySeconds, night) {
120
+ const records = [];
121
+ const pending = [];
122
+ const evaluateOnce = async (user, candidate, now, attempt) => {
123
+ const input = { user, candidate, now };
124
+ const decision = await gate.evaluate(input);
125
+ const record = {
126
+ candidateId: candidate.id,
127
+ userId: user.id,
128
+ at: iso(now),
129
+ attempt,
130
+ outcome: "held",
131
+ ...(decision.rejectedBy ? { rejectedBy: decision.rejectedBy } : {}),
132
+ ...(decision.deferredBy ? { deferredBy: decision.deferredBy } : {}),
133
+ ...(decision.retryAt ? { retryAt: iso(decision.retryAt) } : {}),
134
+ ...(decision.deliverAt ? { deliverAt: iso(decision.deliverAt) } : {}),
135
+ ...(decision.reason ? { reason: decision.reason } : {}),
136
+ sentence: explain(decision).summary,
137
+ };
138
+ if (decision.deferredBy) {
139
+ const due = decision.retryAt ?? now;
140
+ const stillWorthIt = due.getTime() <= now.getTime() + expirySeconds * 1000;
141
+ record.outcome = stillWorthIt ? "deferred" : "expired";
142
+ if (stillWorthIt)
143
+ pending.push({ user, candidate, due, attempt: attempt + 1 });
144
+ records.push(record);
145
+ return;
146
+ }
147
+ if (!decision.allowed) {
148
+ records.push(record);
149
+ return;
150
+ }
151
+ if (!(await gate.commit(decision, input))) {
152
+ record.outcome = "lostAtCommit";
153
+ record.rejectedBy = "commit";
154
+ record.reason = "a budget was exhausted at commit";
155
+ records.push(record);
156
+ return;
157
+ }
158
+ record.outcome = send() ? "sent" : "spentNotDelivered";
159
+ records.push(record);
160
+ };
161
+ /** Re-evaluate every deferral for this user due by `upTo`; both null drains what is left. */
162
+ const drain = async (userId, upTo) => {
163
+ for (;;) {
164
+ const index = pending.findIndex((p) => (userId === null || p.user.id === userId) && (upTo === null || p.due.getTime() <= upTo.getTime()));
165
+ if (index < 0)
166
+ return;
167
+ const [item] = pending.splice(index, 1);
168
+ if (!item)
169
+ return;
170
+ await evaluateOnce(item.user, item.candidate, item.due, item.attempt);
171
+ }
172
+ };
173
+ for (const event of events) {
174
+ const now = event.now ?? new Date(0);
175
+ await drain(event.user.id, now);
176
+ await evaluateOnce(event.user, event.candidate, now, 1);
177
+ }
178
+ await drain(null, null);
179
+ // Budget rows come out of the store, one per user and per local day, because that is how the
180
+ // key is shaped (spec/SPEC.md 5.1). One number per user would report a counter that has just
181
+ // rolled over and hide what the day before spent.
182
+ const zones = new Map(events.map((e) => [e.user.id, e.user.timezone]));
183
+ const wanted = new Map();
184
+ for (const r of records) {
185
+ const key = budgetKey(r.userId, new Date(r.at), zones.get(r.userId));
186
+ if (!wanted.has(key))
187
+ wanted.set(key, { userId: r.userId, localDay: key.slice(key.lastIndexOf(":") + 1), key });
188
+ }
189
+ const budget = [];
190
+ for (const row of [...wanted.values()].sort((a, b) => a.userId.localeCompare(b.userId) || a.localDay.localeCompare(b.localDay))) {
191
+ const used = Number((await store.get(`pg:${row.key}`)) ?? 0);
192
+ if (used > 0)
193
+ budget.push({ userId: row.userId, localDay: row.localDay, used });
194
+ }
195
+ return { label, records, counts: countsOf(records, events, night), budget };
196
+ }
197
+ /** Every candidate is sent. What a product does before this library is installed. */
198
+ function runBaseline(label, events, send, night) {
199
+ const records = events.map((e) => ({
200
+ candidateId: e.candidate.id,
201
+ userId: e.user.id,
202
+ at: iso(e.now ?? new Date(0)),
203
+ attempt: 1,
204
+ outcome: send() ? "sent" : "spentNotDelivered",
205
+ }));
206
+ return { label, records, counts: countsOf(records, events, night), budget: [] };
207
+ }
208
+ /** The last attempt is what became of a candidate; earlier attempts are how it got there. */
209
+ function finalOf(run, candidateId) {
210
+ const attempts = run.records.filter((r) => r.candidateId === candidateId);
211
+ return attempts.length ? attempts[attempts.length - 1] : undefined;
212
+ }
213
+ export async function simulate(options) {
214
+ const seed = options.seed ?? 1;
215
+ const failureRate = options.transportFailureRate ?? 0;
216
+ const expirySeconds = options.expirySeconds ?? DEFAULT_EXPIRY_SECONDS;
217
+ const night = options.night ?? DEFAULT_NIGHT;
218
+ const { events, policies } = options;
219
+ if (!events.length)
220
+ throw new Error("simulate: no events to run");
221
+ if (!policies.length)
222
+ throw new Error("simulate: no policies to run");
223
+ const runs = [];
224
+ for (const entry of policies) {
225
+ // A transport per run, seeded identically, so one run's failures cannot move another's.
226
+ const random = mulberry32(seed);
227
+ const send = () => random() >= failureRate;
228
+ if (!entry.policy && !entry.checks) {
229
+ runs.push(runBaseline(entry.label, events, send, night));
230
+ continue;
231
+ }
232
+ const store = new MemoryStore();
233
+ const gate = entry.policy
234
+ ? createGate({ policy: entry.policy, store })
235
+ : createGate({ checks: entry.checks ?? [], store });
236
+ runs.push(await runGate(entry.label, gate, store, events, send, expirySeconds, night));
237
+ }
238
+ const timeline = events.map((event) => {
239
+ const at = event.now ?? new Date(0);
240
+ const local = localOf(event.user.timezone, at);
241
+ return {
242
+ candidateId: event.candidate.id,
243
+ userId: event.user.id,
244
+ at: iso(at),
245
+ localTime: `${local.day.slice(5)} ${local.text}`,
246
+ type: event.candidate.type,
247
+ priority: event.candidate.priority ?? "normal",
248
+ cells: runs.map((run) => {
249
+ const record = finalOf(run, event.candidate.id);
250
+ if (!record)
251
+ return { outcome: "held" };
252
+ const check = record.rejectedBy ?? record.deferredBy;
253
+ return {
254
+ outcome: record.outcome,
255
+ ...(check ? { check } : {}),
256
+ ...(record.sentence ? { sentence: record.sentence } : {}),
257
+ ...(record.retryAt ? { retryAt: record.retryAt } : {}),
258
+ };
259
+ }),
260
+ };
261
+ });
262
+ return {
263
+ seed,
264
+ events: events.length,
265
+ runs,
266
+ timeline,
267
+ disagreements: timeline.filter((row) => new Set(row.cells.map((c) => c.outcome)).size > 1),
268
+ };
269
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proactive-gate",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Decide whether a proactive AI agent may reach a user right now, and log why not. Ordered checks as code or JSON, a conformance spec, presets for platform and legal limits, adapters for AI SDK, Mastra, LangChain and OpenAI Agents, and a Python sibling.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -48,7 +48,7 @@
48
48
  "sideEffects": false,
49
49
  "scripts": {
50
50
  "build": "tsc -p tsconfig.json",
51
- "test": "npm run build && node test/spec-lint.mjs && node --test dist/test/gate.test.js dist/test/conformance.test.js dist/test/presets.test.js dist/test/adapters.test.js dist/test/init.test.js dist/test/properties.test.js dist/test/dedupe.test.js dist/test/explain.test.js test/release.test.mjs test/examples.test.mjs test/example-imports.test.mjs test/naive.test.mjs test/suite.test.mjs",
51
+ "test": "npm run build && node test/spec-lint.mjs && node --test dist/test/gate.test.js dist/test/conformance.test.js dist/test/presets.test.js dist/test/adapters.test.js dist/test/init.test.js dist/test/properties.test.js dist/test/dedupe.test.js dist/test/simulate.test.js dist/test/monotonicity.test.js dist/test/explain.test.js test/release.test.mjs test/examples.test.mjs test/example-imports.test.mjs test/naive.test.mjs test/playground-render.test.mjs test/playground-scenarios.test.mjs test/snippet.test.mjs test/suite.test.mjs",
52
52
  "lint": "tsc -p tsconfig.json --noEmit",
53
53
  "spec-lint": "node test/spec-lint.mjs",
54
54
  "conformance": "npm run build && node dist/src/cli.js replay --fixtures spec/fixtures",
@@ -61,7 +61,8 @@
61
61
  "bench:compare": "npm run build && node bench/compare.mjs",
62
62
  "conformance-table": "node scripts/conformance-table.mjs",
63
63
  "explain-parity": "node scripts/explain-parity.mjs",
64
- "og": "npm run build && node scripts/og-image.mjs"
64
+ "og": "npm run build && node scripts/og-image.mjs",
65
+ "simulate": "npm run build && node dist/src/cli.js simulate"
65
66
  },
66
67
  "engines": {
67
68
  "node": ">=20"
@@ -11,9 +11,9 @@ The suite is versioned by `SPEC_VERSION`, and each version is tagged `spec/vX.Y.
11
11
  separate from the package's own `vX.Y.Z` release tags.
12
12
 
13
13
  ```sh
14
- git clone --depth 1 --branch spec/v1.2.0 https://github.com/Bubblegunn/proactive-gate
14
+ git clone --depth 1 --branch spec/v1.3.0 https://github.com/Bubblegunn/proactive-gate
15
15
  # or, to keep it beside your own source and update it deliberately
16
- git subtree add --prefix spec https://github.com/Bubblegunn/proactive-gate spec/v1.2.0 --squash
16
+ git subtree add --prefix spec https://github.com/Bubblegunn/proactive-gate spec/v1.3.0 --squash
17
17
  ```
18
18
 
19
19
  A JavaScript implementation can also read the fixtures from an install, because the npm package
@@ -72,7 +72,9 @@ A fixture is a contract for every implementation, not only this one. So a change
72
72
  `test/spec-lint.mjs` checks.
73
73
 
74
74
  Versioning follows `SPEC.md`: a patch adds fixtures existing implementations already pass, a minor
75
- adds a check or a field, a major changes an expectation.
75
+ adds a check or a field, a major changes an expectation. A new preset is a minor for the same
76
+ reason a new check is: 7.3 makes a policy that names an unknown preset a compile error, so nobody
77
+ already passes the fixture that exercises it.
76
78
 
77
79
  ## The honest status of this suite
78
80
 
@@ -81,3 +83,47 @@ weaker evidence than it looks: agreement between two implementations by one auth
81
83
  consistency check than to independent verification. A third implementation, written from `SPEC.md`
82
84
  by someone who has not read the source, is what would test whether this document is enough. Until
83
85
  that exists, treat the suite as a contract that has been used twice, not as a proven standard.
86
+
87
+ ## Implementing this in your language
88
+
89
+ This is the one thing the project is actively asking for, and it is deliberately the last section
90
+ of this document: everything above is what you need, and nothing below it is required of you.
91
+
92
+ **The shape of the work.** A conforming implementation is a decision loop over an ordered list of
93
+ checks and a key-value store with a counter. There is no network, no dependency, no framework and
94
+ no content inspection anywhere in it. The TypeScript version is about 1,400 lines of source and the
95
+ Python sibling about the same, and most of that is the twelve checks, each of which is a small pure
96
+ function over a user, a candidate and an instant.
97
+
98
+ **The order to do it in**, which is how both existing versions were built:
99
+
100
+ 1. Vendor the suite at a tag rather than a branch: `git clone --depth 1 --branch spec/vX.Y.Z` or a
101
+ subtree, and assert in your tests that the `SPEC_VERSION` you vendored is the one you claim.
102
+ 2. Write the fixture loader first. Each file under `fixtures/` is a policy plus a list of cases,
103
+ each case an input and an expectation, and the loader is the only part that touches JSON.
104
+ 3. Make one fixture pass, in this order: `consent`, then `quiet-hours/istanbul`, then
105
+ `budget/daily-atomic-commit`. Those three exercise the whole spine: ordering, a wall clock in a
106
+ named zone, and a counter that has to be atomic at commit.
107
+ 4. Then the rest, in whatever order the failures suggest. `spec/SPEC.md` is numbered, and every
108
+ fixture names the requirement it came from.
109
+
110
+ **What passing means** is defined above and is not negotiable in one direction: a fixture you do
111
+ not pass is declared in your skip file with a reason, in the open. Silence about a failing fixture
112
+ is the one thing that makes a conformance claim worthless.
113
+
114
+ **What you may leave out.** Presets, adapters, the JSON policy compiler, `explain()`, the CLI and
115
+ the simulator are all optional; the fixtures that exercise them are the ones to declare as skipped.
116
+ The twelve checks, the ordering rules, the store contract and the commit-time budget are not
117
+ optional, because they are what the word conform refers to.
118
+
119
+ **What we will do with it.** If you say it conforms and the fixtures agree, it goes in the README
120
+ next to the other two implementations, under your name, with the version of the spec it targets.
121
+ If it does not conform yet, say which fixtures and it goes in as a work in progress, because an
122
+ honest partial claim is more useful to a reader than an absent one.
123
+
124
+ **What we will not do.** We will not write it for you and then call it independent. The value of a
125
+ third implementation is exactly that its author was not us, so a version written here would be
126
+ worth less than the four days it took somebody else.
127
+
128
+ Issue [#16](https://github.com/Bubblegunn/proactive-gate/issues/16) is the place to say you are
129
+ starting, so two people do not write the same one.
package/spec/SPEC.md CHANGED
@@ -7,6 +7,9 @@ empty at a stable release.
7
7
 
8
8
  Versioning: patch releases add fixtures that existing implementations already pass; minor
9
9
  releases add a check or field and mark it with `since`; major releases change an expectation.
10
+ A preset added to the vocabulary is a minor, because 7.3 requires an implementation to reject a
11
+ policy naming a preset it does not know, so a fixture that names the new preset is one no existing
12
+ implementation can pass.
10
13
  An implementation declares the spec version it targets, and its CI MUST assert that the value
11
14
  equals `SPEC_VERSION`.
12
15
 
package/spec/SPEC_VERSION CHANGED
@@ -1 +1 @@
1
- 1.2.0
1
+ 1.3.0
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec_version": "1.2.0",
2
+ "spec_version": "1.3.0",
3
3
  "since": "1.0.0",
4
4
  "name": "adaptive-timing/placeholder",
5
5
  "description": "A JSON policy cannot carry a next-good-moment function; the placeholder passes and keeps the trace shape.",