proactive-gate 0.2.0 → 0.2.2

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
@@ -100,18 +100,59 @@ something returned false".
100
100
  | 5 | `snooze()` | `user.snoozedUntil` is in the future | global pause |
101
101
  | 6 | `mute()` | `candidate.type` is in `user.mutedTypes` | per-type mute |
102
102
  | 7 | `intensity()` | priority is below the user's intensity floor | low hears only high, normal hears normal and up, high hears everything |
103
- | 8 | `quietHours({ priorityFloor })` | inside the user's local quiet window | IANA time zone, window may cross midnight, bypassed at or above the floor |
103
+ | 8 | `quietHours({ priorityFloor })` | inside the user's local quiet window | IANA time zone, window may cross midnight, bypassed at or above the floor; one window every day or [a schedule per day](#quiet-hours-that-differ-by-day) |
104
104
  | 9 | `trustRamp({ days, minPriority })` | user is newer than `days` and priority is below the floor | the system is least calibrated exactly when the user is least forgiving |
105
105
  | 10 | `dismissalCooldown({ dismissals, withinDays, silenceDays })` | the user dismissed that type `dismissals` times in the window | fed by `gate.record(user, candidate, "dismissed")`; every further dismissal restarts the silence |
106
106
  | 11 | `adaptiveTiming({ nextGoodMoment, surfacesFor })` | never | non-rejecting: moves `deliverAt` or narrows surfaces; a check marked `nonRejecting` cannot reject even if it tries |
107
107
  | 12 | `dailyBudget({ limit, bypassPriority })` | the user's local-day counter is at the limit | `evaluate` reads, `commit` increments atomically and can still refuse |
108
108
 
109
+ ### Quiet hours that differ by day
110
+
111
+ A working week is not Monday to Friday everywhere, and a holiday is not a weekday at all.
112
+ `quietHours` takes a schedule as well as a single window:
113
+
114
+ ```ts
115
+ quietHours: {
116
+ default: { start: "22:00", end: "08:00" },
117
+ days: { fri: { start: "00:00", end: "23:59" }, sat: { start: "00:00", end: "23:59" }, sun: null },
118
+ dates: { "2026-12-25": { start: "00:00", end: "23:59" } },
119
+ }
120
+ ```
121
+
122
+ A date beats a weekday beats the default, and `null` means the day has no quiet hours, which is
123
+ how a working day is carved out of a default. A window belongs to the day it opens on, so one
124
+ that crosses midnight silences the next morning and the reason names the day it came from.
125
+
126
+ Two things this deliberately does not do. There is no bundled holiday calendar: the dates you
127
+ observe are yours to supply, and a bundled one goes stale without anyone noticing. And one row
128
+ cannot express more than 24 hours, so a Friday evening to Saturday evening silence is two rows,
129
+ `fri: 18:00 to 00:00` and `sat: 00:00 to 20:00`.
130
+
131
+ Passing a single window is unchanged and remains the common case; a schedule whose every day
132
+ resolves to the same window behaves identically to that window.
133
+
109
134
  `weeklyBudget({ limit, bypassPriority })` is the same shape keyed on the user's local ISO
110
135
  week; `defaultChecks({ weeklyLimit })` places it just before the daily one. Budgets are
111
136
  consumed in check order at commit, so when a weekly check passes and the daily one then
112
137
  refuses, that weekly unit is spent without a delivery. It only happens when two commits
113
138
  race after a shared evaluate.
114
139
 
140
+ ### Two limits you should know before you adopt this
141
+
142
+ Neither is a bug, and both are pinned by tests so a future change has to be deliberate.
143
+
144
+ **The week is the ISO week, so the weekly budget refills on Monday.** Where the working
145
+ week runs Sunday to Thursday, that refill lands one day in: a user who spends the budget on
146
+ Sunday has it back on Monday, with four working days still to run. Changing the key would
147
+ move every counter already in your store, so it is documented rather than quietly altered.
148
+ Pass your own budget check keyed how you like if the ISO week is wrong for your users.
149
+
150
+ **Quiet hours are a single window, the same on every day of the week.** A user carries one
151
+ `start` and one `end`, so a Friday window, a Shabbat window or a public holiday cannot be
152
+ expressed. The day of the week is never read. If you need one, write a check: it is an
153
+ object with an `id` and a `run`, it composes in the order you choose, and the trace will
154
+ show it firing beside the built-in ones.
155
+
115
156
  Order is a design decision and it should be visible. Consent has to come before
116
157
  everything. Quiet hours have to come before the budget, or a rejected candidate
117
158
  consumes a delivery it never made. Reorder freely; the trace will show what you did.
@@ -208,20 +249,67 @@ Both ship off. They read numbers the caller puts on the candidate.
208
249
 
209
250
  - `utilityFloor({ costFalseAlarm, costMissedHelp })` acts only when `candidate.pAccept` clears
210
251
  `tau = cFA / (cFA + pNeed * cFN)` (`pNeed` defaults to 1) and skips when there is no
211
- `pAccept`. This is Horvitz's expected-utility rule with the PRISM threshold.
252
+ `pAccept`. That threshold is the classical Bayes decision boundary: alerting costs
253
+ `(1 - p) * cFA`, silence costs `p * cFN`, so you alert when the first is the smaller.
254
+ The alerting application is [Horvitz, Jacobs and Hovel, "Attention-Sensitive Alerting",
255
+ UAI 1999](https://arxiv.org/abs/1301.6707), whose system is named Priorities.
212
256
  - `boundedDeferral({ lambda, interruptCost, staleness, boundSeconds })` never rejects. When
213
257
  `candidate.busy` is true it moves `deliverAt` to `now + t*`, with
214
258
  `t* = min(bound, lambda * interruptCost / (2 * staleness))`; the defaults give 116 seconds.
215
259
 
216
260
  Neither check ships a model, a cost or a probability. `costFalseAlarm`, `costMissedHelp`,
217
261
  `interruptCost` and `staleness` are yours to measure, and the package has no opinion about
218
- what an interruption costs your users. The rules come from Eric Horvitz's work on
219
- attention-sensitive alerting and bounded deferral; the field measurement people usually
220
- reach for is [Iqbal and Horvitz, "Disruption and recovery of computing tasks", CHI
262
+ what an interruption costs your users. The field measurement people usually reach for is
263
+ [Iqbal and Horvitz, "Disruption and recovery of computing tasks", CHI
221
264
  2007](https://erichorvitz.com/CHI_2007_Iqbal_Horvitz.pdf), which logged real users and put
222
265
  the return to a suspended task in the region of 11 to 16 minutes. The widely repeated "23
223
266
  minutes 15 seconds" figure is not from a peer-reviewed paper and is not used here.
224
267
 
268
+ `boundedDeferral` implements the derivation in [Achlioptas and Horvitz, "Principles of
269
+ Bounded Deferral for Balancing Information Awareness with
270
+ Interruption"](http://erichorvitz.com/Bounded_Deferral.pdf): expected cost is stationary
271
+ where `f'(t0) = lambda * c`, so a quadratic staleness `f(t) = s * t²` gives
272
+ `t* = lambda * c / (2 * s)`.
273
+
274
+ ## Which defaults are measured and which are ours
275
+
276
+ Every default here is either taken from a study, which is then named, or chosen by
277
+ judgement, which is then admitted. There is one of the first kind.
278
+
279
+ | default | where it comes from |
280
+ |---|---|
281
+ | `lambda = 1/43` in `boundedDeferral` | Measured. Achlioptas and Horvitz above: 113 Microsoft employees (42 program managers, 25 developers, 19 testers, 10 administrators, 9 managers, 4 in sales and marketing, 4 research scientists), three sequential business days between 10am and 4pm, 4,803 busy situations, mean busy session 43.12 s, standard deviation 51.79 s |
282
+ | `staleness = 0.0001`, `boundSeconds = 240` | Scale choices. Only the ratio `interruptCost / staleness` changes `t*`, so this pair is one way to write "a few minutes". Nothing fixes either number |
283
+ | `trustRamp` 7 days | Ours. No study sets it |
284
+ | `dismissalCooldown` 3 in 30 days buying 7 days | Ours. A dismissal is the clearest signal a user gives, so the shape is defensible; the three numbers are not from anywhere |
285
+ | `dailyBudget` 5 | Ours, in a supported direction. Pielot and Rello (below) cite an in-situ log study where participants received a median of 63.5 notifications a day, so a handful sits far below the ambient load. Nothing in that work says five |
286
+
287
+ The spread inside the one measured number is worth more than the number. The same paper's
288
+ two-subject analysis puts the mean time to a lower-cost state after an alert at 11 seconds
289
+ for one person and 101 seconds for the other, so the variation between two people is larger
290
+ than the default itself. Measure your own users before you trust it.
291
+
292
+ ### Deferring is supported; silence is not free
293
+
294
+ The strongest evidence that deferral works at all is [Okoshi, Tsubouchi and Tokuda,
295
+ "Real-world large-scale study on adaptive notification scheduling on smartphones",
296
+ *Pervasive and Mobile Computing* 50:1-24
297
+ (2018)](https://keio.elsevierpure.com/en/publications/real-world-large-scale-study-on-adaptive-notification-scheduling-/):
298
+ the Yahoo! JAPAN Android app, more than 680,000 users over three weeks, where holding a
299
+ notification until an interruptible moment was detected cut response time by 49.7 percent
300
+ against immediate delivery. That supports the direction. It says nothing about any window,
301
+ budget or cooldown in this package.
302
+
303
+ The counterweight belongs here too, because a gate that suppresses is not free. In [Pielot
304
+ and Rello, "Productive, Anxious, Lonely: 24 Hours Without Push Notifications", MobileHCI
305
+ 2017](https://arxiv.org/abs/1612.02314), 30 volunteers switched notifications off for a day.
306
+ They were less distracted, and they also worried about missing information, checked their
307
+ phones more often, and felt less connected to the people around them. Fifteen of the thirty
308
+ agreed they were afraid of missing something urgent. Three people approached for the study
309
+ refused outright, because their workplace expected them to be reachable. A silence your user
310
+ did not choose costs them something, and that cost does not appear in any trace this library
311
+ prints.
312
+
225
313
  ## Presets: platform quotas and legal limits, with sources
226
314
 
227
315
  ```ts
@@ -250,6 +338,25 @@ Each preset carries `sources` (the pages the numbers come from) and a `note` on
250
338
  out. Reviewable defaults, not legal advice: several official sources disagree with each other,
251
339
  and the note says which value was chosen and why.
252
340
 
341
+ **Read the scope before you reach for a legal preset.** Every instrument above regulates
342
+ *commercial* communication. `usTcpa`, `euEprivacy`, `krNetworkAct50` and `jpAntiSpamLaw` are
343
+ marketing rules, so they bind your message only when the message itself is commercial. A
344
+ reminder your user asked for is not advertising, and pulling in a marketing preset for it
345
+ imports a restriction the law never placed on you, which is its own kind of wrong answer.
346
+ Use them when the candidate is promotional; when it is not, the platform quotas and your own
347
+ quiet hours are the honest constraints.
348
+
349
+ That scope test is also why some jurisdictions people ask for are missing. Canada's CASL and
350
+ Australia's Spam Act 2003 set consent, identification and unsubscribe duties, and neither
351
+ carries a time-of-day rule at all. The Brazilian window quoted around the web comes from bill
352
+ PLS 48/2018, a proposal rather than enacted law, and it covers telemarketing calls. India is
353
+ the interesting one: the widely repeated "9am to 9pm" is not what the primary text says. The
354
+ Telecom Commercial Communications Customer Preference Regulations make time bands a
355
+ *preference the subscriber registers* with their access provider, alongside content category
356
+ and day type, not a fixed statutory quiet window, and the secondary sources that quote a
357
+ window disagree with each other about whether it starts at 09:00 or 10:00. A preset built on
358
+ that would encode a number no primary source states, so there is none.
359
+
253
360
  ## The budget is enforced at commit, not at evaluate
254
361
 
255
362
  Two instances can both evaluate a candidate for the same user, both see four of
@@ -390,6 +497,10 @@ preset) still refuses. Both are part of `npm run examples` and of the test suite
390
497
  pip install proactive-gate
391
498
  ```
392
499
 
500
+ To run an unreleased state, install from the repository instead: `pip install "proactive-gate @
501
+ git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`. The published release was
502
+ uploaded from a local build with a token, so unlike the npm package it carries no build provenance.
503
+
393
504
  ```python
394
505
  from proactive_gate import Gate
395
506
  gate = Gate.from_policy(policy) # the same policy.json
package/README.tr.md CHANGED
@@ -182,10 +182,67 @@ bir birim tüketmez.
182
182
 
183
183
  - `utilityFloor({ costFalseAlarm, costMissedHelp })` yalnızca `candidate.pAccept` değeri
184
184
  `tau = cFA / (cFA + pNeed * cFN)` eşiğini geçtiğinde konuşur (`pNeed` varsayılanı 1);
185
- `pAccept` yoksa atlar. Bu, Horvitz'in beklenen fayda kuralı ve PRISM eşiğidir.
185
+ `pAccept` yoksa atlar. Bu eşik klasik Bayes karar sınırıdır: konuşmanın maliyeti
186
+ `(1 - p) * cFA`, susmanın maliyeti `p * cFN`, hangisi küçükse o seçilir. Uyarı alanındaki
187
+ karşılığı [Horvitz, Jacobs ve Hovel, "Attention-Sensitive Alerting", UAI
188
+ 1999](https://arxiv.org/abs/1301.6707); o makaledeki sistemin adı Priorities.
186
189
  - `boundedDeferral({ lambda, interruptCost, staleness, boundSeconds })` asla reddetmez.
187
190
  `candidate.busy` doğruysa `deliverAt` değerini `now + t*` yapar;
188
191
  `t* = min(bound, lambda * interruptCost / (2 * staleness))`, varsayılanlar 116 saniye verir.
192
+ Türetim [Achlioptas ve Horvitz, "Principles of Bounded
193
+ Deferral"](http://erichorvitz.com/Bounded_Deferral.pdf) makalesinden.
194
+
195
+ ## Hangi varsayılan ölçüldü, hangisi bizim tercihimiz
196
+
197
+ Buradaki her varsayılan ya bir çalışmadan geliyor ve kaynağı yazılıyor, ya da bir kanaat ve
198
+ bunu söylüyoruz. Birinci türden tek bir tane var.
199
+
200
+ | varsayılan | nereden geliyor |
201
+ |---|---|
202
+ | `boundedDeferral` içindeki `lambda = 1/43` | Ölçüm. Yukarıdaki makale: 113 çalışan, üç ardışık iş günü, 10.00 ile 16.00 arası, 4.803 meşgul durum, ortalama meşguliyet süresi 43,12 saniye, standart sapma 51,79 saniye |
203
+ | `staleness = 0.0001`, `boundSeconds = 240` | Ölçek tercihi. `t*` yalnızca `interruptCost / staleness` oranına bağlı; bu çift "birkaç dakika" demenin bir yolu, iki sayıyı da sabitleyen bir bulgu yok |
204
+ | `trustRamp` 7 gün | Bizim. Hiçbir çalışma bu sayıyı vermiyor |
205
+ | `dismissalCooldown` 30 günde 3 kapatma, 7 gün sessizlik | Bizim. Kapatma, kullanıcının verdiği en net sinyal olduğu için biçim savunulabilir; üç sayı bize ait |
206
+ | `dailyBudget` 5 | Bizim, ama yönü destekli. Pielot ve Rello'nun aktardığı yerinde günlük kayıt çalışmasında katılımcılar günde ortanca 63,5 bildirim alıyor; bir avuç mesaj bunun çok altında. O çalışma "beş" demiyor |
207
+
208
+ Ölçülen tek sayının içindeki dağılım, sayının kendisinden değerli: aynı makalenin iki kişilik
209
+ çözümlemesinde uyarı sonrası düşük maliyetli duruma geçiş ortalaması birinde 11, diğerinde
210
+ 101 saniye. İki kişi arasındaki fark varsayılanın kendisinden büyük.
211
+
212
+ ### Ertelemenin dayanağı var, susmanın bedeli de var
213
+
214
+ Ertelemenin işe yaradığına dair en güçlü kanıt [Okoshi, Tsubouchi ve Tokuda, *Pervasive and
215
+ Mobile Computing* 50:1-24
216
+ (2018)](https://keio.elsevierpure.com/en/publications/real-world-large-scale-study-on-adaptive-notification-scheduling-/):
217
+ Yahoo! JAPAN Android uygulaması, 680.000'den fazla kullanıcı, üç hafta; bildirimi uygun ana
218
+ kadar bekletmek yanıt süresini yüzde 49,7 kısaltmış. Bu, yönü destekler; bu paketteki
219
+ hiçbir pencereyi, bütçeyi veya bekleme süresini desteklemez.
220
+
221
+ Karşı ağırlık da burada durmalı, çünkü susturan bir kapı bedelsiz değil. [Pielot ve Rello,
222
+ MobileHCI 2017](https://arxiv.org/abs/1612.02314) çalışmasında 30 gönüllü bir gün boyunca
223
+ bildirimleri kapatmış. Daha az dağılmışlar, ama aynı zamanda bir şeyi kaçırmaktan
224
+ endişelenmiş, telefonlarına daha sık bakmış ve çevrelerinden kopuk hissetmişler. Otuz kişiden
225
+ on beşi acil bir şeyi kaçırmaktan korktuğunu söylemiş. Çalışma için görüşülen üç kişi,
226
+ işyerinde sürekli ulaşılabilir olmaları beklendiği için katılmayı reddetmiş. Kullanıcının
227
+ seçmediği bir sessizliğin bir bedeli var ve o bedel bu kütüphanenin yazdığı hiçbir izde
228
+ görünmüyor.
229
+
230
+ ## Benimsemeden önce bilmeniz gereken iki sınır
231
+
232
+ İkisi de hata değil ve ikisi de testle sabitlendi, böylece ileride değişecekse bilerek değişir.
233
+
234
+ **Hafta, ISO haftasıdır; haftalık bütçe pazartesi yenilenir.** Çalışma haftası pazardan
235
+ perşembeye uzanan yerlerde bu yenilenme haftanın birinci gününe denk gelir: pazar günü
236
+ bütçesini harcayan bir kullanıcı pazartesi sabahı bütçesini geri alır ve önünde hâlâ dört
237
+ iş günü vardır. Anahtarı değiştirmek deponuzdaki bütün sayaçları kaydıracağı için bunu
238
+ sessizce değiştirmek yerine yazıyoruz. ISO haftası sizin kullanıcılarınız için yanlışsa
239
+ kendi bütçe kontrolünüzü istediğiniz anahtarla yazabilirsiniz.
240
+
241
+ **Sessiz saatler tek bir penceredir ve haftanın her günü aynıdır.** Kullanıcıda tek bir
242
+ `start` ve tek bir `end` vardır; cuma penceresi, Şabat penceresi veya resmî tatil
243
+ tanımlanamaz. Haftanın günü hiç okunmaz. Böyle bir kurala ihtiyacınız varsa kendi
244
+ kontrolünüzü yazın: `id` ve `run` taşıyan bir nesnedir, istediğiniz sırada dizilir ve izde
245
+ yerleşik kontrollerin yanında görünür.
189
246
 
190
247
  ## Hazır paketler: platform kotaları ve yasal sınırlar, kaynaklarıyla
191
248
 
@@ -215,6 +272,24 @@ Her paket `sources` (sayıların geldiği sayfalar) ve neyi dışarıda bırakt
215
272
  `note` taşır. Gözden geçirilebilir varsayılanlar, hukuki tavsiye değil: birkaç resmi kaynak
216
273
  birbiriyle çelişir ve not hangi değerin neden seçildiğini söyler.
217
274
 
275
+ **Yasal bir pakete uzanmadan önce kapsamını okuyun.** Yukarıdaki bütün düzenlemeler *ticari*
276
+ iletişimi düzenler. `usTcpa`, `euEprivacy`, `krNetworkAct50` ve `jpAntiSpamLaw` birer pazarlama
277
+ kuralıdır; yani mesajınızı ancak mesajın kendisi ticari olduğunda bağlar. Kullanıcının kendi
278
+ istediği bir hatırlatma reklam değildir ve onun için pazarlama paketi kullanmak, yasanın size
279
+ hiç koymadığı bir kısıtı kendi elinizle içeri almak olur. Aday promosyon niteliğindeyse
280
+ kullanın; değilse dürüst sınırlar platform kotaları ve kendi sessiz saatlerinizdir.
281
+
282
+ Bazı ülkelerin neden burada olmadığı da aynı kapsam sınavıyla açıklanır. Kanada'nın CASL'i ve
283
+ Avustralya'nın 2003 tarihli Spam Act'i rıza, gönderen kimliği ve abonelikten çıkma
284
+ yükümlülükleri getirir; ikisinde de saat kısıtı yoktur. İnternette dolaşan Brezilya penceresi
285
+ PLS 48/2018 sayılı kanun *teklifinden* gelir, yürürlükteki bir kanundan değil, ve
286
+ telefonla pazarlama aramalarını kapsar. Hindistan ilginç olanı: sıkça tekrarlanan "09.00-21.00"
287
+ birincil metinde yazmaz. TRAI düzenlemesi zaman bantlarını, içerik kategorisi ve gün tipiyle
288
+ birlikte, abonenin operatörüne *kaydettirdiği bir tercih* yapar; sabit bir yasal sessizlik
289
+ penceresi değildir. Üstelik pencereyi aktaran ikincil kaynaklar başlangıcın 09.00 mı 10.00 mı
290
+ olduğunda birbiriyle çelişir. Bunun üzerine kurulacak bir paket, hiçbir birincil kaynağın
291
+ yazmadığı bir sayıyı kodlardı; o yüzden yok.
292
+
218
293
  ## Adaptörler
219
294
 
220
295
  | alt yol | framework | kapı nerede durur |
@@ -234,6 +309,10 @@ kurmak gerekmez. Her biri kapının gerekçesiyle reddeder ve onayda bütçeyi t
234
309
  pip install proactive-gate
235
310
  ```
236
311
 
312
+ Yayınlanmamış bir durumu denemek için depodan kurulur: `pip install "proactive-gate @
313
+ git+https://github.com/Bubblegunn/proactive-gate#subdirectory=python"`. Yayınlanan sürüm yerel bir
314
+ derlemeden token ile yüklendi; npm paketinin aksine derleme kanıtı taşımıyor.
315
+
237
316
  `python/` sapan bir port değil, bir kardeştir: `spec/fixtures` altındaki her senaryoyu senkron
238
317
  `Gate` ve `AsyncGate` (Redis, `redis.asyncio` üzerinden) ile geçer; mypy strict, CI'da Python
239
318
  3.11 ve 3.13. Bkz. [`python/README.md`](python/README.md).
@@ -1,4 +1,4 @@
1
- import type { Check, CheckContext, Priority, Surface } from "./types.js";
1
+ import type { Check, CheckContext, Priority, QuietSchedule, QuietWindow, Surface, Weekday } from "./types.js";
2
2
  export declare const DAY_SECONDS: number;
3
3
  /** Local "HH:MM" and calendar day for an instant in an IANA zone, using Intl only. */
4
4
  export declare function localClock(now: Date, timezone: string): {
@@ -7,6 +7,30 @@ export declare function localClock(now: Date, timezone: string): {
7
7
  };
8
8
  /** True when `minutes` falls inside [start, end), where the window may cross midnight. */
9
9
  export declare function inWindow(minutes: number, start: number, end: number): boolean;
10
+ /**
11
+ * The weekday of a local calendar date, and the date before it.
12
+ *
13
+ * Both are pure calendar arithmetic on the "YYYY-MM-DD" that `localClock` already
14
+ * resolved through Intl, never arithmetic on an instant. That is what keeps zones
15
+ * with a 45-minute offset (Kathmandu, Chatham, Eucla) and every daylight-saving
16
+ * transition out of this: the offset was applied before we got here.
17
+ */
18
+ export declare function weekdayOf(day: string): Weekday;
19
+ export declare function dayBefore(day: string): string;
20
+ /** The window in force on one local date: a date beats a weekday beats the default. */
21
+ export declare function windowFor(quiet: QuietWindow | QuietSchedule, day: string): QuietWindow | null;
22
+ /**
23
+ * Whether a local time is inside quiet hours, and which day's window says so.
24
+ *
25
+ * A window that crosses midnight belongs to the day it opens on, so a time can be
26
+ * quiet because of yesterday: Friday 18:00 to 00:00 silences Saturday 00:00 too.
27
+ * With one window every day this reduces exactly to `inWindow`, which is why the
28
+ * single-window form keeps behaving as it did.
29
+ */
30
+ export declare function quietAt(quiet: QuietWindow | QuietSchedule, day: string, minutes: number): {
31
+ window: QuietWindow;
32
+ day: string;
33
+ } | null;
10
34
  /** A production hard-stop that silences every producer at once. */
11
35
  export declare function killSwitch(isOn: () => boolean | Promise<boolean>): Check;
12
36
  /** Consent comes before everything, or you have evaluated preferences for someone who never agreed. */
@@ -36,6 +60,9 @@ export declare function quietHours(options?: {
36
60
  * For the first `days` after sign-up the user hears from the system only at
37
61
  * or above `minPriority`. A proactive assistant is least calibrated exactly
38
62
  * when the user is least forgiving.
63
+ *
64
+ * Seven days is a judgement, not a finding. No study sets this number, and
65
+ * none of the literature the package cites speaks to it.
39
66
  */
40
67
  export declare function trustRamp(options?: {
41
68
  days?: number;
@@ -45,6 +72,10 @@ export declare function trustRamp(options?: {
45
72
  * When the user has dismissed `dismissals` candidates of a type within
46
73
  * `withinDays`, that type stays silent for `silenceDays`. Fed by
47
74
  * gate.record(userId, candidate, "dismissed").
75
+ *
76
+ * Three in thirty buying seven days is a judgement, not a finding. The shape
77
+ * is defensible, since a dismissal is the clearest signal a user gives; the
78
+ * three numbers are ours and no study sets them.
48
79
  */
49
80
  export declare function dismissalCooldown(options?: {
50
81
  dismissals?: number;
@@ -74,15 +105,36 @@ export interface BudgetOptions {
74
105
  export declare const budgetKey: (userId: string, now: Date, timezone?: string) => string;
75
106
  export declare const weeklyBudgetKey: (userId: string, now: Date, timezone?: string) => string;
76
107
  export declare const monthlyBudgetKey: (userId: string, now: Date, timezone?: string) => string;
77
- /** At most `limit` deliveries per user per local day. */
108
+ /**
109
+ * At most `limit` deliveries per user per local day.
110
+ *
111
+ * Five is a judgement, not a finding. The direction has support: Pielot and
112
+ * Rello, "Productive, Anxious, Lonely: 24 Hours Without Push Notifications",
113
+ * MobileHCI 2017 (https://arxiv.org/abs/1612.02314), cite an in-situ log study
114
+ * (Pielot, Church and de Oliveira, MobileHCI 2014) in which participants
115
+ * received a median of 63.5 notifications a day, so a handful is far below the
116
+ * ambient load. Nothing in that work says five.
117
+ */
78
118
  export declare function dailyBudget(options?: BudgetOptions): BudgetCheck;
79
- /** At most `limit` deliveries per user per local ISO week. */
119
+ /**
120
+ * At most `limit` deliveries per user per local ISO week.
121
+ *
122
+ * The week is the ISO week, so the counter resets on Monday morning in the
123
+ * user's zone. For a Sunday-to-Thursday working week that reset lands
124
+ * mid-week. Documented rather than fixed; changing it would move every
125
+ * existing key.
126
+ */
80
127
  export declare function weeklyBudget(options?: BudgetOptions): BudgetCheck;
81
128
  /** At most `limit` deliveries per user per local calendar month. */
82
129
  export declare function monthlyBudget(options?: BudgetOptions): BudgetCheck;
83
130
  /**
84
- * Horvitz's expected-utility rule with the PRISM threshold: act only when the
85
- * caller's estimate of acceptance clears tau = cFA / (cFA + pNeed * cFN).
131
+ * Expected-utility alerting: act only when the caller's estimate of acceptance
132
+ * clears tau = cFA / (cFA + pNeed * cFN). That threshold is the classical Bayes
133
+ * decision boundary between the cost of alerting when the user did not want it,
134
+ * (1 - p) * cFA, and the cost of staying silent when they did, p * cFN.
135
+ * The alerting application is Horvitz, Jacobs and Hovel, "Attention-Sensitive
136
+ * Alerting", UAI 1999 (https://arxiv.org/abs/1301.6707); the system in that
137
+ * paper is named Priorities.
86
138
  * `candidate.pAccept` and `candidate.pNeed` come from the caller's own model.
87
139
  */
88
140
  export declare function utilityFloor(options: {
@@ -90,10 +142,29 @@ export declare function utilityFloor(options: {
90
142
  costMissedHelp: number;
91
143
  }): Check;
92
144
  /**
93
- * Bounded deferral (Horvitz): when the user is busy, wait t* = min(bound,
145
+ * Bounded deferral: when the user is busy, wait t* = min(bound,
94
146
  * lambda * interruptCost / (2 * staleness)), the optimum of a quadratic
95
147
  * staleness loss against the cost of interrupting a busy person, with the
96
148
  * user becoming free at rate lambda. Never rejects; only moves deliverAt.
149
+ *
150
+ * The derivation is Achlioptas and Horvitz, "Principles of Bounded Deferral
151
+ * for Balancing Information Awareness with Interruption", Microsoft Research
152
+ * (http://erichorvitz.com/Bounded_Deferral.pdf): the expected cost is
153
+ * stationary where f'(t0) = lambda * c with f''(t0) > 0, so a quadratic
154
+ * staleness f(t) = s * t^2 gives t* = lambda * c / (2 * s).
155
+ *
156
+ * `lambda` defaults to 1/43 from the same paper's field study: 113 Microsoft
157
+ * employees (42 program managers, 25 developers, 19 testers, 10 administrators,
158
+ * 9 managers, 4 in sales and marketing, 4 research scientists), three
159
+ * sequential business days between 10am and 4pm, 4,803 busy situations, mean
160
+ * busy-session duration 43.12 s with a standard deviation of 51.79 s. That
161
+ * spread matters: the same paper's two-subject Interruption Workbench analysis
162
+ * puts the mean time to a lower-cost state after an alert at 11 s for one
163
+ * person and 101 s for the other. Measure your own users before trusting it.
164
+ *
165
+ * `staleness` and `boundSeconds` are scale choices, not findings. Only the
166
+ * ratio interruptCost / staleness affects t*, so the pair below is one way to
167
+ * express "a few minutes"; nothing in the literature fixes either number.
97
168
  */
98
169
  export declare function boundedDeferral(options?: {
99
170
  lambda?: number;
@@ -39,6 +39,61 @@ export function inWindow(minutes, start, end) {
39
39
  return start < end ? minutes >= start && minutes < end : minutes >= start || minutes < end;
40
40
  }
41
41
  const localDay = (now, timezone) => (timezone ? localClock(now, timezone).day : now.toISOString().slice(0, 10));
42
+ const WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
43
+ /**
44
+ * The weekday of a local calendar date, and the date before it.
45
+ *
46
+ * Both are pure calendar arithmetic on the "YYYY-MM-DD" that `localClock` already
47
+ * resolved through Intl, never arithmetic on an instant. That is what keeps zones
48
+ * with a 45-minute offset (Kathmandu, Chatham, Eucla) and every daylight-saving
49
+ * transition out of this: the offset was applied before we got here.
50
+ */
51
+ export function weekdayOf(day) {
52
+ const [y, m, d] = day.split("-").map(Number);
53
+ return WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()];
54
+ }
55
+ export function dayBefore(day) {
56
+ const [y, m, d] = day.split("-").map(Number);
57
+ return new Date(Date.UTC(y, m - 1, d - 1)).toISOString().slice(0, 10);
58
+ }
59
+ const isSchedule = (q) => !("start" in q);
60
+ /** The window in force on one local date: a date beats a weekday beats the default. */
61
+ export function windowFor(quiet, day) {
62
+ if (!isSchedule(quiet))
63
+ return quiet;
64
+ const byDate = quiet.dates?.[day];
65
+ if (byDate !== undefined)
66
+ return byDate;
67
+ const byDay = quiet.days?.[weekdayOf(day)];
68
+ if (byDay !== undefined)
69
+ return byDay;
70
+ return quiet.default ?? null;
71
+ }
72
+ /**
73
+ * Whether a local time is inside quiet hours, and which day's window says so.
74
+ *
75
+ * A window that crosses midnight belongs to the day it opens on, so a time can be
76
+ * quiet because of yesterday: Friday 18:00 to 00:00 silences Saturday 00:00 too.
77
+ * With one window every day this reduces exactly to `inWindow`, which is why the
78
+ * single-window form keeps behaving as it did.
79
+ */
80
+ export function quietAt(quiet, day, minutes) {
81
+ const today = windowFor(quiet, day);
82
+ if (today) {
83
+ const start = parseHHMM(today.start);
84
+ const end = parseHHMM(today.end);
85
+ if (start !== end && (start < end ? minutes >= start && minutes < end : minutes >= start))
86
+ return { window: today, day };
87
+ }
88
+ const yesterday = windowFor(quiet, dayBefore(day));
89
+ if (yesterday) {
90
+ const start = parseHHMM(yesterday.start);
91
+ const end = parseHHMM(yesterday.end);
92
+ if (start > end && minutes < end)
93
+ return { window: yesterday, day: dayBefore(day) };
94
+ }
95
+ return null;
96
+ }
42
97
  /* ------------------------------------------------------------------------ */
43
98
  /* The checks, in the order LILA runs them. Compose your own order freely. */
44
99
  /* ------------------------------------------------------------------------ */
@@ -117,14 +172,16 @@ export function quietHours(options = {}) {
117
172
  return pass;
118
173
  if (!user.timezone)
119
174
  return skip("quiet hours set but no timezone on the user; cannot evaluate");
120
- const { minutes } = localClock(now, user.timezone);
121
- const start = parseHHMM(user.quietHours.start);
122
- const end = parseHHMM(user.quietHours.end);
123
- if (!inWindow(minutes, start, end))
175
+ const { minutes, day } = localClock(now, user.timezone);
176
+ const hit = quietAt(user.quietHours, day, minutes);
177
+ if (!hit)
124
178
  return pass;
125
179
  if (atLeast(priority, floor))
126
180
  return pass;
127
- return reject(`quiet hours ${user.quietHours.start} to ${user.quietHours.end} ${user.timezone}; priority ${priority} is below the floor (${floor})`);
181
+ // Name the day the window came from: when it crossed midnight the reason is
182
+ // yesterday's setting, and a reader looking at today's would not find it.
183
+ const whose = hit.day === day ? "" : ` (${weekdayOf(hit.day)} ${hit.day})`;
184
+ return reject(`quiet hours ${hit.window.start} to ${hit.window.end}${whose} ${user.timezone}; priority ${priority} is below the floor (${floor})`);
128
185
  },
129
186
  };
130
187
  }
@@ -132,6 +189,9 @@ export function quietHours(options = {}) {
132
189
  * For the first `days` after sign-up the user hears from the system only at
133
190
  * or above `minPriority`. A proactive assistant is least calibrated exactly
134
191
  * when the user is least forgiving.
192
+ *
193
+ * Seven days is a judgement, not a finding. No study sets this number, and
194
+ * none of the literature the package cites speaks to it.
135
195
  */
136
196
  export function trustRamp(options = {}) {
137
197
  const days = options.days ?? 7;
@@ -153,6 +213,10 @@ export function trustRamp(options = {}) {
153
213
  * When the user has dismissed `dismissals` candidates of a type within
154
214
  * `withinDays`, that type stays silent for `silenceDays`. Fed by
155
215
  * gate.record(userId, candidate, "dismissed").
216
+ *
217
+ * Three in thirty buying seven days is a judgement, not a finding. The shape
218
+ * is defensible, since a dismissal is the clearest signal a user gives; the
219
+ * three numbers are ours and no study sets them.
156
220
  */
157
221
  export function dismissalCooldown(options = {}) {
158
222
  const n = options.dismissals ?? 3;
@@ -235,11 +299,27 @@ const isoWeekKey = (day) => {
235
299
  };
236
300
  export const weeklyBudgetKey = (userId, now, timezone) => `weeklyBudget:${userId}:${isoWeekKey(localDay(now, timezone))}`;
237
301
  export const monthlyBudgetKey = (userId, now, timezone) => `monthlyBudget:${userId}:${localDay(now, timezone).slice(0, 7)}`;
238
- /** At most `limit` deliveries per user per local day. */
302
+ /**
303
+ * At most `limit` deliveries per user per local day.
304
+ *
305
+ * Five is a judgement, not a finding. The direction has support: Pielot and
306
+ * Rello, "Productive, Anxious, Lonely: 24 Hours Without Push Notifications",
307
+ * MobileHCI 2017 (https://arxiv.org/abs/1612.02314), cite an in-situ log study
308
+ * (Pielot, Church and de Oliveira, MobileHCI 2014) in which participants
309
+ * received a median of 63.5 notifications a day, so a handful is far below the
310
+ * ambient load. Nothing in that work says five.
311
+ */
239
312
  export function dailyBudget(options = {}) {
240
313
  return budget({ id: "dailyBudget", label: "daily budget", defaultLimit: 5, keyFor: ({ user, now }) => budgetKey(user.id, now, user.timezone), ttlSeconds: 2 * DAY_SECONDS }, options);
241
314
  }
242
- /** At most `limit` deliveries per user per local ISO week. */
315
+ /**
316
+ * At most `limit` deliveries per user per local ISO week.
317
+ *
318
+ * The week is the ISO week, so the counter resets on Monday morning in the
319
+ * user's zone. For a Sunday-to-Thursday working week that reset lands
320
+ * mid-week. Documented rather than fixed; changing it would move every
321
+ * existing key.
322
+ */
243
323
  export function weeklyBudget(options = {}) {
244
324
  return budget({ id: "weeklyBudget", label: "weekly budget", defaultLimit: 20, keyFor: ({ user, now }) => weeklyBudgetKey(user.id, now, user.timezone), ttlSeconds: 8 * DAY_SECONDS }, options);
245
325
  }
@@ -251,8 +331,13 @@ export function monthlyBudget(options = {}) {
251
331
  /* Optional, caller-fed checks. Off by default; the package ships no model. */
252
332
  /* ------------------------------------------------------------------------ */
253
333
  /**
254
- * Horvitz's expected-utility rule with the PRISM threshold: act only when the
255
- * caller's estimate of acceptance clears tau = cFA / (cFA + pNeed * cFN).
334
+ * Expected-utility alerting: act only when the caller's estimate of acceptance
335
+ * clears tau = cFA / (cFA + pNeed * cFN). That threshold is the classical Bayes
336
+ * decision boundary between the cost of alerting when the user did not want it,
337
+ * (1 - p) * cFA, and the cost of staying silent when they did, p * cFN.
338
+ * The alerting application is Horvitz, Jacobs and Hovel, "Attention-Sensitive
339
+ * Alerting", UAI 1999 (https://arxiv.org/abs/1301.6707); the system in that
340
+ * paper is named Priorities.
256
341
  * `candidate.pAccept` and `candidate.pNeed` come from the caller's own model.
257
342
  */
258
343
  export function utilityFloor(options) {
@@ -270,10 +355,29 @@ export function utilityFloor(options) {
270
355
  }
271
356
  const round3 = (n) => Math.round(n * 1000) / 1000;
272
357
  /**
273
- * Bounded deferral (Horvitz): when the user is busy, wait t* = min(bound,
358
+ * Bounded deferral: when the user is busy, wait t* = min(bound,
274
359
  * lambda * interruptCost / (2 * staleness)), the optimum of a quadratic
275
360
  * staleness loss against the cost of interrupting a busy person, with the
276
361
  * user becoming free at rate lambda. Never rejects; only moves deliverAt.
362
+ *
363
+ * The derivation is Achlioptas and Horvitz, "Principles of Bounded Deferral
364
+ * for Balancing Information Awareness with Interruption", Microsoft Research
365
+ * (http://erichorvitz.com/Bounded_Deferral.pdf): the expected cost is
366
+ * stationary where f'(t0) = lambda * c with f''(t0) > 0, so a quadratic
367
+ * staleness f(t) = s * t^2 gives t* = lambda * c / (2 * s).
368
+ *
369
+ * `lambda` defaults to 1/43 from the same paper's field study: 113 Microsoft
370
+ * employees (42 program managers, 25 developers, 19 testers, 10 administrators,
371
+ * 9 managers, 4 in sales and marketing, 4 research scientists), three
372
+ * sequential business days between 10am and 4pm, 4,803 busy situations, mean
373
+ * busy-session duration 43.12 s with a standard deviation of 51.79 s. That
374
+ * spread matters: the same paper's two-subject Interruption Workbench analysis
375
+ * puts the mean time to a lower-cost state after an alert at 11 s for one
376
+ * person and 101 s for the other. Measure your own users before trusting it.
377
+ *
378
+ * `staleness` and `boundSeconds` are scale choices, not findings. Only the
379
+ * ratio interruptCost / staleness affects t*, so the pair below is one way to
380
+ * express "a few minutes"; nothing in the literature fixes either number.
277
381
  */
278
382
  export function boundedDeferral(options = {}) {
279
383
  const lambda = options.lambda ?? 1 / 43;
@@ -3,6 +3,27 @@ export type Priority = "low" | "normal" | "high" | "critical";
3
3
  export declare const PRIORITY_RANK: Record<Priority, number>;
4
4
  /** Where a delivery may land. Free-form so callers can add their own. */
5
5
  export type Surface = "feed" | "push" | "chat" | "voice" | "email" | (string & {});
6
+ /** A quiet window in local time, "HH:MM" to "HH:MM". `start` after `end` crosses midnight. */
7
+ export type QuietWindow = {
8
+ start: string;
9
+ end: string;
10
+ };
11
+ /** Weekday keys for a quiet-hours schedule, Sunday first to match `Date#getUTCDay`. */
12
+ export type Weekday = "sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat";
13
+ /**
14
+ * Quiet hours that differ by day. A working week is not Monday to Friday everywhere,
15
+ * and a holiday is not a weekday at all, so the window is resolved per day: a calendar
16
+ * date first, then the weekday, then the default. `null` at any level means the day has
17
+ * no quiet hours.
18
+ *
19
+ * There is no bundled holiday calendar and there will not be one: the dates a caller
20
+ * observes are the caller's to supply, and a bundled calendar goes stale silently.
21
+ */
22
+ export type QuietSchedule = {
23
+ default?: QuietWindow | null;
24
+ days?: Partial<Record<Weekday, QuietWindow | null>>;
25
+ dates?: Record<string, QuietWindow | null>;
26
+ };
6
27
  /** Everything the gate knows about the person it might interrupt. */
7
28
  export interface UserState {
8
29
  id: string;
@@ -20,11 +41,25 @@ export interface UserState {
20
41
  intensity?: "low" | "normal" | "high";
21
42
  /** IANA time zone, required for quiet hours. */
22
43
  timezone?: string;
23
- /** Quiet hours in local time, "HH:MM". May cross midnight. */
24
- quietHours?: {
25
- start: string;
26
- end: string;
27
- } | null;
44
+ /**
45
+ * Quiet hours in local time, "HH:MM". May cross midnight.
46
+ *
47
+ * One window applies every day. A schedule gives a window per weekday, and per
48
+ * calendar date for the days a weekday cannot express, such as a public holiday:
49
+ *
50
+ * ```ts
51
+ * quietHours: {
52
+ * default: { start: "22:00", end: "08:00" },
53
+ * days: { fri: { start: "18:00", end: "00:00" }, sat: { start: "00:00", end: "20:00" } },
54
+ * dates: { "2026-12-25": { start: "00:00", end: "23:59" } },
55
+ * }
56
+ * ```
57
+ *
58
+ * `null` for a weekday or a date means no quiet hours that day, which is how you
59
+ * carve a working day out of a default. A date beats a weekday, a weekday beats
60
+ * the default. Dates are the user's local calendar dates, "YYYY-MM-DD".
61
+ */
62
+ quietHours?: QuietWindow | QuietSchedule | null;
28
63
  /** When the user joined. Drives the trust ramp. */
29
64
  createdAt?: Date | string;
30
65
  /** Surfaces the user allows, in preference order. Defaults to the candidate's surfaces. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proactive-gate",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",