proactive-gate 0.3.1 → 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 CHANGED
@@ -252,6 +252,29 @@ a hook that throws is routed to `error` and never changes the decision. `example
252
252
  turns them into one span per check. Every decision has an `id`, and `commit` is idempotent on
253
253
  it: a retry after a timeout does not consume a second unit.
254
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
+
255
278
  ## Optional checks, fed by your own model
256
279
 
257
280
  Both ship off. They read numbers the caller puts on the candidate.
@@ -762,7 +785,7 @@ before. [@Aaqibhafeezkhan](https://github.com/Aaqibhafeezkhan) wrote `SqliteStor
762
785
  every release since, including the one you install today. @Aaqibhafeezkhan came back for a
763
786
  second one and wrote the store contract suite in [#24](https://github.com/Bubblegunn/proactive-gate/pull/24).
764
787
 
765
- 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).
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.
766
789
 
767
790
  ## Cite this
768
791
 
package/README.tr.md CHANGED
@@ -209,6 +209,31 @@ fırlatan bir kanca `error` kancasına yönlendirilir ve kararı asla değiştir
209
209
  `commit` bu id üzerinde tekrarlanabilir: zaman aşımından sonraki bir yeniden deneme ikinci
210
210
  bir birim tüketmez.
211
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
+
212
237
  ## İsteğe bağlı, kendi modelinizin beslediği kontroller
213
238
 
214
239
  İkisi de kapalı gelir; adayın üzerine çağıranın koyduğu sayıları okurlar.
@@ -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
+ }
@@ -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
@@ -1,4 +1,5 @@
1
1
  export { createGate } from "./gate.js";
2
+ export { explain, en } from "./explain.js";
2
3
  export { compilePolicy, KNOWN_CHECKS } from "./policy.js";
3
4
  export { presets } from "./presets.js";
4
5
  export { MemoryStore, RedisStore, SqliteStore } from "./stores.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proactive-gate",
3
- "version": "0.3.1",
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/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/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",
@@ -60,6 +60,7 @@
60
60
  "trace-svg": "npm run build && node scripts/trace-svg.mjs",
61
61
  "bench:compare": "npm run build && node bench/compare.mjs",
62
62
  "conformance-table": "node scripts/conformance-table.mjs",
63
+ "explain-parity": "node scripts/explain-parity.mjs",
63
64
  "og": "npm run build && node scripts/og-image.mjs"
64
65
  },
65
66
  "engines": {