proactive-gate 0.3.0 → 0.4.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 +49 -6
- package/README.tr.md +47 -2
- package/dist/src/explain.d.ts +130 -0
- package/dist/src/explain.js +336 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +1 -0
- package/dist/src/stores.d.ts +7 -0
- package/dist/src/stores.js +18 -1
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -4,10 +4,8 @@ English | [Türkçe](README.tr.md)
|
|
|
4
4
|
|
|
5
5
|
<p>
|
|
6
6
|
<img src="https://img.shields.io/npm/v/proactive-gate?style=flat-square&color=111111&label=npm" alt="npm">
|
|
7
|
-
<img src="https://img.shields.io/npm/dm/proactive-gate?style=flat-square&color=111111" alt="npm downloads">
|
|
8
7
|
<img src="https://img.shields.io/github/actions/workflow/status/Bubblegunn/proactive-gate/ci.yml?style=flat-square&color=111111&label=ci" alt="ci">
|
|
9
8
|
<img src="https://img.shields.io/bundlephobia/minzip/proactive-gate?style=flat-square&color=111111" alt="minzipped size">
|
|
10
|
-
<img src="https://img.shields.io/github/stars/Bubblegunn/proactive-gate?style=flat-square&color=111111" alt="stars">
|
|
11
9
|
<img src="https://img.shields.io/badge/license-MIT-111111?style=flat-square" alt="MIT">
|
|
12
10
|
<a href="https://doi.org/10.5281/zenodo.22393512"><img src="https://img.shields.io/badge/DOI-10.5281%2Fzenodo.22393512-111111?style=flat-square" alt="DOI"></a>
|
|
13
11
|
</p>
|
|
@@ -49,6 +47,13 @@ preset you named, and prints the preset's own source next to the few lines that
|
|
|
49
47
|
the gate into that framework. `npx proactive-gate init --list` shows the fourteen
|
|
50
48
|
platform and legal presets and the four frameworks.
|
|
51
49
|
|
|
50
|
+
If you would rather see the argument than read it: `npm run bench:compare` replays a
|
|
51
|
+
committed day through this gate and through five hand-written `if` statements, and prints
|
|
52
|
+
the six places they disagree: a critical alert the cap should have let through, a two-day-old
|
|
53
|
+
account, a snooze, a run of dismissals, and a local day boundary that silences one user and
|
|
54
|
+
pays another twice. The output, and why none of the six is a matter of taste, is in
|
|
55
|
+
[Compared with hand-rolled checks](#compared-with-hand-rolled-checks-and-feature-flags).
|
|
56
|
+
|
|
52
57
|
Zero dependencies. TypeScript. Node 20 or newer. Framework-agnostic: the gate sits
|
|
53
58
|
between "the model produced something" and "the user's phone buzzed", whichever
|
|
54
59
|
model or framework produced it. Examples: [`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts),
|
|
@@ -247,6 +252,29 @@ a hook that throws is routed to `error` and never changes the decision. `example
|
|
|
247
252
|
turns them into one span per check. Every decision has an `id`, and `commit` is idempotent on
|
|
248
253
|
it: a retry after a timeout does not consume a second unit.
|
|
249
254
|
|
|
255
|
+
## A rejection reason a product manager can read
|
|
256
|
+
|
|
257
|
+
`decision.reason` is written for the engineer holding the trace. `explain(decision)` renders
|
|
258
|
+
the same decision as sentences for the person who decides whether the assistant is too chatty:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
import { explain } from "proactive-gate";
|
|
262
|
+
|
|
263
|
+
explain(decision).summary;
|
|
264
|
+
// "Held until 08:00 because the user's quiet hours run 22:00 to 08:00 Europe/Istanbul and
|
|
265
|
+
// normal priority is below the critical floor needed to override them."
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
It answers "why did this go out at 22:30" too: an allowed decision explains itself, and
|
|
269
|
+
`explanation.checks` carries one sentence per check that ran, in order. The renderer is a
|
|
270
|
+
pure function of the decision; it invents nothing, and a check whose reason matches nothing
|
|
271
|
+
known is quoted verbatim rather than guessed. `language` is a parameter: English ships as
|
|
272
|
+
`en`, and any other language is a `Partial<Sentences>` in `catalogs`, merged over English so
|
|
273
|
+
a partial translation still renders. The Python sibling ships the same sentences, and CI
|
|
274
|
+
compares both renderings of every fixture decision on each push. It was contributed by
|
|
275
|
+
[@LouisDeconinck](https://github.com/LouisDeconinck) in
|
|
276
|
+
[#28](https://github.com/Bubblegunn/proactive-gate/pull/28), in TypeScript and Python together.
|
|
277
|
+
|
|
250
278
|
## Optional checks, fed by your own model
|
|
251
279
|
|
|
252
280
|
Both ship off. They read numbers the caller puts on the candidate.
|
|
@@ -433,8 +461,12 @@ Set `windowSeconds` from your own transport's retry horizon.
|
|
|
433
461
|
`MemoryStore` keeps values in process memory and is useful for a single instance. `RedisStore`
|
|
434
462
|
shares values across instances. `SqliteStore` persists values in a SQLite database without
|
|
435
463
|
adding a package dependency. It was contributed by
|
|
436
|
-
[@
|
|
464
|
+
[@Aaqibhafeezkhan](https://github.com/Aaqibhafeezkhan) in [#3](https://github.com/Bubblegunn/proactive-gate/pull/3). `SqliteStore` requires Node.js 22.5 or newer; the SQLite module
|
|
437
465
|
is loaded only when the store is constructed so the package can still be used on Node.js 20. On Node 22 the module prints an ExperimentalWarning on first use; it is stable from Node 24.
|
|
466
|
+
Expired rows are removed when a read touches them, and every `set` or `incr` first removes the
|
|
467
|
+
rows that have already expired, so a key nobody reads again still disappears: a year of daily
|
|
468
|
+
budget keys leaves the live rows in the table rather than one dead row per day. The write-time sweep and its partial index were contributed by
|
|
469
|
+
[@LouisDeconinck](https://github.com/LouisDeconinck) in [#27](https://github.com/Bubblegunn/proactive-gate/pull/27).
|
|
438
470
|
|
|
439
471
|
**Writing your own store?** `proactive-gate/store-contract` exports the same suite these three are
|
|
440
472
|
held to, so you can prove yours behaves rather than hope:
|
|
@@ -448,7 +480,7 @@ It checks `get`, `set` and `del`, `incr` from an absent key, concurrent `incr` a
|
|
|
448
480
|
expiry boundary, and that a TTL given to `set` and one given to `incr` agree, then replays a
|
|
449
481
|
seeded random operation sequence against `MemoryStore`. A store whose backend owns the clock
|
|
450
482
|
passes `expiry: "skip"` and those cases are reported as skipped rather than quietly dropped. It
|
|
451
|
-
was contributed by [@
|
|
483
|
+
was contributed by [@Aaqibhafeezkhan](https://github.com/Aaqibhafeezkhan) in
|
|
452
484
|
[#24](https://github.com/Bubblegunn/proactive-gate/pull/24), and lives on its own subpath so
|
|
453
485
|
importing the package never pulls `node:test` into your bundle. See
|
|
454
486
|
[docs/store-contract.md](docs/store-contract.md).
|
|
@@ -563,6 +595,15 @@ candidates with the clock taken from each line, and `node examples/ai-sdk/run.mj
|
|
|
563
595
|
day of tool-approval requests, one of which is a critical alert that a legal window (the TCPA
|
|
564
596
|
preset) still refuses. Both are part of `npm run examples` and of the test suite.
|
|
565
597
|
|
|
598
|
+
The other four, the `.ts` files, are illustrations rather than fixtures: they import
|
|
599
|
+
`@langchain/langgraph`, `@mastra/core` and the AI SDK, none of which is a dependency here, so
|
|
600
|
+
they are neither compiled nor executed and this README does not claim they are. What *is*
|
|
601
|
+
checked is the half we control: `test/example-imports.test.mjs` asserts that every symbol they
|
|
602
|
+
import from `proactive-gate` still exists, as a value or as a type, so renaming an export
|
|
603
|
+
cannot leave a published snippet quietly telling readers to import something that is gone. It
|
|
604
|
+
cannot tell you a framework changed its own API, and installing four agent frameworks to
|
|
605
|
+
type-check four snippets is a worse trade than saying which files are executed.
|
|
606
|
+
|
|
566
607
|
## Python
|
|
567
608
|
|
|
568
609
|
```
|
|
@@ -737,13 +778,15 @@ trace, and adds the part they leave out: the budget consumed at send time.
|
|
|
737
778
|
## Thanks
|
|
738
779
|
|
|
739
780
|
Two people sent pull requests on the day this was published, neither of whom I had spoken to
|
|
740
|
-
before. [@
|
|
781
|
+
before. [@Aaqibhafeezkhan](https://github.com/Aaqibhafeezkhan) wrote `SqliteStore`
|
|
741
782
|
([#3](https://github.com/Bubblegunn/proactive-gate/pull/3)) and
|
|
742
783
|
[@edwardsong08](https://github.com/edwardsong08) wrote the weekly budget
|
|
743
784
|
([#9](https://github.com/Bubblegunn/proactive-gate/pull/9)). Both shipped in 0.1.2 and are in
|
|
744
|
-
every release since, including the one you install today. @
|
|
785
|
+
every release since, including the one you install today. @Aaqibhafeezkhan came back for a
|
|
745
786
|
second one and wrote the store contract suite in [#24](https://github.com/Bubblegunn/proactive-gate/pull/24).
|
|
746
787
|
|
|
788
|
+
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.
|
|
789
|
+
|
|
747
790
|
## Cite this
|
|
748
791
|
|
|
749
792
|
Every release is archived on Zenodo with a DOI, so a paper or a report can point at the
|
package/README.tr.md
CHANGED
|
@@ -37,6 +37,14 @@ Sıfır bağımlılık. TypeScript. Node 20 ya da üstü. Framework'ten bağıms
|
|
|
37
37
|
[bubblegunn.github.io/proactive-gate](https://bubblegunn.github.io/proactive-gate/). Python:
|
|
38
38
|
[`python/`](python/README.md).
|
|
39
39
|
|
|
40
|
+
Okumak yerine görmeyi tercih ederseniz: `npm run bench:compare`, kayıtlı bir günü hem bu kapıdan
|
|
41
|
+
hem de elle yazılmış beş `if` ifadesinden geçirir ve ayrıştıkları altı yeri yazdırır: kotanın
|
|
42
|
+
geçirmesi gereken kritik bir uyarı, iki günlük bir hesap, bir erteleme, arka arkaya gelen
|
|
43
|
+
reddedişler, ve bir kullanıcıyı susturup bir diğerine iki kat mesaj veren yerel gün sınırı. Altısının da
|
|
44
|
+
neden zevk meselesi olmadığı, bu dosyanın kısaltılmış olması nedeniyle yalnızca İngilizce
|
|
45
|
+
README'de anlatılıyor: [Compared with hand-rolled
|
|
46
|
+
checks](README.md#compared-with-hand-rolled-checks-and-feature-flags).
|
|
47
|
+
|
|
40
48
|
## Bir karar neye benzer
|
|
41
49
|
|
|
42
50
|
```ts
|
|
@@ -201,6 +209,31 @@ fırlatan bir kanca `error` kancasına yönlendirilir ve kararı asla değiştir
|
|
|
201
209
|
`commit` bu id üzerinde tekrarlanabilir: zaman aşımından sonraki bir yeniden deneme ikinci
|
|
202
210
|
bir birim tüketmez.
|
|
203
211
|
|
|
212
|
+
## Bir ürün yöneticisinin okuyabileceği ret gerekçesi
|
|
213
|
+
|
|
214
|
+
`decision.reason` izi elinde tutan mühendis için yazılmıştır. `explain(decision)` aynı kararı,
|
|
215
|
+
asistanın fazla konuşkan olup olmadığına karar veren kişi için cümlelere çevirir; aynı izden,
|
|
216
|
+
hiçbir şey eklemeden:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
import { explain } from "proactive-gate";
|
|
220
|
+
|
|
221
|
+
explain(decision).summary;
|
|
222
|
+
// "Held until 08:00 because the user's quiet hours run 22:00 to 08:00 Europe/Istanbul and
|
|
223
|
+
// normal priority is below the critical floor needed to override them."
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
"Bu mesaj 22:30'da neden gitti" sorusunun da bir yanıtı olur: izin verilen karar da kendini
|
|
227
|
+
anlatır, `explanation.checks` ise çalışan her kontrol için sırayla bir cümle taşır. Renderer
|
|
228
|
+
kararın saf bir fonksiyonudur: saat okumaz, adaya da kullanıcıya da bakmaz, dolayısıyla
|
|
229
|
+
gate'in vermediği bir kararı anlatamaz. Hiçbir şablona uymayan bir gerekçe tahmin edilmez,
|
|
230
|
+
olduğu gibi alıntılanır ve söyleyen kontrolün adıyla verilir. `language` bir parametredir:
|
|
231
|
+
İngilizce `en` olarak gelir, başka bir dil `catalogs` içinde İngilizcenin üzerine birleşen bir
|
|
232
|
+
`Partial<Sentences>`'tır, böylece yarım bir çeviri de görüntülenir. Python kardeşi aynı
|
|
233
|
+
cümleleri yollar, CI her push'ta iki uygulamanın her fixture kararını karşılaştırır. Bu bölümü
|
|
234
|
+
[@LouisDeconinck](https://github.com/LouisDeconinck)
|
|
235
|
+
[#28](https://github.com/Bubblegunn/proactive-gate/pull/28) ile iki dilde birden yazdı.
|
|
236
|
+
|
|
204
237
|
## İsteğe bağlı, kendi modelinizin beslediği kontroller
|
|
205
238
|
|
|
206
239
|
İkisi de kapalı gelir; adayın üzerine çağıranın koyduğu sayıları okurlar.
|
|
@@ -330,6 +363,16 @@ yazmadığı bir sayıyı kodlardı; o yüzden yok.
|
|
|
330
363
|
Adaptörler framework paketine değil, çağrının biçimine göre tiplenmiştir; başka bir şey
|
|
331
364
|
kurmak gerekmez. Her biri kapının gerekçesiyle reddeder ve onayda bütçeyi tüketir.
|
|
332
365
|
|
|
366
|
+
Örneklerden ikisi framework kurulu olmadan ve ağ olmadan çalışır: `node examples/mastra/run.mjs`
|
|
367
|
+
ve `node examples/ai-sdk/run.mjs`. İkisi de `npm run examples` ve test paketinin parçası.
|
|
368
|
+
Diğer dört `.ts` dosyası ise fikstür değil, örnekleme: `@langchain/langgraph`, `@mastra/core` ve
|
|
369
|
+
AI SDK'yı içe aktarıyorlar, hiçbiri buranın bağımlılığı değil, dolayısıyla ne derleniyor ne
|
|
370
|
+
çalıştırılıyorlar ve bu README onların çalıştığını iddia etmiyor. Denetlenen şey, bizim
|
|
371
|
+
denetleyebildiğimiz yarısı: `test/example-imports.test.mjs`, bu dosyaların `proactive-gate`'ten içe
|
|
372
|
+
aktardığı her sembolün değer ya da tip olarak hâlâ var olduğunu doğrular, böylece bir export'un adı
|
|
373
|
+
değiştiğinde yayımlanmış bir örnek okuyucuya artık var olmayan bir şeyi içe aktarmasını söylemeye
|
|
374
|
+
sessizce devam edemez.
|
|
375
|
+
|
|
333
376
|
## Python
|
|
334
377
|
|
|
335
378
|
```
|
|
@@ -337,8 +380,10 @@ pip install proactive-gate
|
|
|
337
380
|
```
|
|
338
381
|
|
|
339
382
|
Yayınlanmamış bir durumu denemek için depodan kurulur: `pip install "proactive-gate @
|
|
340
|
-
git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`.
|
|
341
|
-
|
|
383
|
+
git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`. Python paketi npm paketiyle
|
|
384
|
+
aynı workflow tarafından yayımlanıyor, dolayısıyla her dosyayı hangi deponun ve hangi workflow'un
|
|
385
|
+
ürettiğini adlandıran PyPI yayın attestation'ları taşıyor. 0.2.2 öncesi sürümler yerel bir
|
|
386
|
+
derlemeden token ile yüklendi ve hiçbir kanıt taşımıyor.
|
|
342
387
|
|
|
343
388
|
`python/` sapan bir port değil, bir kardeştir: `spec/fixtures` altındaki her senaryoyu senkron
|
|
344
389
|
`Gate` ve `AsyncGate` (Redis, `redis.asyncio` üzerinden) ile geçer; mypy strict, CI'da Python
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* explain(): a decision rendered as sentences a non-engineer can read.
|
|
3
|
+
*
|
|
4
|
+
* The renderer is a pure function of the decision. Every fact in a sentence
|
|
5
|
+
* comes from a trace entry's own reason or from a decision field, so it can
|
|
6
|
+
* only describe a decision the gate actually made; a check that never ran has
|
|
7
|
+
* no sentence. The machine reasons are the contract both implementations pin
|
|
8
|
+
* word for word, which is what makes them safe to read back here. When a
|
|
9
|
+
* reason does not match what a check emits, the sentence quotes it verbatim
|
|
10
|
+
* rather than guessing.
|
|
11
|
+
*
|
|
12
|
+
* `decision.reason` is unchanged: the plain sentence and the machine reason
|
|
13
|
+
* sit side by side and neither replaces the other.
|
|
14
|
+
*/
|
|
15
|
+
import type { Decision, TraceEntry } from "./types.js";
|
|
16
|
+
/** Fields a parser pulled out of a machine reason; a template reads them. */
|
|
17
|
+
export type SentenceFacts = Record<string, string | undefined>;
|
|
18
|
+
export type SentenceTemplate = (facts: SentenceFacts) => string;
|
|
19
|
+
/**
|
|
20
|
+
* Every sentence the renderer can emit, as one flat template table. A new
|
|
21
|
+
* language is a `Partial<Sentences>` merged over English, so a partial
|
|
22
|
+
* translation still renders. Keys are `check.outcome` plus the summary,
|
|
23
|
+
* note, gate and fallback entries.
|
|
24
|
+
*/
|
|
25
|
+
export interface Sentences {
|
|
26
|
+
/** A catalog may define keys beyond this table; explain() only reads the named ones. */
|
|
27
|
+
[key: string]: SentenceTemplate | undefined;
|
|
28
|
+
/** Whole-decision line for a reject or defer. `until` is set when the trace names the instant the hold lifts. */
|
|
29
|
+
"summary.held": SentenceTemplate;
|
|
30
|
+
/** Whole-decision line for an allowed decision. `notes` is pre-joined, leading semicolon included. */
|
|
31
|
+
"summary.allowed": SentenceTemplate;
|
|
32
|
+
/** Allowed with an empty trace. */
|
|
33
|
+
"summary.allowedEmpty": SentenceTemplate;
|
|
34
|
+
/** Not allowed but no stopping entry found; the decision was built by hand. */
|
|
35
|
+
"summary.heldUnknown": SentenceTemplate;
|
|
36
|
+
"note.deliverAt": SentenceTemplate;
|
|
37
|
+
"note.nearLimit": SentenceTemplate;
|
|
38
|
+
"note.shadowed": SentenceTemplate;
|
|
39
|
+
/** Wraps a shadowed stop: `body` is the clause the check would have produced. */
|
|
40
|
+
"entry.shadow": SentenceTemplate;
|
|
41
|
+
"killSwitch.pass": SentenceTemplate;
|
|
42
|
+
"killSwitch.reject": SentenceTemplate;
|
|
43
|
+
"consent.pass": SentenceTemplate;
|
|
44
|
+
"consent.reject": SentenceTemplate;
|
|
45
|
+
"enabled.pass": SentenceTemplate;
|
|
46
|
+
"enabled.reject": SentenceTemplate;
|
|
47
|
+
"mode.pass": SentenceTemplate;
|
|
48
|
+
"mode.reject": SentenceTemplate;
|
|
49
|
+
"snooze.pass": SentenceTemplate;
|
|
50
|
+
"snooze.reject": SentenceTemplate;
|
|
51
|
+
"mute.pass": SentenceTemplate;
|
|
52
|
+
"mute.reject": SentenceTemplate;
|
|
53
|
+
"intensity.pass": SentenceTemplate;
|
|
54
|
+
"intensity.reject": SentenceTemplate;
|
|
55
|
+
"quietHours.pass": SentenceTemplate;
|
|
56
|
+
"quietHours.reject": SentenceTemplate;
|
|
57
|
+
"quietHours.skip": SentenceTemplate;
|
|
58
|
+
"trustRamp.pass": SentenceTemplate;
|
|
59
|
+
"trustRamp.reject": SentenceTemplate;
|
|
60
|
+
"trustRamp.skip": SentenceTemplate;
|
|
61
|
+
"dismissalCooldown.pass": SentenceTemplate;
|
|
62
|
+
"dismissalCooldown.reject": SentenceTemplate;
|
|
63
|
+
"adaptiveTiming.pass": SentenceTemplate;
|
|
64
|
+
"adaptiveTiming.adjust": SentenceTemplate;
|
|
65
|
+
"dedupe.pass": SentenceTemplate;
|
|
66
|
+
"dedupe.reject": SentenceTemplate;
|
|
67
|
+
"dedupe.skip": SentenceTemplate;
|
|
68
|
+
"dailyBudget.pass": SentenceTemplate;
|
|
69
|
+
"dailyBudget.reject": SentenceTemplate;
|
|
70
|
+
"weeklyBudget.pass": SentenceTemplate;
|
|
71
|
+
"weeklyBudget.reject": SentenceTemplate;
|
|
72
|
+
"monthlyBudget.pass": SentenceTemplate;
|
|
73
|
+
"monthlyBudget.reject": SentenceTemplate;
|
|
74
|
+
"windowBudget.pass": SentenceTemplate;
|
|
75
|
+
"windowBudget.reject": SentenceTemplate;
|
|
76
|
+
"rateLimit.pass": SentenceTemplate;
|
|
77
|
+
"rateLimit.reject": SentenceTemplate;
|
|
78
|
+
"budget.pass": SentenceTemplate;
|
|
79
|
+
"budget.reject": SentenceTemplate;
|
|
80
|
+
"utilityFloor.pass": SentenceTemplate;
|
|
81
|
+
"utilityFloor.reject": SentenceTemplate;
|
|
82
|
+
"utilityFloor.skip": SentenceTemplate;
|
|
83
|
+
"boundedDeferral.pass": SentenceTemplate;
|
|
84
|
+
"boundedDeferral.adjust": SentenceTemplate;
|
|
85
|
+
"allowedWindow.pass": SentenceTemplate;
|
|
86
|
+
"allowedWindow.reject": SentenceTemplate;
|
|
87
|
+
"allowedWindow.skip": SentenceTemplate;
|
|
88
|
+
"requiresConsent.pass": SentenceTemplate;
|
|
89
|
+
"requiresConsent.reject": SentenceTemplate;
|
|
90
|
+
"requiresConsent.skip": SentenceTemplate;
|
|
91
|
+
"recentInteraction.pass": SentenceTemplate;
|
|
92
|
+
"recentInteraction.reject": SentenceTemplate;
|
|
93
|
+
/** The gate's own trace reasons, not any check's: store failures and ignored non-rejecting stops. */
|
|
94
|
+
"gate.failOpen": SentenceTemplate;
|
|
95
|
+
"gate.failClosed": SentenceTemplate;
|
|
96
|
+
"gate.nonRejecting": SentenceTemplate;
|
|
97
|
+
/** Unknown check ids and reasons that match nothing: quote the trace, invent nothing. */
|
|
98
|
+
"fallback.stop": SentenceTemplate;
|
|
99
|
+
"fallback.pass": SentenceTemplate;
|
|
100
|
+
"fallback.skip": SentenceTemplate;
|
|
101
|
+
"fallback.adjust": SentenceTemplate;
|
|
102
|
+
}
|
|
103
|
+
/** One trace entry rendered: which check, what it said, and the sentence for it. */
|
|
104
|
+
export interface CheckSentence {
|
|
105
|
+
id: string;
|
|
106
|
+
outcome: TraceEntry["outcome"];
|
|
107
|
+
shadow?: boolean;
|
|
108
|
+
sentence: string;
|
|
109
|
+
}
|
|
110
|
+
export interface Explanation {
|
|
111
|
+
/** The decision as one sentence: "held until 08:00 because ...", "allowed because ...". */
|
|
112
|
+
summary: string;
|
|
113
|
+
/** One sentence per check that ran, in the order it ran. */
|
|
114
|
+
checks: CheckSentence[];
|
|
115
|
+
}
|
|
116
|
+
export interface ExplainOptions {
|
|
117
|
+
/**
|
|
118
|
+
* Language of the sentences. Only "en" ships with the package; any other
|
|
119
|
+
* code needs a matching entry in `catalogs`, which is merged over English
|
|
120
|
+
* so a partial translation still renders.
|
|
121
|
+
*/
|
|
122
|
+
language?: string;
|
|
123
|
+
catalogs?: Record<string, Partial<Sentences>>;
|
|
124
|
+
}
|
|
125
|
+
export declare const en: Sentences;
|
|
126
|
+
/**
|
|
127
|
+
* Render a decision as sentences. Deterministic: the same decision always
|
|
128
|
+
* produces the same explanation, and nothing outside the decision is read.
|
|
129
|
+
*/
|
|
130
|
+
export declare function explain(decision: Decision, options?: ExplainOptions): Explanation;
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/* English ---------------------------------------------------------------- */
|
|
2
|
+
const plural = (n, unit) => `${n} ${unit}${n === 1 ? "" : "s"}`;
|
|
3
|
+
/** "1d" to "within the last day", "24h" to "within the last 24 hours": the dedupe window, said long. */
|
|
4
|
+
const spanWords = (label) => {
|
|
5
|
+
const m = /^(\d+)([dhms])$/.exec(label);
|
|
6
|
+
if (!m)
|
|
7
|
+
return `within the last ${label}`;
|
|
8
|
+
const unit = { d: "day", h: "hour", m: "minute", s: "second" }[m[2]];
|
|
9
|
+
const n = Number(m[1]);
|
|
10
|
+
return n === 1 ? `within the last ${unit}` : `within the last ${plural(n, unit)}`;
|
|
11
|
+
};
|
|
12
|
+
/** "86400" to "day", "7200" to "2 hours": a rate-limit period in seconds, said long. */
|
|
13
|
+
const secondsWords = (text) => {
|
|
14
|
+
const n = Number(text);
|
|
15
|
+
if (!Number.isFinite(n))
|
|
16
|
+
return `${text} seconds`;
|
|
17
|
+
// "per hour", not "per 1 hour": the same singular spanWords already says as "the last day".
|
|
18
|
+
const period = (count, unit) => (count === 1 ? unit : plural(count, unit));
|
|
19
|
+
if (n % 86400 === 0)
|
|
20
|
+
return period(n / 86400, "day");
|
|
21
|
+
if (n % 3600 === 0)
|
|
22
|
+
return period(n / 3600, "hour");
|
|
23
|
+
if (n % 60 === 0)
|
|
24
|
+
return period(n / 60, "minute");
|
|
25
|
+
return period(n, "second");
|
|
26
|
+
};
|
|
27
|
+
/** The spend note is true only when the pass really read the counter: a bypassed budget returns a bare pass, a near-limit pass cannot be a bypass. */
|
|
28
|
+
const budgetPass = (label) => (f) => f.used !== undefined
|
|
29
|
+
? `the ${label} had room, but only just: ${f.used} of ${f.limit} already used; the unit is spent when the message actually goes out`
|
|
30
|
+
: `the ${label} did not stop it`;
|
|
31
|
+
const budgetReject = (label) => (f) => `the ${label} of ${f.limit} was already spent (${f.used} used)`;
|
|
32
|
+
export const en = {
|
|
33
|
+
"summary.held": (f) => `held${f.until ? ` until ${f.until}` : ""} because ${f.clause}`,
|
|
34
|
+
"summary.allowed": (f) => `allowed at ${f.at} because no check stopped it${f.notes ?? ""}`,
|
|
35
|
+
"summary.allowedEmpty": () => "allowed; no checks ran",
|
|
36
|
+
"summary.heldUnknown": () => "held; the trace does not name the check that stopped it",
|
|
37
|
+
"note.deliverAt": (f) => `delivery waits until ${f.at}`,
|
|
38
|
+
"note.nearLimit": (f) => `${f.check} was close to its limit (${f.used} of ${f.limit} used)`,
|
|
39
|
+
"note.shadowed": (f) => `${f.check} would have stopped it but ran in shadow mode`,
|
|
40
|
+
"entry.shadow": (f) => `it would have stopped the message (${f.body}), but the check ran in shadow mode so evaluation continued`,
|
|
41
|
+
"killSwitch.pass": () => "the kill switch was off",
|
|
42
|
+
"killSwitch.reject": () => "the kill switch is on, which stops every message",
|
|
43
|
+
"consent.pass": () => "the user has agreed to proactive messages",
|
|
44
|
+
"consent.reject": () => "the user has not agreed to proactive messages",
|
|
45
|
+
"enabled.pass": () => "proactive messages are switched on for this profile",
|
|
46
|
+
"enabled.reject": () => "proactive messages are switched off on this user's profile",
|
|
47
|
+
"mode.pass": () => "the user's operating mode did not block it",
|
|
48
|
+
"mode.reject": (f) => `the user's operating mode is "${f.mode}", which does not allow proactive messages`,
|
|
49
|
+
"snooze.pass": () => "no snooze was in effect",
|
|
50
|
+
"snooze.reject": (f) => `the user has snoozed the assistant until ${f.until}`,
|
|
51
|
+
"mute.pass": () => "this type of message is not muted",
|
|
52
|
+
"mute.reject": (f) => `the user has muted "${f.type}" messages`,
|
|
53
|
+
"intensity.pass": () => "the message's priority satisfied the user's intensity setting",
|
|
54
|
+
"intensity.reject": (f) => `the message was ${f.priority} priority, and the user's "${f.level}" intensity setting allows only ${f.floor} and above`,
|
|
55
|
+
"quietHours.pass": () => "quiet hours did not block it",
|
|
56
|
+
"quietHours.reject": (f) => `the user's quiet hours run ${f.start} to ${f.end} ${f.tz}${f.from ? `, a window belonging to ${f.from},` : ""} and ${f.priority} priority is below the ${f.floor} floor needed to override them`,
|
|
57
|
+
"quietHours.skip": () => "the user has quiet hours but no time zone, so the check could not run",
|
|
58
|
+
"trustRamp.pass": () => "the new-user trust period did not block it",
|
|
59
|
+
"trustRamp.reject": (f) => `the user is on day ${f.day} of a ${f.days}-day trust period for new users, which allows only ${f.floor} priority and above, and the message was ${f.priority}`,
|
|
60
|
+
"trustRamp.skip": () => "the user's sign-up date is not on record, so the new-user trust period could not be checked",
|
|
61
|
+
"dismissalCooldown.pass": () => "the user has not dismissed this type enough to silence it",
|
|
62
|
+
"dismissalCooldown.reject": (f) => `the user has dismissed "${f.type}" messages ${f.count} times in ${f.within} days, so this type stays silent until ${f.until}`,
|
|
63
|
+
"adaptiveTiming.pass": () => "the timing check left the message as it was",
|
|
64
|
+
"adaptiveTiming.adjust": (f) => f.at && f.surfaces
|
|
65
|
+
? `delivery was moved to ${f.at} and narrowed to ${f.surfaces.split(",").join(", ")}`
|
|
66
|
+
: f.at
|
|
67
|
+
? `delivery was moved to ${f.at}`
|
|
68
|
+
: `delivery was narrowed to ${(f.surfaces ?? "").split(",").join(", ")}`,
|
|
69
|
+
"dedupe.pass": () => "this event had not already produced a message; the event is claimed when the message actually goes out",
|
|
70
|
+
"dedupe.reject": (f) => `the same event already produced a message ${spanWords(f.window ?? "")}`,
|
|
71
|
+
"dedupe.skip": () => "the candidate carried no event key, so duplicate detection could not run",
|
|
72
|
+
"dailyBudget.pass": budgetPass("daily budget"),
|
|
73
|
+
"dailyBudget.reject": budgetReject("user's daily budget"),
|
|
74
|
+
"weeklyBudget.pass": budgetPass("weekly budget"),
|
|
75
|
+
"weeklyBudget.reject": budgetReject("user's weekly budget"),
|
|
76
|
+
"monthlyBudget.pass": budgetPass("monthly budget"),
|
|
77
|
+
"monthlyBudget.reject": budgetReject("user's monthly budget"),
|
|
78
|
+
"windowBudget.pass": budgetPass("window budget"),
|
|
79
|
+
"windowBudget.reject": (f) => `the budget for the window opened by the user's last message was already spent (${f.used} of ${f.limit} used)`,
|
|
80
|
+
"rateLimit.pass": () => "the rate limit did not stop it",
|
|
81
|
+
"rateLimit.reject": (f) => `the rate limit of ${f.limit} messages per ${secondsWords(f.per ?? "")} was already reached (${f.used} used)`,
|
|
82
|
+
"budget.pass": (f) => `the budget had room, but only just: ${f.used} of ${f.limit} already used; the unit is spent when the message actually goes out`,
|
|
83
|
+
"budget.reject": (f) => `the ${f.label} was already used up (${f.used} of ${f.limit} used)`,
|
|
84
|
+
"utilityFloor.pass": () => "the estimated acceptance chance cleared the utility floor",
|
|
85
|
+
"utilityFloor.reject": (f) => `the estimated chance the user would accept this message was ${f.pAccept}, below the utility floor of ${f.tau}`,
|
|
86
|
+
"utilityFloor.skip": () => "the candidate carried no acceptance estimate, so the utility floor could not run",
|
|
87
|
+
"boundedDeferral.pass": () => "the user did not look busy",
|
|
88
|
+
"boundedDeferral.adjust": (f) => `the user looked busy, so delivery was deferred ${f.tStar} seconds to ${f.at}`,
|
|
89
|
+
"allowedWindow.pass": (f) => (f.name ? `the "${f.name}" allowed window did not block it` : "the allowed window did not block it"),
|
|
90
|
+
"allowedWindow.reject": (f) => `messages may only go out between ${f.start} and ${f.end} (${f.zone}), and the local time was outside that window`,
|
|
91
|
+
"allowedWindow.skip": () => "the user has no time zone, so the allowed window could not be checked",
|
|
92
|
+
"requiresConsent.pass": (f) => (f.name ? `the "${f.name}" consent the check needs was in place` : "the consent the check needs was in place"),
|
|
93
|
+
"requiresConsent.reject": (f) => `the user has not given the required "${f.name}" consent${f.start ? `, which applies between ${f.start} and ${f.end}` : ""}`,
|
|
94
|
+
"requiresConsent.skip": () => "the user has no time zone, so the hours this consent applies could not be checked",
|
|
95
|
+
"recentInteraction.pass": () => "the user had written to the assistant recently enough",
|
|
96
|
+
"recentInteraction.reject": (f) => f.age ? `the user's last message to the assistant was ${f.age} h ago, outside the ${f.within} h window` : "the user has never written to the assistant, and this rule allows messages only after they do",
|
|
97
|
+
"gate.failOpen": (f) => `the "${f.id}" check failed with "${f.error}", and the gate is set to let messages through when a check fails`,
|
|
98
|
+
"gate.failClosed": (f) => `the "${f.id}" check failed with "${f.error}", and the gate is set to stop messages when a check fails`,
|
|
99
|
+
"gate.nonRejecting": (f) => `the "${f.id}" check tried to ${f.kind} ("${f.reason}") but is marked non-rejecting, so the gate ignored it`,
|
|
100
|
+
"fallback.stop": (f) => `the "${f.id}" check stopped it: ${f.reason}`,
|
|
101
|
+
"fallback.pass": (f) => `the "${f.id}" check let it through${f.reason ? ` (${f.reason})` : ""}`,
|
|
102
|
+
"fallback.skip": (f) => `the "${f.id}" check did not weigh in: ${f.reason}`,
|
|
103
|
+
"fallback.adjust": (f) => `the "${f.id}" check adjusted it: ${f.reason}`,
|
|
104
|
+
};
|
|
105
|
+
const match = (pattern, keys) => (reason) => {
|
|
106
|
+
const m = pattern.exec(reason);
|
|
107
|
+
if (!m)
|
|
108
|
+
return null;
|
|
109
|
+
const facts = {};
|
|
110
|
+
keys.forEach((key, i) => {
|
|
111
|
+
const value = m[i + 1];
|
|
112
|
+
if (value !== undefined)
|
|
113
|
+
facts[key] = value;
|
|
114
|
+
});
|
|
115
|
+
return facts;
|
|
116
|
+
};
|
|
117
|
+
const fixed = (text) => (reason) => (reason === text ? {} : null);
|
|
118
|
+
const first = (...parsers) => (reason) => {
|
|
119
|
+
for (const parse of parsers) {
|
|
120
|
+
const facts = parse(reason);
|
|
121
|
+
if (facts)
|
|
122
|
+
return facts;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
};
|
|
126
|
+
const BUDGET_STOP = match(/^.* of (\d+) used \((\d+)\)$/, ["limit", "used"]);
|
|
127
|
+
const BUDGET_NEAR = match(/^(\d+) of (\d+) used$/, ["used", "limit"]);
|
|
128
|
+
const parseQuietHours = (reason) => {
|
|
129
|
+
const m = /^quiet hours (\S+) to (\S+)(?: \((\w+) (\S+)\))? (\S+); priority (\w+) is below the floor \((\w+)\)$/.exec(reason);
|
|
130
|
+
if (!m)
|
|
131
|
+
return null;
|
|
132
|
+
// holdUntil is the fact the summary headlines ("held until 08:00"); checks
|
|
133
|
+
// whose clause already says the instant (snooze, cooldown) leave it unset.
|
|
134
|
+
const facts = { start: m[1], end: m[2], tz: m[5], priority: m[6], floor: m[7], holdUntil: m[2] };
|
|
135
|
+
if (m[3] !== undefined)
|
|
136
|
+
facts.from = `${m[3]} ${m[4]}`;
|
|
137
|
+
return facts;
|
|
138
|
+
};
|
|
139
|
+
const PARSERS = {
|
|
140
|
+
killSwitch: { stop: fixed("engine kill switch is on") },
|
|
141
|
+
consent: { stop: fixed("user has not consented to proactive behaviour") },
|
|
142
|
+
enabled: { stop: fixed("proactive behaviour is disabled on this profile") },
|
|
143
|
+
mode: { stop: match(/^operating mode "(.+)" does not allow proactive messages$/, ["mode"]) },
|
|
144
|
+
snooze: { stop: match(/^snoozed until (.+)$/, ["until"]) },
|
|
145
|
+
mute: { stop: match(/^type "(.+)" is muted by the user$/, ["type"]) },
|
|
146
|
+
intensity: { stop: match(/^priority (\w+) is below the "(\w+)" intensity floor \((\w+)\)$/, ["priority", "level", "floor"]) },
|
|
147
|
+
quietHours: { stop: parseQuietHours, skip: fixed("quiet hours set but no timezone on the user; cannot evaluate") },
|
|
148
|
+
trustRamp: { stop: match(/^trust ramp: day (\d+) of (\S+), priority (\w+) is below (\w+)$/, ["day", "days", "priority", "floor"]), skip: fixed("no createdAt on the user; ramp cannot be evaluated") },
|
|
149
|
+
dismissalCooldown: { stop: match(/^(\d+) dismissals of "(.+)" in (\S+) days; silent until (.+)$/, ["count", "type", "within", "until"]) },
|
|
150
|
+
adaptiveTiming: { adjust: first(match(/^deliver at (\S+); surfaces (.+)$/, ["at", "surfaces"]), match(/^deliver at (\S+)$/, ["at"]), match(/^surfaces (.+)$/, ["surfaces"])) },
|
|
151
|
+
dedupe: { stop: match(/^already delivered within the last (.+)$/, ["window"]), skip: fixed("no dedupeKey on the candidate; deduplication cannot be evaluated") },
|
|
152
|
+
dailyBudget: { stop: BUDGET_STOP, passReason: BUDGET_NEAR },
|
|
153
|
+
weeklyBudget: { stop: BUDGET_STOP, passReason: BUDGET_NEAR },
|
|
154
|
+
monthlyBudget: { stop: BUDGET_STOP, passReason: BUDGET_NEAR },
|
|
155
|
+
windowBudget: { stop: BUDGET_STOP, passReason: BUDGET_NEAR },
|
|
156
|
+
rateLimit: { stop: match(/^rate limit (\d+) per (\d+) s of \d+ used \((\d+)\)$/, ["limit", "per", "used"]), passReason: BUDGET_NEAR },
|
|
157
|
+
utilityFloor: { stop: match(/^pAccept (\S+) < tau (\S+)$/, ["pAccept", "tau"]), skip: fixed("no pAccept on the candidate; utility floor cannot be evaluated") },
|
|
158
|
+
boundedDeferral: { adjust: match(/^user busy; deliver at (\S+) \(t\* (\d+) s\)$/, ["at", "tStar"]) },
|
|
159
|
+
allowedWindow: {
|
|
160
|
+
stop: match(/^outside the allowed window (\S+) to (\S+) (.+)$/, ["start", "end", "zone"]),
|
|
161
|
+
idFacts: (id) => (id.startsWith("window:") ? { name: id.slice("window:".length) } : {}),
|
|
162
|
+
skip: fixed("no timezone on the user; window cannot be evaluated"),
|
|
163
|
+
},
|
|
164
|
+
requiresConsent: {
|
|
165
|
+
stop: match(/^consent "(.+)" is missing(?: \(required (\S+) to (\S+)\))?$/, ["name", "start", "end"]),
|
|
166
|
+
idFacts: (id) => (id.startsWith("consent:") ? { name: id.slice("consent:".length) } : {}),
|
|
167
|
+
skip: fixed("no timezone on the user; consent window cannot be evaluated"),
|
|
168
|
+
},
|
|
169
|
+
recentInteraction: {
|
|
170
|
+
stop: first(fixed("no inbound message from the user on record"), match(/^last inbound message (\d+) h ago, window is (\S+) h$/, ["age", "within"])),
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
/** The ids the package itself emits: the fixed check ids, plus consent:<name>, rate:<limit>/<period>s and window:<name>. */
|
|
174
|
+
const keyForId = (id) => {
|
|
175
|
+
if (id in PARSERS)
|
|
176
|
+
return id;
|
|
177
|
+
if (id.startsWith("consent:"))
|
|
178
|
+
return "requiresConsent";
|
|
179
|
+
if (id.startsWith("rate:"))
|
|
180
|
+
return "rateLimit";
|
|
181
|
+
if (id.startsWith("window:"))
|
|
182
|
+
return "allowedWindow";
|
|
183
|
+
return undefined;
|
|
184
|
+
};
|
|
185
|
+
/** The gate's own trace entries: a store failure, or a non-rejecting check that tried to stop evaluation anyway. */
|
|
186
|
+
const GATE_ENTRIES = [
|
|
187
|
+
{ key: "gate.failOpen", parse: match(/^check threw \((.*)\); failing open$/, ["error"]) },
|
|
188
|
+
{ key: "gate.failClosed", parse: match(/^check threw \((.*)\); failing closed$/, ["error"]) },
|
|
189
|
+
{ key: "gate.nonRejecting", parse: match(/^non-rejecting check returned (\w+) \((.*)\); ignored$/, ["kind", "reason"]) },
|
|
190
|
+
];
|
|
191
|
+
/**
|
|
192
|
+
* Custom ids (a preset's "window:kakao", a caller's own wrapper) still emit the
|
|
193
|
+
* check's reason, so an unmatched id falls back to recognizing the reason's
|
|
194
|
+
* shape. Order is distinctiveness: each pattern names the check it sounds like.
|
|
195
|
+
*/
|
|
196
|
+
const STOP_SCAN = ["quietHours", "allowedWindow", "requiresConsent", "rateLimit", "dismissalCooldown", "snooze", "mute", "mode", "intensity", "trustRamp", "dedupe", "utilityFloor", "recentInteraction", "killSwitch", "consent", "enabled"];
|
|
197
|
+
const SKIP_SCAN = ["quietHours", "trustRamp", "dedupe", "utilityFloor", "allowedWindow", "requiresConsent"];
|
|
198
|
+
const ADJUST_SCAN = ["adaptiveTiming", "boundedDeferral"];
|
|
199
|
+
const BUDGET_LABELS = { "daily budget": "dailyBudget", "weekly budget": "weeklyBudget", "monthly budget": "monthlyBudget", "window budget": "windowBudget" };
|
|
200
|
+
/** A "label of N used (M)" reason from a budget whose id we do not know. */
|
|
201
|
+
const scanBudget = (reason) => {
|
|
202
|
+
const m = /^(.+) of (\d+) used \((\d+)\)$/.exec(reason);
|
|
203
|
+
if (!m)
|
|
204
|
+
return null;
|
|
205
|
+
const known = BUDGET_LABELS[m[1]];
|
|
206
|
+
const facts = { label: m[1], limit: m[2], used: m[3] };
|
|
207
|
+
return { key: known ?? "budget", facts };
|
|
208
|
+
};
|
|
209
|
+
const cap = (text) => (text ? text[0].toUpperCase() + text.slice(1) : text);
|
|
210
|
+
const sentenceOf = (fragment) => `${cap(fragment)}.`;
|
|
211
|
+
const t = (s, key, facts) => s[key]?.(facts) ?? en[key]?.(facts) ?? facts.reason ?? "";
|
|
212
|
+
/** The stopping entry's clause and, when the trace names it, the instant the hold lifts. */
|
|
213
|
+
function stopFacts(entry) {
|
|
214
|
+
const reason = entry.reason ?? "";
|
|
215
|
+
for (const g of GATE_ENTRIES) {
|
|
216
|
+
const facts = g.parse(reason);
|
|
217
|
+
if (facts)
|
|
218
|
+
return { template: g.key, facts: { id: entry.id, ...facts } };
|
|
219
|
+
}
|
|
220
|
+
const key = keyForId(entry.id);
|
|
221
|
+
if (key && PARSERS[key]?.stop) {
|
|
222
|
+
const facts = PARSERS[key].stop(reason);
|
|
223
|
+
if (facts)
|
|
224
|
+
return { template: `${key}.reject`, facts };
|
|
225
|
+
}
|
|
226
|
+
for (const k of STOP_SCAN) {
|
|
227
|
+
const facts = PARSERS[k]?.stop?.(reason);
|
|
228
|
+
if (facts)
|
|
229
|
+
return { template: `${k}.reject`, facts };
|
|
230
|
+
}
|
|
231
|
+
const budget = scanBudget(reason);
|
|
232
|
+
if (budget)
|
|
233
|
+
return { template: `${budget.key}.reject`, facts: budget.facts };
|
|
234
|
+
return { template: "fallback.stop", facts: { id: entry.id, reason } };
|
|
235
|
+
}
|
|
236
|
+
function entryBody(entry, s) {
|
|
237
|
+
const reason = entry.reason ?? "";
|
|
238
|
+
const key = keyForId(entry.id);
|
|
239
|
+
switch (entry.outcome) {
|
|
240
|
+
case "reject":
|
|
241
|
+
case "defer": {
|
|
242
|
+
const stop = stopFacts(entry);
|
|
243
|
+
return t(s, stop.template, stop.facts);
|
|
244
|
+
}
|
|
245
|
+
case "pass": {
|
|
246
|
+
if (reason) {
|
|
247
|
+
const facts = key ? PARSERS[key]?.passReason?.(reason) : BUDGET_NEAR(reason);
|
|
248
|
+
if (facts)
|
|
249
|
+
return t(s, `${key ?? "budget"}.pass`, facts);
|
|
250
|
+
return t(s, "fallback.pass", { id: entry.id, reason });
|
|
251
|
+
}
|
|
252
|
+
const facts = key ? (PARSERS[key]?.idFacts?.(entry.id) ?? {}) : {};
|
|
253
|
+
return t(s, key ? `${key}.pass` : "fallback.pass", { id: entry.id, ...facts });
|
|
254
|
+
}
|
|
255
|
+
case "skip": {
|
|
256
|
+
for (const g of GATE_ENTRIES) {
|
|
257
|
+
const facts = g.parse(reason);
|
|
258
|
+
if (facts)
|
|
259
|
+
return t(s, g.key, { id: entry.id, ...facts });
|
|
260
|
+
}
|
|
261
|
+
if (key && PARSERS[key]?.skip) {
|
|
262
|
+
const facts = PARSERS[key].skip(reason);
|
|
263
|
+
if (facts)
|
|
264
|
+
return t(s, `${key}.skip`, facts);
|
|
265
|
+
}
|
|
266
|
+
for (const k of SKIP_SCAN) {
|
|
267
|
+
const facts = PARSERS[k]?.skip?.(reason);
|
|
268
|
+
if (facts)
|
|
269
|
+
return t(s, `${k}.skip`, facts);
|
|
270
|
+
}
|
|
271
|
+
return t(s, "fallback.skip", { id: entry.id, reason });
|
|
272
|
+
}
|
|
273
|
+
case "adjust": {
|
|
274
|
+
if (key && PARSERS[key]?.adjust) {
|
|
275
|
+
const facts = PARSERS[key].adjust(reason);
|
|
276
|
+
if (facts)
|
|
277
|
+
return t(s, `${key}.adjust`, facts);
|
|
278
|
+
}
|
|
279
|
+
for (const k of ADJUST_SCAN) {
|
|
280
|
+
const facts = PARSERS[k]?.adjust?.(reason);
|
|
281
|
+
if (facts)
|
|
282
|
+
return t(s, `${k}.adjust`, facts);
|
|
283
|
+
}
|
|
284
|
+
return t(s, "fallback.adjust", { id: entry.id, reason });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function explainEntry(entry, s) {
|
|
289
|
+
const body = entryBody(entry, s);
|
|
290
|
+
// Shadow is only a would-have-stopped when the outcome could have stopped.
|
|
291
|
+
const shadowedStop = entry.shadow === true && (entry.outcome === "reject" || entry.outcome === "defer");
|
|
292
|
+
return {
|
|
293
|
+
id: entry.id,
|
|
294
|
+
outcome: entry.outcome,
|
|
295
|
+
...(entry.shadow ? { shadow: true } : {}),
|
|
296
|
+
sentence: sentenceOf(shadowedStop ? t(s, "entry.shadow", { body }) : body),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function summarize(decision, s) {
|
|
300
|
+
if (decision.allowed) {
|
|
301
|
+
if (!decision.trace.length)
|
|
302
|
+
return sentenceOf(t(s, "summary.allowedEmpty", {}));
|
|
303
|
+
const notes = [];
|
|
304
|
+
if (decision.deliverAt)
|
|
305
|
+
notes.push(t(s, "note.deliverAt", { at: decision.deliverAt.toISOString() }));
|
|
306
|
+
for (const n of decision.nearLimit)
|
|
307
|
+
notes.push(t(s, "note.nearLimit", { check: n.check, used: String(n.used), limit: String(n.limit) }));
|
|
308
|
+
for (const id of decision.shadowed)
|
|
309
|
+
notes.push(t(s, "note.shadowed", { check: id }));
|
|
310
|
+
return sentenceOf(t(s, "summary.allowed", { at: decision.evaluatedAt.toISOString(), notes: notes.length ? `; ${notes.join("; ")}` : "" }));
|
|
311
|
+
}
|
|
312
|
+
const stopId = decision.rejectedBy ?? decision.deferredBy;
|
|
313
|
+
const entry = stopId ? [...decision.trace].reverse().find((e) => e.id === stopId && (e.outcome === "reject" || e.outcome === "defer")) : undefined;
|
|
314
|
+
if (!entry)
|
|
315
|
+
return sentenceOf(t(s, "summary.heldUnknown", {}));
|
|
316
|
+
const stop = stopFacts(entry);
|
|
317
|
+
const clause = t(s, stop.template, stop.facts);
|
|
318
|
+
// For a deferral the hold ends at retryAt; when the clause already names the
|
|
319
|
+
// instant (snooze's reason is "snoozed until X"), naming it twice reads worse.
|
|
320
|
+
const clauseSaysWhen = stop.facts.until !== undefined;
|
|
321
|
+
const until = stop.facts.holdUntil ?? (!clauseSaysWhen && decision.deferredBy ? decision.retryAt?.toISOString() : undefined);
|
|
322
|
+
return sentenceOf(t(s, "summary.held", { clause, ...(until ? { until } : {}) }));
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Render a decision as sentences. Deterministic: the same decision always
|
|
326
|
+
* produces the same explanation, and nothing outside the decision is read.
|
|
327
|
+
*/
|
|
328
|
+
export function explain(decision, options = {}) {
|
|
329
|
+
const language = options.language ?? "en";
|
|
330
|
+
const overlay = options.catalogs?.[language];
|
|
331
|
+
if (language !== "en" && !overlay) {
|
|
332
|
+
throw new Error(`no sentence catalog for language "${language}"; pass one with the catalogs option`);
|
|
333
|
+
}
|
|
334
|
+
const s = { ...en, ...overlay };
|
|
335
|
+
return { summary: summarize(decision, s), checks: decision.trace.map((entry) => explainEntry(entry, s)) };
|
|
336
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { createGate } from "./gate.js";
|
|
2
2
|
export type { Gate, PolicyGateOptions } from "./gate.js";
|
|
3
|
+
export { explain, en } from "./explain.js";
|
|
4
|
+
export type { CheckSentence, Explanation, ExplainOptions, SentenceFacts, Sentences, SentenceTemplate } from "./explain.js";
|
|
3
5
|
export { compilePolicy, KNOWN_CHECKS } from "./policy.js";
|
|
4
6
|
export { presets } from "./presets.js";
|
|
5
7
|
export type { Preset } from "./presets.js";
|
package/dist/src/index.js
CHANGED
package/dist/src/stores.d.ts
CHANGED
|
@@ -40,10 +40,17 @@ export declare class SqliteStore implements Store {
|
|
|
40
40
|
private readonly database;
|
|
41
41
|
private readonly clock;
|
|
42
42
|
constructor(path: string, clock?: () => number);
|
|
43
|
+
/**
|
|
44
|
+
* A read prunes only the key it touches; a write first clears every row that
|
|
45
|
+
* has already expired, so a key nobody reads again does not live forever.
|
|
46
|
+
*/
|
|
47
|
+
private sweep;
|
|
43
48
|
private live;
|
|
44
49
|
get(key: string): Promise<string | null>;
|
|
45
50
|
set(key: string, value: string, ttlSeconds?: number): Promise<void>;
|
|
46
51
|
incr(key: string, ttlSeconds?: number): Promise<number>;
|
|
47
52
|
del(key: string): Promise<void>;
|
|
53
|
+
/** Test helper. */
|
|
54
|
+
size(): number;
|
|
48
55
|
close(): void;
|
|
49
56
|
}
|
package/dist/src/stores.js
CHANGED
|
@@ -78,6 +78,15 @@ export class SqliteStore {
|
|
|
78
78
|
this.database = new DatabaseSync(path);
|
|
79
79
|
this.clock = clock;
|
|
80
80
|
this.database.exec("CREATE TABLE IF NOT EXISTS proactive_gate_store (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL, expires_at INTEGER)");
|
|
81
|
+
// The partial index keeps a sweep proportional to the dead rows instead of a table scan.
|
|
82
|
+
this.database.exec("CREATE INDEX IF NOT EXISTS proactive_gate_store_expires_at ON proactive_gate_store (expires_at) WHERE expires_at IS NOT NULL");
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A read prunes only the key it touches; a write first clears every row that
|
|
86
|
+
* has already expired, so a key nobody reads again does not live forever.
|
|
87
|
+
*/
|
|
88
|
+
sweep(now) {
|
|
89
|
+
this.database.prepare("DELETE FROM proactive_gate_store WHERE expires_at IS NOT NULL AND expires_at <= ?").run(now);
|
|
81
90
|
}
|
|
82
91
|
live(key) {
|
|
83
92
|
const row = this.database.prepare("SELECT value, expires_at FROM proactive_gate_store WHERE key = ?").get(key);
|
|
@@ -93,7 +102,9 @@ export class SqliteStore {
|
|
|
93
102
|
return this.live(key)?.value ?? null;
|
|
94
103
|
}
|
|
95
104
|
async set(key, value, ttlSeconds) {
|
|
96
|
-
const
|
|
105
|
+
const now = this.clock();
|
|
106
|
+
const expiresAt = ttlSeconds ? now + ttlSeconds * 1000 : null;
|
|
107
|
+
this.sweep(now);
|
|
97
108
|
this.database
|
|
98
109
|
.prepare("INSERT INTO proactive_gate_store (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at")
|
|
99
110
|
.run(key, value, expiresAt);
|
|
@@ -101,6 +112,7 @@ export class SqliteStore {
|
|
|
101
112
|
async incr(key, ttlSeconds) {
|
|
102
113
|
const now = this.clock();
|
|
103
114
|
const expiresAt = ttlSeconds ? now + ttlSeconds * 1000 : null;
|
|
115
|
+
this.sweep(now);
|
|
104
116
|
const row = this.database
|
|
105
117
|
.prepare("INSERT INTO proactive_gate_store (key, value, expires_at) VALUES (?, '1', ?) ON CONFLICT(key) DO UPDATE SET value = CASE WHEN proactive_gate_store.expires_at IS NOT NULL AND proactive_gate_store.expires_at <= ? THEN '1' ELSE CAST(CAST(proactive_gate_store.value AS INTEGER) + 1 AS TEXT) END, expires_at = CASE WHEN proactive_gate_store.expires_at IS NOT NULL AND proactive_gate_store.expires_at <= ? THEN excluded.expires_at ELSE proactive_gate_store.expires_at END RETURNING value")
|
|
106
118
|
.get(key, expiresAt, now, now);
|
|
@@ -109,6 +121,11 @@ export class SqliteStore {
|
|
|
109
121
|
async del(key) {
|
|
110
122
|
this.database.prepare("DELETE FROM proactive_gate_store WHERE key = ?").run(key);
|
|
111
123
|
}
|
|
124
|
+
/** Test helper. */
|
|
125
|
+
size() {
|
|
126
|
+
const row = this.database.prepare("SELECT COUNT(*) AS n FROM proactive_gate_store").get();
|
|
127
|
+
return row.n;
|
|
128
|
+
}
|
|
112
129
|
close() {
|
|
113
130
|
this.database.close();
|
|
114
131
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proactive-gate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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 test/release.test.mjs test/examples.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/explain.test.js test/release.test.mjs test/examples.test.mjs test/example-imports.test.mjs test/naive.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",
|
|
@@ -59,7 +59,9 @@
|
|
|
59
59
|
"release-gate": "npm run build && node scripts/release-gate.mjs",
|
|
60
60
|
"trace-svg": "npm run build && node scripts/trace-svg.mjs",
|
|
61
61
|
"bench:compare": "npm run build && node bench/compare.mjs",
|
|
62
|
-
"conformance-table": "node scripts/conformance-table.mjs"
|
|
62
|
+
"conformance-table": "node scripts/conformance-table.mjs",
|
|
63
|
+
"explain-parity": "node scripts/explain-parity.mjs",
|
|
64
|
+
"og": "npm run build && node scripts/og-image.mjs"
|
|
63
65
|
},
|
|
64
66
|
"engines": {
|
|
65
67
|
"node": ">=20"
|