proactive-gate 0.5.0 → 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.
package/README.md CHANGED
@@ -36,6 +36,67 @@ if (decision.allowed && (await gate.commit(decision, { user, candidate }))) {
36
36
  }
37
37
  ```
38
38
 
39
+ ## What it changes, before you install anything
40
+
41
+ ```
42
+ npx proactive-gate simulate
43
+ ```
44
+
45
+ No key, no account, nothing to configure. It replays one week of an assistant that fires when its
46
+ own data arrives rather than when the recipient is awake, first with no gate and then through the
47
+ default order, and prints what each one did with every candidate.
48
+
49
+ | | no gate | proactive-gate |
50
+ | ----------------------------------------------------------- | ------: | -------------: |
51
+ | delivered | 171 | 74 |
52
+ | held | 0 | 97 |
53
+ | **delivered inside the recipient's own quiet hours** | **52** | **3** |
54
+ | of those, critical, which the documented floor lets through | 5 | 3 |
55
+ | most one person received in one local day | 6 | 5 |
56
+
57
+ The row in bold is the one worth reading twice. It counts deliveries inside the window each person
58
+ set for themselves, not inside a curfew somebody picked for them: the user in the week who asked for
59
+ no quiet hours at all is not protected from herself, and the one whose window runs 00:00 to 06:00 is
60
+ not judged against anyone else's night. Under the default order the only three that landed there were
61
+ critical, which is what the documented priority floor is for.
62
+
63
+ **Scope and method, because a number without them is decoration.** The week is a generator plus a
64
+ seed, not anybody's traffic: eight users in five time zones, candidate instants drawn uniformly
65
+ across the UTC day, parameters written down in `src/demo-week.ts` and dumped to
66
+ [`examples/week.jsonl`](examples/week.jsonl) so you can read what it produced. It measures what a
67
+ policy does to a stream. It cannot tell you whether a message was wanted, or what its recipient did
68
+ with it. Every user in it has consented and no dismissals are seeded, so `consent`, `enabled`,
69
+ `killSwitch`, `dedupe` and `dismissalCooldown` never fire in that run.
70
+
71
+ Three more ways to run it:
72
+
73
+ ```
74
+ npx proactive-gate simulate --disagreements --why # the sentence behind each hold
75
+ npx proactive-gate simulate your-candidates.jsonl # your traffic, not a generated week
76
+ npx proactive-gate simulate --policy examples/policies/aggressive.json \
77
+ --policy examples/policies/respectful.json
78
+ ```
79
+
80
+ The last one is the comparison to run before changing a policy in production: same stream, two
81
+ policies, and the 73 candidates they disagree about listed one by one.
82
+
83
+ ## What this will not do
84
+
85
+ This repository is young and grew quickly, so the boundary is written down rather than left to be
86
+ inferred from what happens to be in it.
87
+
88
+ - **The twelve checks are the policy surface.** A thirteenth needs a deployment whose rule the
89
+ twelve cannot express, not a rule that exists somewhere in the world.
90
+ - **The preset catalogue is frozen at what is merged.** A new preset needs someone who is shipping
91
+ to that channel, or under that instrument, and who says so on the issue. "This country also has a
92
+ law" is not the bar, because every country does, and a preset nobody ships against rots unread.
93
+ - **Adapters and stores are frozen on the same terms.** The next one arrives with the person who
94
+ needs it.
95
+ - **What grows instead is evidence**: the simulator above, the conformance suite, and an
96
+ implementation of the specification by somebody who is not us.
97
+ - **What will never be here**: anything that needs a server, a hosted account, or reads the content
98
+ of a message.
99
+
39
100
  Or start from a policy file and the wiring for your framework, in one command:
40
101
 
41
102
  ```
@@ -393,7 +454,9 @@ and day type, not a fixed statutory quiet window, and the secondary sources that
393
454
  window disagree with each other about whether it starts at 09:00 or 10:00. What the
394
455
  regulation does fix is the default: four of the nine Schedule-II bands, covering 00:00 to
395
456
  10:00 and 21:00 to 24:00, are off for every customer until the subscriber switches that band
396
- on, so `inTcccp` encodes those as one opt-in consent per band rather than a hard window.
457
+ on, so `inTcccp` encodes those as one opt-in consent per band rather than a hard window. It was
458
+ contributed by [@LouisDeconinck](https://github.com/LouisDeconinck) in
459
+ [#29](https://github.com/Bubblegunn/proactive-gate/pull/29), in TypeScript and Python together.
397
460
 
398
461
  ## The budget is enforced at commit, not at evaluate
399
462
 
@@ -791,7 +854,7 @@ before. [@Aaqibhafeezkhan](https://github.com/Aaqibhafeezkhan) wrote `SqliteStor
791
854
  every release since, including the one you install today. @Aaqibhafeezkhan came back for a
792
855
  second one and wrote the store contract suite in [#24](https://github.com/Bubblegunn/proactive-gate/pull/24).
793
856
 
794
- A third person arrived from the other direction. [@LouisDeconinck](https://github.com/LouisDeconinck) took [#26](https://github.com/Bubblegunn/proactive-gate/issues/26), an issue this project filed against itself to admit that expired rows were never cleaned up, and twelve minutes later sent the fix in both languages with the test that would have caught the original bug ([#27](https://github.com/Bubblegunn/proactive-gate/pull/27), in 0.3.1). He then took [#23](https://github.com/Bubblegunn/proactive-gate/issues/23), the issue asking for a rejection reason a product manager could read, and wrote its sixty-seven sentence templates in both languages ([#28](https://github.com/Bubblegunn/proactive-gate/pull/28), in 0.4.0). That is the work in this library that is hardest to review and easiest to get wrong, because it lives in the wording rather than in the code.
857
+ A third person arrived from the other direction. [@LouisDeconinck](https://github.com/LouisDeconinck) took [#26](https://github.com/Bubblegunn/proactive-gate/issues/26), an issue this project filed against itself to admit that expired rows were never cleaned up, and twelve minutes later sent the fix in both languages with the test that would have caught the original bug ([#27](https://github.com/Bubblegunn/proactive-gate/pull/27), in 0.3.1). He then took [#23](https://github.com/Bubblegunn/proactive-gate/issues/23), the issue asking for a rejection reason a product manager could read, and wrote its sixty-seven sentence templates in both languages ([#28](https://github.com/Bubblegunn/proactive-gate/pull/28), in 0.4.0). That is the work in this library that is hardest to review and easiest to get wrong, because it lives in the wording rather than in the code. His third, hours later, was the India preset ([#29](https://github.com/Bubblegunn/proactive-gate/pull/29), in 0.5.0): he read TRAI's Schedule-II rather than the summaries everyone repeats, found that the "9am to 9pm" everybody quotes is not in the primary text, and encoded the four default-off bands as the opt-ins the regulation actually describes.
795
858
 
796
859
  ## Cite this
797
860
 
package/README.tr.md CHANGED
@@ -29,6 +29,51 @@ if (decision.allowed && (await gate.commit(decision, { user, candidate }))) {
29
29
  }
30
30
  ```
31
31
 
32
+ ## Kurmadan önce ne değiştirdiğini görün
33
+
34
+ ```
35
+ npx proactive-gate simulate
36
+ ```
37
+
38
+ Anahtar yok, hesap yok, ayar yok. Verisi hazır olduğunda tetiklenen, yani kullanıcının uyanık olup
39
+ olmadığına bakmayan bir asistanın bir haftasını önce hiç geçit olmadan, sonra varsayılan sırayla
40
+ tekrar oynatır ve her adaya ne olduğunu yazar.
41
+
42
+ | | geçit yok | proactive-gate |
43
+ | ------------------------------------------------------ | --------: | -------------: |
44
+ | iletildi | 171 | 74 |
45
+ | tutuldu | 0 | 97 |
46
+ | **kişinin kendi sessiz saatleri içinde iletilen** | **52** | **3** |
47
+ | bunlardan kritik olanlar (eşiğin geçirdiği) | 5 | 3 |
48
+ | bir kişinin bir günde aldığı en yüksek sayı | 6 | 5 |
49
+
50
+ Koyu satır iki kez okunmaya değer: herkesin **kendi** belirlediği pencereyi sayar, birinin başkası
51
+ için seçtiği bir sokağa çıkma yasağını değil. Varsayılan sırada o pencereye giren üç mesajın üçü de
52
+ kritikti, ki belgelenmiş öncelik eşiği tam bunun için var.
53
+
54
+ **Kapsam ve yöntem, çünkü bunlar olmadan sayı süstür.** Hafta bir üretici ve bir tohumdur, kimsenin
55
+ gerçek trafiği değil: beş saat diliminde sekiz kullanıcı, adayların anları UTC günü boyunca düzgün
56
+ dağılımla seçilmiş, bütün parametreler `src/demo-week.ts` içinde yazılı ve
57
+ [`examples/week.jsonl`](examples/week.jsonl) dosyasına dökülmüş. Bir politikanın bir akışa ne
58
+ yaptığını ölçer; bir mesajın istenip istendiğini ya da alıcının onunla ne yaptığını ölçemez.
59
+
60
+ ```
61
+ npx proactive-gate simulate --disagreements --why # her tutmanın arkasındaki cümle
62
+ npx proactive-gate simulate kendi-adaylarim.jsonl # üretilmiş hafta değil, sizin trafiğiniz
63
+ ```
64
+
65
+ ## Bu kütüphanenin yapmayacağı şeyler
66
+
67
+ - **On iki kontrol, politika yüzeyinin tamamıdır.** On üçüncüsü için, on ikisinin ifade edemediği
68
+ bir kuralı olan gerçek bir kurulum gerekir.
69
+ - **Preset kataloğu birleşmiş hâliyle donduruldu.** Yeni bir preset için, o kanala ya da o mevzuata
70
+ göre gerçekten ürün gönderen ve bunu issue'da söyleyen birine ihtiyaç var. "Bu ülkenin de bir
71
+ yasası var" yeterli değil, çünkü her ülkenin var; kimsenin kullanmadığı preset çürür.
72
+ - **Adaptörler ve store'lar da aynı kuralla dondu.** Bir sonraki, ona ihtiyacı olan kişiyle gelir.
73
+ - **Bunun yerine büyüyen şey kanıt**: yukarıdaki simülatör, uygunluk paketi ve spesifikasyonun
74
+ bizim dışımızda biri tarafından yazılmış bir uygulaması.
75
+ - **Asla olmayacaklar**: sunucu, barındırılan hesap ya da mesaj içeriğini okuyan herhangi bir şey.
76
+
32
77
  Sıfır bağımlılık. TypeScript. Node 20 ya da üstü. Framework'ten bağımsız: kapı, "model bir
33
78
  şey üretti" ile "kullanıcının telefonu titredi" arasında durur; hangi model ya da framework
34
79
  üretmiş olursa olsun. Örnekler: [`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts),
@@ -323,7 +368,7 @@ const gate = createGate({ checks: [checks.consent(), ...presets.kakaoBrandMessag
323
368
  | `krNetworkAct50` | reklam rızası, ayrıca 21:00 ile 08:00 yerel saat için gece rızası |
324
369
  | `jpAntiSpamLaw` | opt-in |
325
370
  | `cnMinorMode` | reşit olmayanlar için: 06:00 ile 22:00 Asia/Shanghai ve günde bir |
326
- | `inTcccp` | promosyon rızası; varsayılan olarak kapalı olan 00:00-10:00 ve 21:00-24:00 bantları ayrı ayrı opt-in ister |
371
+ | `inTcccp` | promosyon rızası; varsayılan olarak kapalı olan 00:00-10:00 ve 21:00-24:00 bantları ayrı ayrı opt-in ister ([@LouisDeconinck](https://github.com/LouisDeconinck), [#29](https://github.com/Bubblegunn/proactive-gate/pull/29)) |
327
372
  | `usTcpa` | kullanıcının yerel saatiyle 08:00 ile 21:00 (47 CFR 64.1200) |
328
373
  | `euEprivacy` | pazarlama rızası, mevcut müşteriler için yumuşak opt-in |
329
374
  | `telegramBot` | sohbet başına saniyede 1 ve dakikada 20 |
package/dist/src/cli.d.ts CHANGED
@@ -4,6 +4,8 @@ import type { Decision, EvaluateInput } from "./types.js";
4
4
  export declare function loadPolicy(path?: string): Promise<Gate>;
5
5
  export declare function replay(lines: string[], gate: Gate, commit: boolean): Promise<Decision[]>;
6
6
  export declare function summarize(decisions: Decision[]): string;
7
+ /** One JSONL line per candidate, the same shape `replay` reads. */
8
+ export declare function parseEvents(text: string): EvaluateInput[];
7
9
  interface PreToolUseEvent {
8
10
  tool_name?: string;
9
11
  tool_input?: {
package/dist/src/cli.js CHANGED
@@ -21,12 +21,33 @@ import { createRequire } from "node:module";
21
21
  import { createGate } from "./gate.js";
22
22
  import { defaultChecks } from "./checks.js";
23
23
  import { loadFixtures, readSkips, runFixture } from "./conformance.js";
24
+ import { simulate } from "./simulate.js";
25
+ import { formatSimulation } from "./simulate-report.js";
26
+ import { DEMO_WEEK_NOTE, demoWeek, demoWeekPeople } from "./demo-week.js";
24
27
  import { FRAMEWORKS, listText, plan } from "./init.js";
25
- const HELP = `usage: proactive-gate init [--preset <name>] [--framework <name>] [--out <file>]
28
+ const HELP = `usage: proactive-gate simulate [events.jsonl] [--policy <file>]... [--seed <n>]
29
+ proactive-gate init [--preset <name>] [--framework <name>] [--out <file>]
26
30
  proactive-gate replay <events.jsonl> [--policy <file>] [--json] [--commit]
27
31
  proactive-gate replay --fixtures <dir> [--skip <file>]
28
32
  proactive-gate hook --policy <file> [--tool <name>]
29
33
 
34
+ simulate runs one stream of candidates under two policies and shows what each one did
35
+ with every candidate: sent, held and why, deferred and until when, which budget it spent.
36
+ With no arguments it runs a generated week against no gate at all, which is the comparison
37
+ that answers what installing this would change. It adds nothing to the decision path: the
38
+ same gate runs twice.
39
+
40
+ events.jsonl your own candidates, one EvaluateInput per line (default: a generated week)
41
+ --policy <file> a policy.json to run. Given once, it is compared against no gate; given
42
+ twice, the two policies are compared against each other
43
+ --seed <n> seeds the generated week and the simulated transport (default 7)
44
+ --limit <n> timeline rows to print, 0 for all (default 20)
45
+ --disagreements print only the candidates the policies disagreed about
46
+ --why print each held candidate's sentence under its row
47
+ --transport-failure-rate <f> fraction of simulated sends that fail (default 0)
48
+ --dump-events <f> write the generated week to <f> as JSONL and exit
49
+ --json the whole result, for computing your own figures
50
+
30
51
  init writes a policy you can read and edit, and prints the lines that wire it in.
31
52
 
32
53
  --preset <name> a platform or legal preset to append (see --list)
@@ -125,6 +146,30 @@ export function summarize(decisions) {
125
146
  return lines.join("\n");
126
147
  }
127
148
  const pct = (n, total) => (total ? `${((100 * n) / total).toFixed(1)}%` : "0%");
149
+ /** Every value of a flag that may be repeated, in the order it was given. */
150
+ const argValues = (argv, flag) => {
151
+ const out = [];
152
+ for (const [i, a] of argv.entries())
153
+ if (a === flag && argv[i + 1] !== undefined)
154
+ out.push(argv[i + 1]);
155
+ return out;
156
+ };
157
+ /** One JSONL line per candidate, the same shape `replay` reads. */
158
+ export function parseEvents(text) {
159
+ const events = [];
160
+ for (const [i, line] of text.split("\n").entries()) {
161
+ if (!line.trim())
162
+ continue;
163
+ try {
164
+ const raw = JSON.parse(line);
165
+ events.push({ ...raw, ...(raw.now ? { now: new Date(raw.now) } : {}) });
166
+ }
167
+ catch (error) {
168
+ throw new Error(`line ${i + 1}: ${error instanceof Error ? error.message : String(error)}`);
169
+ }
170
+ }
171
+ return events;
172
+ }
128
173
  const argValue = (argv, flag) => {
129
174
  const i = argv.indexOf(flag);
130
175
  return i >= 0 ? argv[i + 1] : undefined;
@@ -189,6 +234,52 @@ async function main(argv) {
189
234
  return;
190
235
  }
191
236
  const [command, file] = argv;
237
+ if (command === "simulate") {
238
+ const seed = Number(argValue(argv, "--seed") ?? 7);
239
+ if (!Number.isFinite(seed)) {
240
+ console.error("--seed takes a number");
241
+ process.exit(2);
242
+ }
243
+ const eventsFile = file && !file.startsWith("--") ? file : undefined;
244
+ const events = eventsFile ? parseEvents(await readFile(eventsFile, "utf8")) : demoWeek(seed);
245
+ const dump = argValue(argv, "--dump-events");
246
+ if (dump) {
247
+ const lines = events.map((e) => JSON.stringify({ user: e.user, candidate: e.candidate, now: e.now?.toISOString() }));
248
+ await writeFile(dump, `${lines.join("\n")}\n`);
249
+ console.log(`wrote ${events.length} events to ${dump}`);
250
+ return;
251
+ }
252
+ const policyFiles = argValues(argv, "--policy");
253
+ const policies = [];
254
+ for (const path of policyFiles) {
255
+ if (!path.endsWith(".json")) {
256
+ console.error(`simulate reads policy documents, not modules: ${path} must be a .json policy, because the simulation owns the store it reads budgets back out of`);
257
+ process.exit(2);
258
+ }
259
+ const label = path.replace(/^.*[/\\]/, "").replace(/\.json$/, "");
260
+ policies.push({ label, policy: JSON.parse(await readFile(path, "utf8")) });
261
+ }
262
+ // No policy given: the comparison a stranger wants, no gate against the default order.
263
+ // One policy given: the same comparison, against theirs. Two or more: theirs against theirs.
264
+ if (policies.length === 0)
265
+ policies.push({ label: "proactive-gate", checks: defaultChecks() });
266
+ if (policies.length === 1)
267
+ policies.unshift({ label: "no gate" });
268
+ const failureRate = Number(argValue(argv, "--transport-failure-rate") ?? 0);
269
+ const result = await simulate({ events, policies, seed, transportFailureRate: failureRate });
270
+ if (argv.includes("--json")) {
271
+ console.log(JSON.stringify(result, null, 2));
272
+ return;
273
+ }
274
+ const limitArg = argValue(argv, "--limit");
275
+ console.log(formatSimulation(result, {
276
+ ...(limitArg === undefined ? {} : { limit: Number(limitArg) }),
277
+ disagreementsOnly: argv.includes("--disagreements"),
278
+ why: argv.includes("--why"),
279
+ ...(eventsFile ? {} : { note: DEMO_WEEK_NOTE, people: demoWeekPeople() }),
280
+ }));
281
+ return;
282
+ }
192
283
  if (command === "init") {
193
284
  if (argv.includes("--list")) {
194
285
  console.log(listText());
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The week `proactive-gate simulate` runs when you give it nothing of your own.
3
+ *
4
+ * It is a generator plus a seed, not a committed blob, so it is auditable in a way a data file
5
+ * is not: the parameters below are the whole definition, and `--dump-events` writes out exactly
6
+ * what they produced. It adds nothing to the published package.
7
+ *
8
+ * What it is: eight users in five time zones over seven days, with an assistant that fires when
9
+ * its own data arrives rather than when the recipient is awake. That is the ordinary failure
10
+ * mode this library exists for, and it is why candidate instants are drawn uniformly across the
11
+ * UTC day rather than placed politely inside each user's waking hours.
12
+ *
13
+ * What it is not: anybody's real traffic, and not a measurement of one. It cannot tell you how
14
+ * often a real assistant has something to say, whether a message was wanted, or what a user did
15
+ * with it. It measures what a policy does to a stream, which is the only thing a policy decides.
16
+ *
17
+ * What it does not reach, said plainly so nobody reads a clean run as full coverage: every user
18
+ * here has consented and is enabled, so `consent`, `enabled` and `killSwitch` never fire; no
19
+ * dismissals are seeded, so `dismissalCooldown` never fires; no candidate carries a `dedupeKey`,
20
+ * `pAccept` or `busy`, so `dedupe`, `utilityFloor` and `boundedDeferral` stay quiet; and no
21
+ * preset is involved. The checks it does exercise are quiet hours with its priority floor,
22
+ * mode, mute, snooze, intensity, the trust ramp and the daily budget.
23
+ */
24
+ import type { EvaluateInput } from "./types.js";
25
+ /** First and last instant of the generated week, inclusive of the first, exclusive of the last. */
26
+ export declare const DEMO_WEEK_START = "2026-09-07T00:00:00.000Z";
27
+ export declare const DEMO_WEEK_DAYS = 7;
28
+ /**
29
+ * The generated week, in instant order.
30
+ *
31
+ * Ordered by instant rather than grouped by user because that is the order a server produces
32
+ * them in, and because the budget and the deferral queue both depend on the order they arrive.
33
+ */
34
+ export declare function demoWeek(seed?: number): EvaluateInput[];
35
+ /** One line per person, for the footer of the table, so the week is legible without reading code. */
36
+ export declare const demoWeekPeople: () => string[];
37
+ /** What the week is and is not, printed under any table built from it. */
38
+ export declare const DEMO_WEEK_NOTE: string;
@@ -0,0 +1,115 @@
1
+ /** First and last instant of the generated week, inclusive of the first, exclusive of the last. */
2
+ export const DEMO_WEEK_START = "2026-09-07T00:00:00.000Z";
3
+ export const DEMO_WEEK_DAYS = 7;
4
+ /** One line per user, and every field on it is there to make a particular check reachable. */
5
+ const PEOPLE = [
6
+ {
7
+ user: { id: "ayse", proactiveEnabled: true, mode: "normal", intensity: "normal", timezone: "Europe/Istanbul", quietHours: { start: "22:00", end: "08:00" }, createdAt: "2026-04-02T00:00:00Z" },
8
+ why: "the ordinary case: an established account with ordinary quiet hours",
9
+ },
10
+ {
11
+ user: { id: "ben", proactiveEnabled: true, mode: "normal", intensity: "low", timezone: "America/New_York", quietHours: { start: "23:00", end: "07:00" }, createdAt: "2026-01-15T00:00:00Z" },
12
+ why: "intensity low, so the floor rises and ordinary messages stop being worth sending",
13
+ },
14
+ {
15
+ user: { id: "chika", proactiveEnabled: true, mode: "normal", intensity: "normal", timezone: "Asia/Tokyo", quietHours: { start: "22:30", end: "06:30" }, createdAt: "2026-09-04T00:00:00Z" },
16
+ why: "three days old at the start of the week, so the trust ramp is still on",
17
+ },
18
+ {
19
+ user: { id: "dilan", proactiveEnabled: true, mode: "normal", intensity: "normal", timezone: "Europe/Istanbul", quietHours: { start: "22:00", end: "08:00" }, mutedTypes: ["digest"], createdAt: "2026-03-01T00:00:00Z" },
20
+ why: "one type muted, which is a user's own decision and not a budget",
21
+ },
22
+ {
23
+ user: { id: "emre", proactiveEnabled: true, mode: "normal", intensity: "normal", timezone: "America/Sao_Paulo", quietHours: { start: "23:00", end: "07:00" }, snoozedUntil: "2026-09-10T12:00:00Z", createdAt: "2026-02-20T00:00:00Z" },
24
+ why: "snoozed into the middle of the week, so the first days are held rather than dropped",
25
+ },
26
+ {
27
+ user: { id: "fatima", proactiveEnabled: true, mode: "focus", intensity: "normal", timezone: "Europe/London", quietHours: { start: "22:00", end: "07:30" }, createdAt: "2025-11-11T00:00:00Z" },
28
+ why: "in focus mode all week, which the default order only lets critical through",
29
+ },
30
+ {
31
+ user: { id: "gabriel", proactiveEnabled: true, mode: "normal", intensity: "normal", timezone: "America/New_York", quietHours: { start: "00:00", end: "06:00" }, createdAt: "2026-09-06T00:00:00Z" },
32
+ why: "one day old, with a narrow night: a new account is the strictest case there is",
33
+ },
34
+ {
35
+ user: { id: "hana", proactiveEnabled: true, mode: "normal", intensity: "high", timezone: "Asia/Tokyo", quietHours: null, createdAt: "2025-08-01T00:00:00Z" },
36
+ why: "intensity high and no quiet hours at all: the person who wants everything",
37
+ },
38
+ ];
39
+ /** Types an assistant of this shape actually produces, and nothing invented for the demo. */
40
+ const TYPES = ["reminder", "insight", "digest", "alert", "nudge"];
41
+ /** Priorities, weighted the way a real stream is: mostly ordinary, rarely an emergency. */
42
+ const PRIORITIES = [
43
+ { value: "low", weight: 15 },
44
+ { value: "normal", weight: 60 },
45
+ { value: "high", weight: 20 },
46
+ { value: "critical", weight: 5 },
47
+ ];
48
+ /**
49
+ * Candidates per user per day, drawn uniformly from this list: a mean of about three, which is
50
+ * a product that surfaces a couple of reminders, a digest and the occasional alert. Chosen
51
+ * before the run rather than after seeing it, and wide enough that the default daily budget of
52
+ * five binds for the chattier days instead of never being reached.
53
+ */
54
+ const PER_DAY = [1, 2, 3, 3, 4, 6];
55
+ function mulberry32(seed) {
56
+ let a = seed >>> 0;
57
+ return () => {
58
+ a = (a + 0x6d2b79f5) >>> 0;
59
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
60
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
61
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
62
+ };
63
+ }
64
+ const pick = (random, values) => values[Math.floor(random() * values.length)];
65
+ function pickPriority(random) {
66
+ const total = PRIORITIES.reduce((n, p) => n + p.weight, 0);
67
+ let roll = random() * total;
68
+ for (const p of PRIORITIES) {
69
+ roll -= p.weight;
70
+ if (roll <= 0)
71
+ return p.value;
72
+ }
73
+ return "normal";
74
+ }
75
+ /**
76
+ * The generated week, in instant order.
77
+ *
78
+ * Ordered by instant rather than grouped by user because that is the order a server produces
79
+ * them in, and because the budget and the deferral queue both depend on the order they arrive.
80
+ */
81
+ export function demoWeek(seed = 7) {
82
+ const random = mulberry32(seed);
83
+ const start = Date.parse(DEMO_WEEK_START);
84
+ const events = [];
85
+ let n = 0;
86
+ for (const { user } of PEOPLE) {
87
+ for (let day = 0; day < DEMO_WEEK_DAYS; day += 1) {
88
+ const count = pick(random, PER_DAY);
89
+ for (let i = 0; i < count; i += 1) {
90
+ const minuteOfDay = Math.floor(random() * 24 * 60);
91
+ const at = new Date(start + day * 86400000 + minuteOfDay * 60000);
92
+ const candidate = {
93
+ id: `c${(n += 1)}`,
94
+ type: pick(random, TYPES),
95
+ priority: pickPriority(random),
96
+ surfaces: ["push", "feed"],
97
+ };
98
+ events.push({ user: { ...user, consent: true }, candidate, now: at });
99
+ }
100
+ }
101
+ }
102
+ return events.sort((a, b) => (a.now?.getTime() ?? 0) - (b.now?.getTime() ?? 0));
103
+ }
104
+ /** One line per person, for the footer of the table, so the week is legible without reading code. */
105
+ export const demoWeekPeople = () => PEOPLE.map(({ user, why }) => `${user.id} (${user.timezone}): ${why}`);
106
+ /** What the week is and is not, printed under any table built from it. */
107
+ export const DEMO_WEEK_NOTE = [
108
+ "This is a generated week, not anybody's traffic: eight users in five time zones over seven days,",
109
+ "with candidate instants drawn uniformly across the UTC day, which is how an assistant that fires",
110
+ "when its data arrives meets a person who is asleep. It measures what a policy does to a stream.",
111
+ "It cannot tell you whether a message was wanted, or what its recipient did with it.",
112
+ "Every user here has consented and no dismissals are seeded, so consent, enabled, killSwitch,",
113
+ "dedupe and dismissalCooldown never fire in this run. Point --events at your own JSONL for a",
114
+ "number about your own traffic.",
115
+ ].join("\n");
@@ -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.5.0",
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"
@@ -83,3 +83,47 @@ weaker evidence than it looks: agreement between two implementations by one auth
83
83
  consistency check than to independent verification. A third implementation, written from `SPEC.md`
84
84
  by someone who has not read the source, is what would test whether this document is enough. Until
85
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.