proactive-gate 0.3.1__tar.gz → 0.4.0__tar.gz
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.
- {proactive_gate-0.3.1 → proactive_gate-0.4.0}/PKG-INFO +7 -1
- {proactive_gate-0.3.1 → proactive_gate-0.4.0}/README.md +6 -0
- {proactive_gate-0.3.1 → proactive_gate-0.4.0}/pyproject.toml +1 -1
- {proactive_gate-0.3.1 → proactive_gate-0.4.0}/src/proactive_gate/__init__.py +5 -3
- proactive_gate-0.4.0/src/proactive_gate/explain.py +517 -0
- proactive_gate-0.4.0/tests/test_explain.py +473 -0
- {proactive_gate-0.3.1 → proactive_gate-0.4.0}/.gitignore +0 -0
- {proactive_gate-0.3.1 → proactive_gate-0.4.0}/src/proactive_gate/checks.py +0 -0
- {proactive_gate-0.3.1 → proactive_gate-0.4.0}/src/proactive_gate/clock.py +0 -0
- {proactive_gate-0.3.1 → proactive_gate-0.4.0}/src/proactive_gate/conformance.py +0 -0
- {proactive_gate-0.3.1 → proactive_gate-0.4.0}/src/proactive_gate/gate.py +0 -0
- {proactive_gate-0.3.1 → proactive_gate-0.4.0}/src/proactive_gate/policy.py +0 -0
- {proactive_gate-0.3.1 → proactive_gate-0.4.0}/src/proactive_gate/presets.py +0 -0
- {proactive_gate-0.3.1 → proactive_gate-0.4.0}/src/proactive_gate/py.typed +0 -0
- {proactive_gate-0.3.1 → proactive_gate-0.4.0}/src/proactive_gate/stores.py +0 -0
- {proactive_gate-0.3.1 → proactive_gate-0.4.0}/src/proactive_gate/types.py +0 -0
- {proactive_gate-0.3.1 → proactive_gate-0.4.0}/tests/conftest.py +0 -0
- {proactive_gate-0.3.1 → proactive_gate-0.4.0}/tests/test_clock.py +0 -0
- {proactive_gate-0.3.1 → proactive_gate-0.4.0}/tests/test_conformance.py +0 -0
- {proactive_gate-0.3.1 → proactive_gate-0.4.0}/tests/test_stores.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: proactive-gate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Decide whether a proactive assistant may speak now: ordered checks, budgets, quiet hours, JSON policies, shared conformance fixtures with the TypeScript package.
|
|
5
5
|
Project-URL: Homepage, https://bubblegunn.github.io/proactive-gate/
|
|
6
6
|
Project-URL: Documentation, https://bubblegunn.github.io/proactive-gate/start/
|
|
@@ -84,6 +84,12 @@ else:
|
|
|
84
84
|
`evaluate` reads; `commit` is the atomic increment right before you send, and it is
|
|
85
85
|
idempotent on `decision.id`. Every decision carries `trace`, one entry per check that ran.
|
|
86
86
|
|
|
87
|
+
`explain(decision)` renders a decision as sentences a non-engineer follows, a pure function
|
|
88
|
+
of the trace with the same wording as the TypeScript catalog: `explain(decision).summary`
|
|
89
|
+
for the decision as one sentence, `.checks` for one per check that ran. `language` defaults
|
|
90
|
+
to `"en"`; another language is a mapping over the same keys in `catalogs`, merged over
|
|
91
|
+
English so a partial translation still renders.
|
|
92
|
+
|
|
87
93
|
## A policy is data
|
|
88
94
|
|
|
89
95
|
```python
|
|
@@ -47,6 +47,12 @@ else:
|
|
|
47
47
|
`evaluate` reads; `commit` is the atomic increment right before you send, and it is
|
|
48
48
|
idempotent on `decision.id`. Every decision carries `trace`, one entry per check that ran.
|
|
49
49
|
|
|
50
|
+
`explain(decision)` renders a decision as sentences a non-engineer follows, a pure function
|
|
51
|
+
of the trace with the same wording as the TypeScript catalog: `explain(decision).summary`
|
|
52
|
+
for the decision as one sentence, `.checks` for one per check that ran. `language` defaults
|
|
53
|
+
to `"en"`; another language is a mapping over the same keys in `catalogs`, merged over
|
|
54
|
+
English so a partial translation still renders.
|
|
55
|
+
|
|
50
56
|
## A policy is data
|
|
51
57
|
|
|
52
58
|
```python
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "proactive-gate"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Decide whether a proactive assistant may speak now: ordered checks, budgets, quiet hours, JSON policies, shared conformance fixtures with the TypeScript package."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -5,6 +5,7 @@ contract in ``spec/`` and the same fixtures.
|
|
|
5
5
|
"""
|
|
6
6
|
from . import checks, presets
|
|
7
7
|
from .checks import Check, default_checks
|
|
8
|
+
from .explain import EN, CheckSentence, Explanation, explain
|
|
8
9
|
from .gate import AsyncGate, Evaluation, Gate, Hooks, decide
|
|
9
10
|
from .policy import KNOWN_CHECKS, CompiledPolicy, compile_policy, load_policy
|
|
10
11
|
from .presets import Preset
|
|
@@ -24,8 +25,9 @@ from .types import (
|
|
|
24
25
|
__version__ = "0.2.0"
|
|
25
26
|
|
|
26
27
|
__all__ = [
|
|
27
|
-
"PRIORITY_RANK", "KNOWN_CHECKS", "AsyncGate", "AsyncMemoryStore", "AsyncStore", "Candidate", "Check",
|
|
28
|
-
"CompiledPolicy", "Context", "Decision", "EvaluateInput", "Evaluation", "
|
|
28
|
+
"EN", "PRIORITY_RANK", "KNOWN_CHECKS", "AsyncGate", "AsyncMemoryStore", "AsyncStore", "Candidate", "Check",
|
|
29
|
+
"CheckSentence", "CompiledPolicy", "Context", "Decision", "EvaluateInput", "Evaluation", "Explanation",
|
|
30
|
+
"Gate", "Hooks", "MemoryStore",
|
|
29
31
|
"Outcome", "Preset", "Priority", "RedisStore", "SqliteStore", "Store", "TraceEntry", "UserState",
|
|
30
|
-
"__version__", "checks", "compile_policy", "decide", "default_checks", "load_policy", "presets",
|
|
32
|
+
"__version__", "checks", "compile_policy", "decide", "default_checks", "explain", "load_policy", "presets",
|
|
31
33
|
]
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""explain(): a decision rendered as sentences a non-engineer can read.
|
|
2
|
+
|
|
3
|
+
The renderer is a pure function of the decision. Every fact in a sentence
|
|
4
|
+
comes from a trace entry's own reason or from a decision field, so it can
|
|
5
|
+
only describe a decision the gate actually made; a check that never ran has
|
|
6
|
+
no sentence. The machine reasons are the contract both implementations pin
|
|
7
|
+
word for word, which is what makes them safe to read back here. When a
|
|
8
|
+
reason does not match what a check emits, the sentence quotes it verbatim
|
|
9
|
+
rather than guessing.
|
|
10
|
+
|
|
11
|
+
``decision.reason`` is unchanged: the plain sentence and the machine reason
|
|
12
|
+
sit side by side and neither replaces the other.
|
|
13
|
+
|
|
14
|
+
Language is a parameter: English ships as ``EN``, and another language is a
|
|
15
|
+
mapping over the same keys, merged over English so a partial translation
|
|
16
|
+
still renders. The sentences match the TypeScript catalog word for word.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from collections.abc import Callable, Mapping
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
from .types import Decision, TraceEntry, iso_z
|
|
25
|
+
|
|
26
|
+
# A template reads the fields a parser pulled out of a machine reason.
|
|
27
|
+
SentenceTemplate = Callable[[Mapping[str, str]], str]
|
|
28
|
+
Sentences = dict[str, SentenceTemplate]
|
|
29
|
+
Facts = dict[str, str]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class CheckSentence:
|
|
34
|
+
"""One trace entry rendered: which check, what it said, and the sentence for it."""
|
|
35
|
+
|
|
36
|
+
id: str
|
|
37
|
+
outcome: str
|
|
38
|
+
sentence: str
|
|
39
|
+
shadow: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class Explanation:
|
|
44
|
+
"""``summary`` is the decision as one sentence; ``checks`` is one sentence per check that ran, in order."""
|
|
45
|
+
|
|
46
|
+
summary: str
|
|
47
|
+
checks: tuple[CheckSentence, ...]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# English ------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _plural(n: int, unit: str) -> str:
|
|
54
|
+
return f"{n} {unit}{'' if n == 1 else 's'}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _span_words(label: str) -> str:
|
|
58
|
+
"""'1d' to 'within the last day', '24h' to 'within the last 24 hours'."""
|
|
59
|
+
m = re.fullmatch(r"(\d+)([dhms])", label)
|
|
60
|
+
if not m:
|
|
61
|
+
return f"within the last {label}"
|
|
62
|
+
unit = {"d": "day", "h": "hour", "m": "minute", "s": "second"}[m.group(2)]
|
|
63
|
+
n = int(m.group(1))
|
|
64
|
+
return f"within the last {unit}" if n == 1 else f"within the last {_plural(n, unit)}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _seconds_words(text: str) -> str:
|
|
68
|
+
"""'86400' to 'day', '7200' to '2 hours': a rate-limit period in seconds, said long."""
|
|
69
|
+
try:
|
|
70
|
+
n = int(text)
|
|
71
|
+
except ValueError:
|
|
72
|
+
return f"{text} seconds"
|
|
73
|
+
|
|
74
|
+
# "per hour", not "per 1 hour": the same singular _span_words already says as "the last day".
|
|
75
|
+
def period(count: int, unit: str) -> str:
|
|
76
|
+
return unit if count == 1 else _plural(count, unit)
|
|
77
|
+
|
|
78
|
+
if n % 86400 == 0:
|
|
79
|
+
return period(n // 86400, "day")
|
|
80
|
+
if n % 3600 == 0:
|
|
81
|
+
return period(n // 3600, "hour")
|
|
82
|
+
if n % 60 == 0:
|
|
83
|
+
return period(n // 60, "minute")
|
|
84
|
+
return period(n, "second")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _budget_pass(label: str) -> SentenceTemplate:
|
|
88
|
+
# The spend note is true only when the pass really read the counter: a
|
|
89
|
+
# bypassed budget returns a bare pass, a near-limit pass cannot be a bypass.
|
|
90
|
+
def render(f: Mapping[str, str]) -> str:
|
|
91
|
+
if "used" in f:
|
|
92
|
+
return f"the {label} had room, but only just: {f['used']} of {f['limit']} already used; the unit is spent when the message actually goes out"
|
|
93
|
+
return f"the {label} did not stop it"
|
|
94
|
+
|
|
95
|
+
return render
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _budget_reject(label: str) -> SentenceTemplate:
|
|
99
|
+
return lambda f: f"the {label} of {f['limit']} was already spent ({f['used']} used)"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _held(f: Mapping[str, str]) -> str:
|
|
103
|
+
until = f.get("until")
|
|
104
|
+
return f"held{' until ' + until if until else ''} because {f['clause']}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _allowed(f: Mapping[str, str]) -> str:
|
|
108
|
+
return f"allowed at {f['at']} because no check stopped it{f.get('notes', '')}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _quiet_reject(f: Mapping[str, str]) -> str:
|
|
112
|
+
owned = f", a window belonging to {f['from']}," if "from" in f else ""
|
|
113
|
+
return f"the user's quiet hours run {f['start']} to {f['end']} {f['tz']}{owned} and {f['priority']} priority is below the {f['floor']} floor needed to override them"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _timing_adjust(f: Mapping[str, str]) -> str:
|
|
117
|
+
surfaces = (f.get("surfaces") or "").replace(",", ", ")
|
|
118
|
+
if "at" in f and "surfaces" in f:
|
|
119
|
+
return f"delivery was moved to {f['at']} and narrowed to {surfaces}"
|
|
120
|
+
if "at" in f:
|
|
121
|
+
return f"delivery was moved to {f['at']}"
|
|
122
|
+
return f"delivery was narrowed to {surfaces}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _window_pass(f: Mapping[str, str]) -> str:
|
|
126
|
+
name = f.get("name")
|
|
127
|
+
return f'the "{name}" allowed window did not block it' if name else "the allowed window did not block it"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _consent_pass(f: Mapping[str, str]) -> str:
|
|
131
|
+
name = f.get("name")
|
|
132
|
+
return f'the "{name}" consent the check needs was in place' if name else "the consent the check needs was in place"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _consent_reject(f: Mapping[str, str]) -> str:
|
|
136
|
+
hours = f", which applies between {f['start']} and {f['end']}" if "start" in f else ""
|
|
137
|
+
return f'the user has not given the required "{f["name"]}" consent{hours}'
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _recent_reject(f: Mapping[str, str]) -> str:
|
|
141
|
+
if "age" in f:
|
|
142
|
+
return f"the user's last message to the assistant was {f['age']} h ago, outside the {f['within']} h window"
|
|
143
|
+
return "the user has never written to the assistant, and this rule allows messages only after they do"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _fallback_pass(f: Mapping[str, str]) -> str:
|
|
147
|
+
reason = f.get("reason")
|
|
148
|
+
return f'the "{f["id"]}" check let it through{f" ({reason})" if reason else ""}'
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
EN: Sentences = {
|
|
152
|
+
"summary.held": _held,
|
|
153
|
+
"summary.allowed": _allowed,
|
|
154
|
+
"summary.allowedEmpty": lambda f: "allowed; no checks ran",
|
|
155
|
+
"summary.heldUnknown": lambda f: "held; the trace does not name the check that stopped it",
|
|
156
|
+
"note.deliverAt": lambda f: f"delivery waits until {f['at']}",
|
|
157
|
+
"note.nearLimit": lambda f: f"{f['check']} was close to its limit ({f['used']} of {f['limit']} used)",
|
|
158
|
+
"note.shadowed": lambda f: f"{f['check']} would have stopped it but ran in shadow mode",
|
|
159
|
+
"entry.shadow": lambda f: f"it would have stopped the message ({f['body']}), but the check ran in shadow mode so evaluation continued",
|
|
160
|
+
"killSwitch.pass": lambda f: "the kill switch was off",
|
|
161
|
+
"killSwitch.reject": lambda f: "the kill switch is on, which stops every message",
|
|
162
|
+
"consent.pass": lambda f: "the user has agreed to proactive messages",
|
|
163
|
+
"consent.reject": lambda f: "the user has not agreed to proactive messages",
|
|
164
|
+
"enabled.pass": lambda f: "proactive messages are switched on for this profile",
|
|
165
|
+
"enabled.reject": lambda f: "proactive messages are switched off on this user's profile",
|
|
166
|
+
"mode.pass": lambda f: "the user's operating mode did not block it",
|
|
167
|
+
"mode.reject": lambda f: f'the user\'s operating mode is "{f["mode"]}", which does not allow proactive messages',
|
|
168
|
+
"snooze.pass": lambda f: "no snooze was in effect",
|
|
169
|
+
"snooze.reject": lambda f: f"the user has snoozed the assistant until {f['until']}",
|
|
170
|
+
"mute.pass": lambda f: "this type of message is not muted",
|
|
171
|
+
"mute.reject": lambda f: f'the user has muted "{f["type"]}" messages',
|
|
172
|
+
"intensity.pass": lambda f: "the message's priority satisfied the user's intensity setting",
|
|
173
|
+
"intensity.reject": lambda f: f"the message was {f['priority']} priority, and the user's \"{f['level']}\" intensity setting allows only {f['floor']} and above",
|
|
174
|
+
"quietHours.pass": lambda f: "quiet hours did not block it",
|
|
175
|
+
"quietHours.reject": _quiet_reject,
|
|
176
|
+
"quietHours.skip": lambda f: "the user has quiet hours but no time zone, so the check could not run",
|
|
177
|
+
"trustRamp.pass": lambda f: "the new-user trust period did not block it",
|
|
178
|
+
"trustRamp.reject": lambda f: 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']}",
|
|
179
|
+
"trustRamp.skip": lambda f: "the user's sign-up date is not on record, so the new-user trust period could not be checked",
|
|
180
|
+
"dismissalCooldown.pass": lambda f: "the user has not dismissed this type enough to silence it",
|
|
181
|
+
"dismissalCooldown.reject": lambda f: f"the user has dismissed \"{f['type']}\" messages {f['count']} times in {f['within']} days, so this type stays silent until {f['until']}",
|
|
182
|
+
"adaptiveTiming.pass": lambda f: "the timing check left the message as it was",
|
|
183
|
+
"adaptiveTiming.adjust": _timing_adjust,
|
|
184
|
+
"dedupe.pass": lambda f: "this event had not already produced a message; the event is claimed when the message actually goes out",
|
|
185
|
+
"dedupe.reject": lambda f: f"the same event already produced a message {_span_words(f.get('window', ''))}",
|
|
186
|
+
"dedupe.skip": lambda f: "the candidate carried no event key, so duplicate detection could not run",
|
|
187
|
+
"dailyBudget.pass": _budget_pass("daily budget"),
|
|
188
|
+
"dailyBudget.reject": _budget_reject("user's daily budget"),
|
|
189
|
+
"weeklyBudget.pass": _budget_pass("weekly budget"),
|
|
190
|
+
"weeklyBudget.reject": _budget_reject("user's weekly budget"),
|
|
191
|
+
"monthlyBudget.pass": _budget_pass("monthly budget"),
|
|
192
|
+
"monthlyBudget.reject": _budget_reject("user's monthly budget"),
|
|
193
|
+
"windowBudget.pass": _budget_pass("window budget"),
|
|
194
|
+
"windowBudget.reject": lambda f: f"the budget for the window opened by the user's last message was already spent ({f['used']} of {f['limit']} used)",
|
|
195
|
+
"rateLimit.pass": lambda f: "the rate limit did not stop it",
|
|
196
|
+
"rateLimit.reject": lambda f: f"the rate limit of {f['limit']} messages per {_seconds_words(f.get('per', ''))} was already reached ({f['used']} used)",
|
|
197
|
+
"budget.pass": lambda f: 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",
|
|
198
|
+
"budget.reject": lambda f: f"the {f['label']} was already used up ({f['used']} of {f['limit']} used)",
|
|
199
|
+
"utilityFloor.pass": lambda f: "the estimated acceptance chance cleared the utility floor",
|
|
200
|
+
"utilityFloor.reject": lambda f: f"the estimated chance the user would accept this message was {f['pAccept']}, below the utility floor of {f['tau']}",
|
|
201
|
+
"utilityFloor.skip": lambda f: "the candidate carried no acceptance estimate, so the utility floor could not run",
|
|
202
|
+
"boundedDeferral.pass": lambda f: "the user did not look busy",
|
|
203
|
+
"boundedDeferral.adjust": lambda f: f"the user looked busy, so delivery was deferred {f['tStar']} seconds to {f['at']}",
|
|
204
|
+
"allowedWindow.pass": _window_pass,
|
|
205
|
+
"allowedWindow.reject": lambda f: f"messages may only go out between {f['start']} and {f['end']} ({f['zone']}), and the local time was outside that window",
|
|
206
|
+
"allowedWindow.skip": lambda f: "the user has no time zone, so the allowed window could not be checked",
|
|
207
|
+
"requiresConsent.pass": _consent_pass,
|
|
208
|
+
"requiresConsent.reject": _consent_reject,
|
|
209
|
+
"requiresConsent.skip": lambda f: "the user has no time zone, so the hours this consent applies could not be checked",
|
|
210
|
+
"recentInteraction.pass": lambda f: "the user had written to the assistant recently enough",
|
|
211
|
+
"recentInteraction.reject": _recent_reject,
|
|
212
|
+
"gate.failOpen": lambda f: f"the \"{f['id']}\" check failed with \"{f['error']}\", and the gate is set to let messages through when a check fails",
|
|
213
|
+
"gate.failClosed": lambda f: f"the \"{f['id']}\" check failed with \"{f['error']}\", and the gate is set to stop messages when a check fails",
|
|
214
|
+
"gate.nonRejecting": lambda f: f"the \"{f['id']}\" check tried to {f['kind']} (\"{f['reason']}\") but is marked non-rejecting, so the gate ignored it",
|
|
215
|
+
"fallback.stop": lambda f: f"the \"{f['id']}\" check stopped it: {f['reason']}",
|
|
216
|
+
"fallback.pass": _fallback_pass,
|
|
217
|
+
"fallback.skip": lambda f: f"the \"{f['id']}\" check did not weigh in: {f['reason']}",
|
|
218
|
+
"fallback.adjust": lambda f: f"the \"{f['id']}\" check adjusted it: {f['reason']}",
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# Reading the machine reasons back into facts ------------------------------ #
|
|
223
|
+
|
|
224
|
+
Parser = Callable[[str], "Facts | None"]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _match(pattern: str, keys: tuple[str, ...]) -> Parser:
|
|
228
|
+
compiled = re.compile(pattern)
|
|
229
|
+
|
|
230
|
+
def parse(reason: str) -> Facts | None:
|
|
231
|
+
m = compiled.fullmatch(reason)
|
|
232
|
+
if not m:
|
|
233
|
+
return None
|
|
234
|
+
facts: Facts = {}
|
|
235
|
+
for key, value in zip(keys, m.groups()):
|
|
236
|
+
if value is not None:
|
|
237
|
+
facts[key] = value
|
|
238
|
+
return facts
|
|
239
|
+
|
|
240
|
+
return parse
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _fixed(text: str) -> Parser:
|
|
244
|
+
return lambda reason: {} if reason == text else None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _first(*parsers: Parser) -> Parser:
|
|
248
|
+
def parse(reason: str) -> Facts | None:
|
|
249
|
+
for p in parsers:
|
|
250
|
+
facts = p(reason)
|
|
251
|
+
if facts is not None:
|
|
252
|
+
return facts
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
return parse
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
_BUDGET_STOP = _match(r".* of (\d+) used \((\d+)\)", ("limit", "used"))
|
|
259
|
+
_BUDGET_NEAR = _match(r"(\d+) of (\d+) used", ("used", "limit"))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _parse_quiet_hours(reason: str) -> Facts | None:
|
|
263
|
+
m = re.fullmatch(r"quiet hours (\S+) to (\S+)(?: \((\w+) (\S+)\))? (\S+); priority (\w+) is below the floor \((\w+)\)", reason)
|
|
264
|
+
if not m:
|
|
265
|
+
return None
|
|
266
|
+
# holdUntil is the fact the summary headlines ("held until 08:00"); checks
|
|
267
|
+
# whose clause already says the instant (snooze, cooldown) leave it unset.
|
|
268
|
+
facts: Facts = {"start": m.group(1), "end": m.group(2), "tz": m.group(5), "priority": m.group(6), "floor": m.group(7), "holdUntil": m.group(2)}
|
|
269
|
+
if m.group(3) is not None:
|
|
270
|
+
facts["from"] = f"{m.group(3)} {m.group(4)}"
|
|
271
|
+
return facts
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _consent_id_facts(check_id: str) -> Facts:
|
|
275
|
+
return {"name": check_id[len("consent:"):]} if check_id.startswith("consent:") else {}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _window_id_facts(check_id: str) -> Facts:
|
|
279
|
+
return {"name": check_id[len("window:"):]} if check_id.startswith("window:") else {}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
PARSERS: dict[str, dict[str, Parser | Callable[[str], Facts]]] = {
|
|
283
|
+
"killSwitch": {"stop": _fixed("engine kill switch is on")},
|
|
284
|
+
"consent": {"stop": _fixed("user has not consented to proactive behaviour")},
|
|
285
|
+
"enabled": {"stop": _fixed("proactive behaviour is disabled on this profile")},
|
|
286
|
+
"mode": {"stop": _match(r'operating mode "(.+)" does not allow proactive messages', ("mode",))},
|
|
287
|
+
"snooze": {"stop": _match(r"snoozed until (.+)", ("until",))},
|
|
288
|
+
"mute": {"stop": _match(r'type "(.+)" is muted by the user', ("type",))},
|
|
289
|
+
"intensity": {"stop": _match(r'priority (\w+) is below the "(\w+)" intensity floor \((\w+)\)', ("priority", "level", "floor"))},
|
|
290
|
+
"quietHours": {"stop": _parse_quiet_hours, "skip": _fixed("quiet hours set but no timezone on the user; cannot evaluate")},
|
|
291
|
+
"trustRamp": {
|
|
292
|
+
"stop": _match(r"trust ramp: day (\d+) of (\S+), priority (\w+) is below (\w+)", ("day", "days", "priority", "floor")),
|
|
293
|
+
"skip": _fixed("no createdAt on the user; ramp cannot be evaluated"),
|
|
294
|
+
},
|
|
295
|
+
"dismissalCooldown": {"stop": _match(r'(\d+) dismissals of "(.+)" in (\S+) days; silent until (.+)', ("count", "type", "within", "until"))},
|
|
296
|
+
"adaptiveTiming": {
|
|
297
|
+
"adjust": _first(
|
|
298
|
+
_match(r"deliver at (\S+); surfaces (.+)", ("at", "surfaces")),
|
|
299
|
+
_match(r"deliver at (\S+)", ("at",)),
|
|
300
|
+
_match(r"surfaces (.+)", ("surfaces",)),
|
|
301
|
+
)
|
|
302
|
+
},
|
|
303
|
+
"dedupe": {"stop": _match(r"already delivered within the last (.+)", ("window",)), "skip": _fixed("no dedupeKey on the candidate; deduplication cannot be evaluated")},
|
|
304
|
+
"dailyBudget": {"stop": _BUDGET_STOP, "passReason": _BUDGET_NEAR},
|
|
305
|
+
"weeklyBudget": {"stop": _BUDGET_STOP, "passReason": _BUDGET_NEAR},
|
|
306
|
+
"monthlyBudget": {"stop": _BUDGET_STOP, "passReason": _BUDGET_NEAR},
|
|
307
|
+
"windowBudget": {"stop": _BUDGET_STOP, "passReason": _BUDGET_NEAR},
|
|
308
|
+
"rateLimit": {"stop": _match(r"rate limit (\d+) per (\d+) s of \d+ used \((\d+)\)", ("limit", "per", "used")), "passReason": _BUDGET_NEAR},
|
|
309
|
+
"utilityFloor": {"stop": _match(r"pAccept (\S+) < tau (\S+)", ("pAccept", "tau")), "skip": _fixed("no pAccept on the candidate; utility floor cannot be evaluated")},
|
|
310
|
+
"boundedDeferral": {"adjust": _match(r"user busy; deliver at (\S+) \(t\* (\d+) s\)", ("at", "tStar"))},
|
|
311
|
+
"allowedWindow": {
|
|
312
|
+
"stop": _match(r"outside the allowed window (\S+) to (\S+) (.+)", ("start", "end", "zone")),
|
|
313
|
+
"idFacts": _window_id_facts,
|
|
314
|
+
"skip": _fixed("no timezone on the user; window cannot be evaluated"),
|
|
315
|
+
},
|
|
316
|
+
"requiresConsent": {
|
|
317
|
+
"stop": _match(r'consent "(.+)" is missing(?: \(required (\S+) to (\S+)\))?', ("name", "start", "end")),
|
|
318
|
+
"idFacts": _consent_id_facts,
|
|
319
|
+
"skip": _fixed("no timezone on the user; consent window cannot be evaluated"),
|
|
320
|
+
},
|
|
321
|
+
"recentInteraction": {
|
|
322
|
+
"stop": _first(
|
|
323
|
+
_fixed("no inbound message from the user on record"),
|
|
324
|
+
_match(r"last inbound message (\d+) h ago, window is (\S+) h", ("age", "within")),
|
|
325
|
+
)
|
|
326
|
+
},
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _key_for_id(check_id: str) -> str | None:
|
|
331
|
+
"""The ids the package itself emits: the fixed check ids, plus consent:<name>, rate:<limit>/<period>s and window:<name>."""
|
|
332
|
+
if check_id in PARSERS:
|
|
333
|
+
return check_id
|
|
334
|
+
if check_id.startswith("consent:"):
|
|
335
|
+
return "requiresConsent"
|
|
336
|
+
if check_id.startswith("rate:"):
|
|
337
|
+
return "rateLimit"
|
|
338
|
+
if check_id.startswith("window:"):
|
|
339
|
+
return "allowedWindow"
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# The gate's own trace entries: a store failure, or a non-rejecting check that
|
|
344
|
+
# tried to stop evaluation anyway.
|
|
345
|
+
_GATE_ENTRIES: tuple[tuple[str, Parser], ...] = (
|
|
346
|
+
("gate.failOpen", _match(r"check threw \((.*)\); failing open", ("error",))),
|
|
347
|
+
("gate.failClosed", _match(r"check threw \((.*)\); failing closed", ("error",))),
|
|
348
|
+
("gate.nonRejecting", _match(r"non-rejecting check returned (\w+) \((.*)\); ignored", ("kind", "reason"))),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# Custom ids (a preset's "window:kakao", a caller's own wrapper) still emit the
|
|
352
|
+
# check's reason, so an unmatched id falls back to recognizing the reason's
|
|
353
|
+
# shape. Order is distinctiveness: each pattern names the check it sounds like.
|
|
354
|
+
_STOP_SCAN = ("quietHours", "allowedWindow", "requiresConsent", "rateLimit", "dismissalCooldown", "snooze", "mute", "mode", "intensity", "trustRamp", "dedupe", "utilityFloor", "recentInteraction", "killSwitch", "consent", "enabled")
|
|
355
|
+
_SKIP_SCAN = ("quietHours", "trustRamp", "dedupe", "utilityFloor", "allowedWindow", "requiresConsent")
|
|
356
|
+
_ADJUST_SCAN = ("adaptiveTiming", "boundedDeferral")
|
|
357
|
+
|
|
358
|
+
_BUDGET_LABELS = {"daily budget": "dailyBudget", "weekly budget": "weeklyBudget", "monthly budget": "monthlyBudget", "window budget": "windowBudget"}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _scan_budget(reason: str) -> tuple[str, Facts] | None:
|
|
362
|
+
"""A 'label of N used (M)' reason from a budget whose id we do not know."""
|
|
363
|
+
m = re.fullmatch(r"(.+) of (\d+) used \((\d+)\)", reason)
|
|
364
|
+
if not m:
|
|
365
|
+
return None
|
|
366
|
+
key = _BUDGET_LABELS.get(m.group(1), "budget")
|
|
367
|
+
return key, {"label": m.group(1), "limit": m.group(2), "used": m.group(3)}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _cap(text: str) -> str:
|
|
371
|
+
return text[:1].upper() + text[1:] if text else text
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _sentence_of(fragment: str) -> str:
|
|
375
|
+
return f"{_cap(fragment)}."
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _t(sentences: Mapping[str, SentenceTemplate], key: str, facts: Facts) -> str:
|
|
379
|
+
template = sentences.get(key) or EN.get(key)
|
|
380
|
+
return template(facts) if template else facts.get("reason", "")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _stop_facts(entry: TraceEntry) -> tuple[str, Facts]:
|
|
384
|
+
"""The stopping entry's clause and, when the trace names it, the instant the hold lifts."""
|
|
385
|
+
reason = entry.reason or ""
|
|
386
|
+
for gkey, parse in _GATE_ENTRIES:
|
|
387
|
+
gate_facts = parse(reason)
|
|
388
|
+
if gate_facts is not None:
|
|
389
|
+
return gkey, {"id": entry.id, **gate_facts}
|
|
390
|
+
check_key = _key_for_id(entry.id)
|
|
391
|
+
if check_key is not None:
|
|
392
|
+
parser = PARSERS[check_key].get("stop")
|
|
393
|
+
if parser is not None:
|
|
394
|
+
own = parser(reason)
|
|
395
|
+
if own is not None:
|
|
396
|
+
return f"{check_key}.reject", own
|
|
397
|
+
for k in _STOP_SCAN:
|
|
398
|
+
parser = PARSERS[k].get("stop")
|
|
399
|
+
if parser is None:
|
|
400
|
+
continue
|
|
401
|
+
scanned = parser(reason)
|
|
402
|
+
if scanned is not None:
|
|
403
|
+
return f"{k}.reject", scanned
|
|
404
|
+
budget = _scan_budget(reason)
|
|
405
|
+
if budget is not None:
|
|
406
|
+
return f"{budget[0]}.reject", budget[1]
|
|
407
|
+
return "fallback.stop", {"id": entry.id, "reason": reason}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _entry_body(entry: TraceEntry, sentences: Mapping[str, SentenceTemplate]) -> str:
|
|
411
|
+
reason = entry.reason or ""
|
|
412
|
+
check_key = _key_for_id(entry.id)
|
|
413
|
+
facts: Facts | None
|
|
414
|
+
if entry.outcome in ("reject", "defer"):
|
|
415
|
+
template, stop = _stop_facts(entry)
|
|
416
|
+
return _t(sentences, template, stop)
|
|
417
|
+
if entry.outcome == "pass":
|
|
418
|
+
if reason:
|
|
419
|
+
if check_key is not None:
|
|
420
|
+
parser = PARSERS[check_key].get("passReason")
|
|
421
|
+
facts = parser(reason) if parser is not None else None
|
|
422
|
+
else:
|
|
423
|
+
facts = _BUDGET_NEAR(reason)
|
|
424
|
+
if facts is not None:
|
|
425
|
+
return _t(sentences, f"{check_key or 'budget'}.pass", facts)
|
|
426
|
+
return _t(sentences, "fallback.pass", {"id": entry.id, "reason": reason})
|
|
427
|
+
base: Facts = {}
|
|
428
|
+
if check_key is not None:
|
|
429
|
+
id_facts = PARSERS[check_key].get("idFacts")
|
|
430
|
+
if id_facts is not None:
|
|
431
|
+
base = dict(id_facts(entry.id) or {})
|
|
432
|
+
return _t(sentences, f"{check_key}.pass" if check_key else "fallback.pass", {"id": entry.id, **base})
|
|
433
|
+
if entry.outcome == "skip":
|
|
434
|
+
for gkey, parse in _GATE_ENTRIES:
|
|
435
|
+
facts = parse(reason)
|
|
436
|
+
if facts is not None:
|
|
437
|
+
return _t(sentences, gkey, {"id": entry.id, **facts})
|
|
438
|
+
if check_key is not None:
|
|
439
|
+
parser = PARSERS[check_key].get("skip")
|
|
440
|
+
if parser is not None:
|
|
441
|
+
facts = parser(reason)
|
|
442
|
+
if facts is not None:
|
|
443
|
+
return _t(sentences, f"{check_key}.skip", facts)
|
|
444
|
+
for k in _SKIP_SCAN:
|
|
445
|
+
parser = PARSERS[k].get("skip")
|
|
446
|
+
if parser is None:
|
|
447
|
+
continue
|
|
448
|
+
facts = parser(reason)
|
|
449
|
+
if facts is not None:
|
|
450
|
+
return _t(sentences, f"{k}.skip", facts)
|
|
451
|
+
return _t(sentences, "fallback.skip", {"id": entry.id, "reason": reason})
|
|
452
|
+
# adjust
|
|
453
|
+
if check_key is not None:
|
|
454
|
+
parser = PARSERS[check_key].get("adjust")
|
|
455
|
+
if parser is not None:
|
|
456
|
+
facts = parser(reason)
|
|
457
|
+
if facts is not None:
|
|
458
|
+
return _t(sentences, f"{check_key}.adjust", facts)
|
|
459
|
+
for k in _ADJUST_SCAN:
|
|
460
|
+
parser = PARSERS[k].get("adjust")
|
|
461
|
+
if parser is None:
|
|
462
|
+
continue
|
|
463
|
+
facts = parser(reason)
|
|
464
|
+
if facts is not None:
|
|
465
|
+
return _t(sentences, f"{k}.adjust", facts)
|
|
466
|
+
return _t(sentences, "fallback.adjust", {"id": entry.id, "reason": reason})
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _explain_entry(entry: TraceEntry, sentences: Mapping[str, SentenceTemplate]) -> CheckSentence:
|
|
470
|
+
body = _entry_body(entry, sentences)
|
|
471
|
+
# Shadow is only a would-have-stopped when the outcome could have stopped.
|
|
472
|
+
if entry.shadow and entry.outcome in ("reject", "defer"):
|
|
473
|
+
body = _t(sentences, "entry.shadow", {"body": body})
|
|
474
|
+
return CheckSentence(id=entry.id, outcome=entry.outcome, sentence=_sentence_of(body), shadow=entry.shadow)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _summarize(decision: Decision, sentences: Mapping[str, SentenceTemplate]) -> str:
|
|
478
|
+
if decision.allowed:
|
|
479
|
+
if not decision.trace:
|
|
480
|
+
return _sentence_of(_t(sentences, "summary.allowedEmpty", {}))
|
|
481
|
+
notes: list[str] = []
|
|
482
|
+
if decision.deliver_at is not None:
|
|
483
|
+
notes.append(_t(sentences, "note.deliverAt", {"at": iso_z(decision.deliver_at)}))
|
|
484
|
+
for n in decision.near_limit:
|
|
485
|
+
notes.append(_t(sentences, "note.nearLimit", {"check": n.check, "used": str(n.used), "limit": str(n.limit)}))
|
|
486
|
+
for check_id in decision.shadowed:
|
|
487
|
+
notes.append(_t(sentences, "note.shadowed", {"check": check_id}))
|
|
488
|
+
return _sentence_of(_t(sentences, "summary.allowed", {"at": iso_z(decision.evaluated_at), "notes": "; " + "; ".join(notes) if notes else ""}))
|
|
489
|
+
stop_id = decision.rejected_by or decision.deferred_by
|
|
490
|
+
entry = next((e for e in reversed(decision.trace) if e.id == stop_id and e.outcome in ("reject", "defer")), None) if stop_id else None
|
|
491
|
+
if entry is None:
|
|
492
|
+
return _sentence_of(_t(sentences, "summary.heldUnknown", {}))
|
|
493
|
+
template, facts = _stop_facts(entry)
|
|
494
|
+
clause = _t(sentences, template, facts)
|
|
495
|
+
# For a deferral the hold ends at retryAt; when the clause already names the
|
|
496
|
+
# instant (snooze's reason is "snoozed until X"), naming it twice reads worse.
|
|
497
|
+
clause_says_when = "until" in facts
|
|
498
|
+
until = facts.get("holdUntil")
|
|
499
|
+
if until is None and not clause_says_when and decision.deferred_by and decision.retry_at is not None:
|
|
500
|
+
until = iso_z(decision.retry_at)
|
|
501
|
+
held_facts: Facts = {"clause": clause}
|
|
502
|
+
if until is not None:
|
|
503
|
+
held_facts["until"] = until
|
|
504
|
+
return _sentence_of(_t(sentences, "summary.held", held_facts))
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def explain(decision: Decision, language: str = "en", catalogs: Mapping[str, Mapping[str, SentenceTemplate]] | None = None) -> Explanation:
|
|
508
|
+
"""Render a decision as sentences. Deterministic: the same decision always
|
|
509
|
+
produces the same explanation, and nothing outside the decision is read."""
|
|
510
|
+
overlay = (catalogs or {}).get(language)
|
|
511
|
+
if language != "en" and overlay is None:
|
|
512
|
+
raise ValueError(f'no sentence catalog for language "{language}"; pass one with the catalogs argument')
|
|
513
|
+
sentences: Sentences = {**EN, **(overlay or {})}
|
|
514
|
+
return Explanation(summary=_summarize(decision, sentences), checks=tuple(_explain_entry(e, sentences) for e in decision.trace))
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
__all__ = ["EN", "CheckSentence", "Explanation", "SentenceTemplate", "Sentences", "explain"]
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""explain(): the same sentences as the TypeScript catalog, word for word."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from proactive_gate import (
|
|
11
|
+
EN,
|
|
12
|
+
Candidate,
|
|
13
|
+
Decision,
|
|
14
|
+
EvaluateInput,
|
|
15
|
+
Gate,
|
|
16
|
+
MemoryStore,
|
|
17
|
+
TraceEntry,
|
|
18
|
+
UserState,
|
|
19
|
+
checks,
|
|
20
|
+
explain,
|
|
21
|
+
)
|
|
22
|
+
from proactive_gate.types import QuietSchedule, QuietWindow
|
|
23
|
+
|
|
24
|
+
UTC = timezone.utc
|
|
25
|
+
NOON = datetime(2026, 9, 4, 9, 0, tzinfo=UTC) # 12:00 in Istanbul (UTC+3)
|
|
26
|
+
NIGHT = datetime(2026, 9, 4, 20, 30, tzinfo=UTC) # 23:30 in Istanbul
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def user(**overrides: Any) -> UserState:
|
|
30
|
+
base: dict[str, Any] = {
|
|
31
|
+
"id": "u1",
|
|
32
|
+
"consent": True,
|
|
33
|
+
"proactive_enabled": True,
|
|
34
|
+
"mode": "normal",
|
|
35
|
+
"intensity": "normal",
|
|
36
|
+
"timezone": "Europe/Istanbul",
|
|
37
|
+
"quiet_hours": QuietWindow("22:00", "08:00"),
|
|
38
|
+
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
|
|
39
|
+
}
|
|
40
|
+
base.update(overrides)
|
|
41
|
+
return UserState(**base)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def candidate(**overrides: Any) -> Candidate:
|
|
45
|
+
base: dict[str, Any] = {"id": "c1", "type": "reminder", "priority": "normal", "surfaces": ("push", "feed")}
|
|
46
|
+
base.update(overrides)
|
|
47
|
+
return Candidate(**base)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def evaluate(checks_list: Any, u: UserState | None = None, c: Candidate | None = None, now: datetime = NOON, store: MemoryStore | None = None, **gate_kw: Any) -> Decision:
|
|
51
|
+
gate = Gate(checks_list, store=store or MemoryStore(), **gate_kw)
|
|
52
|
+
return gate.evaluate(EvaluateInput(u or user(), c or candidate(), now))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_pure_function_of_the_decision() -> None:
|
|
56
|
+
d = evaluate(checks.default_checks(), now=NIGHT)
|
|
57
|
+
first = explain(d)
|
|
58
|
+
second = explain(d)
|
|
59
|
+
assert first == second
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_plain_sentence_and_machine_reason_are_both_on_hand() -> None:
|
|
63
|
+
d = evaluate([checks.QuietHours()], now=NIGHT)
|
|
64
|
+
e = explain(d)
|
|
65
|
+
assert d.reason == "quiet hours 22:00 to 08:00 Europe/Istanbul; priority normal is below the floor (critical)"
|
|
66
|
+
assert e.summary == "Held until 08:00 because the user's quiet hours run 22:00 to 08:00 Europe/Istanbul and normal priority is below the critical floor needed to override them."
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_not_stopped_every_check_gets_a_sentence() -> None:
|
|
70
|
+
d = evaluate(checks.default_checks(), now=NOON)
|
|
71
|
+
e = explain(d)
|
|
72
|
+
assert e.summary == "Allowed at 2026-09-04T09:00:00.000Z because no check stopped it."
|
|
73
|
+
assert [c.id for c in e.checks] == [t.id for t in d.trace]
|
|
74
|
+
assert all(c.sentence for c in e.checks)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_kill_switch() -> None:
|
|
78
|
+
d = evaluate([checks.KillSwitch(True)], now=NOON)
|
|
79
|
+
assert explain(d).summary == "Held because the kill switch is on, which stops every message."
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.parametrize(
|
|
83
|
+
("overrides", "clause"),
|
|
84
|
+
[
|
|
85
|
+
({"consent": False}, "the user has not agreed to proactive messages"),
|
|
86
|
+
({"proactive_enabled": False}, "proactive messages are switched off on this user's profile"),
|
|
87
|
+
({"mode": "focus"}, 'the user\'s operating mode is "focus", which does not allow proactive messages'),
|
|
88
|
+
({"muted_types": ("reminder",)}, 'the user has muted "reminder" messages'),
|
|
89
|
+
],
|
|
90
|
+
)
|
|
91
|
+
def test_preference_checks_name_what_the_user_set(overrides: dict[str, Any], clause: str) -> None:
|
|
92
|
+
d = evaluate(checks.default_checks(), u=user(**overrides), now=NOON)
|
|
93
|
+
assert explain(d).summary == f"Held because {clause}."
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_snooze_reject_and_defer() -> None:
|
|
97
|
+
d = evaluate([checks.Snooze()], u=user(snoozed_until=datetime(2026, 9, 4, 12, 0, tzinfo=UTC)), now=NOON)
|
|
98
|
+
assert explain(d).summary == "Held because the user has snoozed the assistant until 2026-09-04T12:00:00.000Z."
|
|
99
|
+
deferred = evaluate([checks.Snooze(defer=True)], u=user(snoozed_until=datetime(2026, 9, 4, 12, 0, tzinfo=UTC)), now=NOON)
|
|
100
|
+
assert deferred.deferred_by == "snooze"
|
|
101
|
+
assert explain(deferred).summary == "Held because the user has snoozed the assistant until 2026-09-04T12:00:00.000Z."
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_intensity() -> None:
|
|
105
|
+
d = evaluate([checks.Intensity()], u=user(intensity="low"), now=NOON)
|
|
106
|
+
assert explain(d).summary == 'Held because the message was normal priority, and the user\'s "low" intensity setting allows only high and above.'
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_quiet_hours_window_owned_by_yesterday_says_so() -> None:
|
|
110
|
+
# Friday's 18:00 to 06:00 window is what silences Saturday 00:30 local.
|
|
111
|
+
scheduled = user(quiet_hours=QuietSchedule(default=None, days={"fri": QuietWindow("18:00", "06:00")}))
|
|
112
|
+
d = evaluate([checks.QuietHours()], u=scheduled, now=datetime(2026, 9, 4, 21, 30, tzinfo=UTC))
|
|
113
|
+
assert d.rejected_by == "quietHours"
|
|
114
|
+
assert "a window belonging to fri 2026-09-04" in explain(d).summary
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_quiet_hours_skip_without_timezone() -> None:
|
|
118
|
+
d = evaluate([checks.QuietHours()], u=user(timezone=None), now=NIGHT)
|
|
119
|
+
assert explain(d).checks[0].sentence == "The user has quiet hours but no time zone, so the check could not run."
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_trust_ramp_names_the_day() -> None:
|
|
123
|
+
d = evaluate([checks.TrustRamp()], u=user(created_at=datetime(2026, 9, 2, tzinfo=UTC)), now=NOON)
|
|
124
|
+
assert explain(d).summary == "Held because the user is on day 3 of a 7-day trust period for new users, which allows only high priority and above, and the message was normal."
|
|
125
|
+
skip = evaluate([checks.TrustRamp()], u=user(created_at=None), now=NOON)
|
|
126
|
+
assert explain(skip).checks[0].sentence == "The user's sign-up date is not on record, so the new-user trust period could not be checked."
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_dismissal_cooldown_said_plainly() -> None:
|
|
130
|
+
store = MemoryStore()
|
|
131
|
+
gate = Gate([checks.DismissalCooldown()], store=store)
|
|
132
|
+
u = user()
|
|
133
|
+
for day in (1, 2, 3):
|
|
134
|
+
gate.record(u, Candidate(id=f"c{day}", type="reminder"), "dismissed", datetime(2026, 9, day, 10, 0, tzinfo=UTC))
|
|
135
|
+
d = gate.evaluate(EvaluateInput(u, candidate(), datetime(2026, 9, 5, 10, 0, tzinfo=UTC)))
|
|
136
|
+
assert explain(d).summary == 'Held because the user has dismissed "reminder" messages 3 times in 30 days, so this type stays silent until 2026-09-10T10:00:00.000Z.'
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_adaptive_timing_adjust() -> None:
|
|
140
|
+
d = evaluate(
|
|
141
|
+
[checks.AdaptiveTiming(next_good_moment=lambda ctx: datetime(2026, 9, 4, 10, 0, tzinfo=UTC), surfaces_for=lambda ctx: ("feed",))],
|
|
142
|
+
now=NOON,
|
|
143
|
+
)
|
|
144
|
+
assert d.allowed
|
|
145
|
+
e = explain(d)
|
|
146
|
+
assert e.checks[0].sentence == "Delivery was moved to 2026-09-04T10:00:00.000Z and narrowed to feed."
|
|
147
|
+
assert "delivery waits until 2026-09-04T10:00:00.000Z" in e.summary
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_budget_spent_at_send_time() -> None:
|
|
151
|
+
store = MemoryStore()
|
|
152
|
+
gate = Gate([checks.DailyBudget(limit=5), checks.WeeklyBudget(limit=20)], store=store)
|
|
153
|
+
store.set("pg:budget:u1:2026-09-04", "4")
|
|
154
|
+
near = gate.evaluate(EvaluateInput(user(), candidate(), NOON))
|
|
155
|
+
e = explain(near)
|
|
156
|
+
assert e.checks[0].sentence == "The daily budget had room, but only just: 4 of 5 already used; the unit is spent when the message actually goes out."
|
|
157
|
+
assert "dailyBudget was close to its limit (4 of 5 used)" in e.summary
|
|
158
|
+
|
|
159
|
+
store.set("pg:budget:u1:2026-09-04", "5")
|
|
160
|
+
out = gate.evaluate(EvaluateInput(user(), candidate(), NOON))
|
|
161
|
+
assert explain(out).summary == "Held because the user's daily budget of 5 was already spent (5 used)."
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_rate_limit_custom_id() -> None:
|
|
165
|
+
store = MemoryStore()
|
|
166
|
+
window = int(NOON.timestamp() // 60)
|
|
167
|
+
store.set(f"pg:rate:channel:general:60:{window}", "20")
|
|
168
|
+
d = evaluate([checks.RateLimit(limit=20, per_seconds=60, key_by="channel", id="rate:20/min")], c=candidate(channel="general"), store=store, now=NOON)
|
|
169
|
+
assert explain(d).summary == "Held because the rate limit of 20 messages per minute was already reached (20 used)."
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@pytest.mark.parametrize(
|
|
173
|
+
("per_seconds", "limit", "said"),
|
|
174
|
+
[(60, 20, "per minute"), (3600, 1000, "per hour"), (24 * 3600, 3, "per day"), (7200, 5, "per 2 hours"), (90, 2, "per 90 seconds")],
|
|
175
|
+
)
|
|
176
|
+
def test_rate_limit_periods(per_seconds: int, limit: int, said: str) -> None:
|
|
177
|
+
"""One of a unit drops the number; kakao_brand_message ships the hour and line_messaging_api the day."""
|
|
178
|
+
store = MemoryStore()
|
|
179
|
+
store.set(f"pg:rate:user:u1:{per_seconds}:{int(NOON.timestamp() // per_seconds)}", str(limit))
|
|
180
|
+
d = evaluate([checks.RateLimit(limit=limit, per_seconds=per_seconds)], store=store, now=NOON)
|
|
181
|
+
assert explain(d).summary == f"Held because the rate limit of {limit} messages {said} was already reached ({limit} used)."
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_dedupe_delivered_and_missing_key() -> None:
|
|
185
|
+
store = MemoryStore()
|
|
186
|
+
store.set("pg:dedupe:u1:order:42", "1")
|
|
187
|
+
seen = evaluate([checks.Dedupe()], c=candidate(dedupe_key="order:42"), store=store, now=NOON)
|
|
188
|
+
assert explain(seen).summary == "Held because the same event already produced a message within the last day."
|
|
189
|
+
no_key = evaluate([checks.Dedupe()], now=NOON)
|
|
190
|
+
assert explain(no_key).checks[0].sentence == "The candidate carried no event key, so duplicate detection could not run."
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_utility_floor() -> None:
|
|
194
|
+
d = evaluate([checks.UtilityFloor(1, 1)], c=candidate(p_accept=0.3), now=NOON)
|
|
195
|
+
assert explain(d).summary == "Held because the estimated chance the user would accept this message was 0.3, below the utility floor of 0.5."
|
|
196
|
+
skip = evaluate([checks.UtilityFloor(1, 1)], now=NOON)
|
|
197
|
+
assert explain(skip).checks[0].sentence == "The candidate carried no acceptance estimate, so the utility floor could not run."
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_bounded_deferral_adjust() -> None:
|
|
201
|
+
d = evaluate([checks.BoundedDeferral()], c=candidate(busy=True), now=NOON)
|
|
202
|
+
assert d.allowed
|
|
203
|
+
e = explain(d)
|
|
204
|
+
assert e.checks[0].sentence.startswith("The user looked busy, so delivery was deferred 116")
|
|
205
|
+
assert "delivery waits until" in e.summary
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_allowed_window_custom_id() -> None:
|
|
209
|
+
d = evaluate([checks.AllowedWindow("08:00", "21:00", timezone="user", id="window:tcpa")], now=NIGHT)
|
|
210
|
+
assert d.rejected_by == "window:tcpa"
|
|
211
|
+
assert explain(d).summary == "Held because messages may only go out between 08:00 and 21:00 (Europe/Istanbul), and the local time was outside that window."
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_allowed_window_preset_id_names_the_window() -> None:
|
|
215
|
+
"""us_tcpa, kakao_brand_message and cn_minor_mode each give the window an id of its own."""
|
|
216
|
+
named = evaluate([checks.AllowedWindow("08:00", "21:00", timezone="user", id="window:tcpa")], now=NOON)
|
|
217
|
+
assert named.allowed is True
|
|
218
|
+
assert explain(named).checks[0].sentence == 'The "tcpa" allowed window did not block it.'
|
|
219
|
+
|
|
220
|
+
plain = evaluate([checks.AllowedWindow("08:00", "21:00", timezone="user")], now=NOON)
|
|
221
|
+
assert explain(plain).checks[0].sentence == "The allowed window did not block it."
|
|
222
|
+
|
|
223
|
+
no_zone = evaluate([checks.AllowedWindow("08:00", "21:00", timezone="user", id="window:tcpa")], u=user(timezone=None), now=NOON)
|
|
224
|
+
assert explain(no_zone).checks[0].sentence == "The user has no time zone, so the allowed window could not be checked."
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_requires_consent_with_and_without_hours() -> None:
|
|
228
|
+
d = evaluate([checks.RequiresConsent("ad")], now=NOON)
|
|
229
|
+
assert explain(d).summary == 'Held because the user has not given the required "ad" consent.'
|
|
230
|
+
dn = evaluate([checks.RequiresConsent("night", when={"start": "21:00", "end": "08:00", "timezone": "user"})], now=NIGHT)
|
|
231
|
+
assert explain(dn).summary == 'Held because the user has not given the required "night" consent, which applies between 21:00 and 08:00.'
|
|
232
|
+
ok = evaluate([checks.RequiresConsent("ad")], u=user(consents={"ad": True}), now=NOON)
|
|
233
|
+
assert explain(ok).checks[0].sentence == 'The "ad" consent the check needs was in place.'
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_recent_interaction() -> None:
|
|
237
|
+
never = evaluate([checks.RecentInteraction(48)], now=NOON)
|
|
238
|
+
assert explain(never).summary == "Held because the user has never written to the assistant, and this rule allows messages only after they do."
|
|
239
|
+
old = evaluate([checks.RecentInteraction(48)], u=user(last_inbound_at=datetime(2026, 9, 1, 8, 0, tzinfo=UTC)), now=NOON)
|
|
240
|
+
assert explain(old).summary == "Held because the user's last message to the assistant was 73 h ago, outside the 48 h window."
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_window_budget() -> None:
|
|
244
|
+
store = MemoryStore()
|
|
245
|
+
last = datetime(2026, 9, 4, 8, 0, tzinfo=UTC)
|
|
246
|
+
store.set(f"pg:windowBudget:u1:{int(last.timestamp())}", "1")
|
|
247
|
+
d = evaluate([checks.WindowBudget(limit=1, within_hours=48)], u=user(last_inbound_at=last), store=store, now=NOON)
|
|
248
|
+
assert explain(d).summary == "Held because the budget for the window opened by the user's last message was already spent (1 of 1 used)."
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_gate_entries_fail_open_closed_and_ignored_non_rejecting() -> None:
|
|
252
|
+
class Boom(checks.BaseCheck):
|
|
253
|
+
id = "mystery"
|
|
254
|
+
|
|
255
|
+
def run(self, ctx: Any, values: Any) -> Any:
|
|
256
|
+
raise RuntimeError("boom")
|
|
257
|
+
|
|
258
|
+
opened = evaluate([Boom(), checks.Consent()], now=NOON)
|
|
259
|
+
assert explain(opened).checks[0].sentence == 'The "mystery" check failed with "boom", and the gate is set to let messages through when a check fails.'
|
|
260
|
+
closed = evaluate([Boom(), checks.Consent()], now=NOON, on_store_error="closed")
|
|
261
|
+
assert explain(closed).summary == 'Held because the "mystery" check failed with "boom", and the gate is set to stop messages when a check fails.'
|
|
262
|
+
|
|
263
|
+
class Polite(checks.BaseCheck):
|
|
264
|
+
id = "polite"
|
|
265
|
+
non_rejecting = True
|
|
266
|
+
|
|
267
|
+
def run(self, ctx: Any, values: Any) -> Any:
|
|
268
|
+
from proactive_gate.types import Outcome
|
|
269
|
+
|
|
270
|
+
return Outcome("reject", "felt like it")
|
|
271
|
+
|
|
272
|
+
ignored = evaluate([Polite(), checks.Consent()], now=NOON)
|
|
273
|
+
assert explain(ignored).checks[0].sentence == 'The "polite" check tried to reject ("felt like it") but is marked non-rejecting, so the gate ignored it.'
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_shadowed_stop_is_what_would_have_happened() -> None:
|
|
277
|
+
class Shadowed(checks.BaseCheck):
|
|
278
|
+
id = "quietHours"
|
|
279
|
+
shadow = True
|
|
280
|
+
|
|
281
|
+
def run(self, ctx: Any, values: Any) -> Any:
|
|
282
|
+
from proactive_gate.types import Outcome
|
|
283
|
+
|
|
284
|
+
return Outcome("reject", "quiet hours 22:00 to 08:00 Europe/Istanbul; priority normal is below the floor (critical)")
|
|
285
|
+
|
|
286
|
+
d = evaluate([Shadowed(), checks.Consent()], now=NIGHT)
|
|
287
|
+
e = explain(d)
|
|
288
|
+
assert e.checks[0].shadow
|
|
289
|
+
assert e.checks[0].sentence == (
|
|
290
|
+
"It would have stopped the message (the user's quiet hours run 22:00 to 08:00 Europe/Istanbul "
|
|
291
|
+
"and normal priority is below the critical floor needed to override them), but the check ran in shadow mode so evaluation continued."
|
|
292
|
+
)
|
|
293
|
+
assert "quietHours would have stopped it but ran in shadow mode" in e.summary
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_shadowed_pass_does_not_claim_it_would_have_stopped() -> None:
|
|
297
|
+
d = Decision(
|
|
298
|
+
id="u1:c1:2026-09-04T09:00:00.000Z#1",
|
|
299
|
+
allowed=True,
|
|
300
|
+
user_id="u1",
|
|
301
|
+
candidate_id="c1",
|
|
302
|
+
surfaces=("feed",),
|
|
303
|
+
trace=(TraceEntry(id="dailyBudget", outcome="pass", ms=0.0, reason="4 of 5 used", shadow=True),),
|
|
304
|
+
evaluated_at=NOON,
|
|
305
|
+
)
|
|
306
|
+
assert explain(d).checks[0].sentence == (
|
|
307
|
+
"The daily budget had room, but only just: 4 of 5 already used; "
|
|
308
|
+
"the unit is spent when the message actually goes out."
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def test_unknown_check_quotes_its_own_reason() -> None:
|
|
313
|
+
class Weekend(checks.BaseCheck):
|
|
314
|
+
id = "weekend"
|
|
315
|
+
|
|
316
|
+
def run(self, ctx: Any, values: Any) -> Any:
|
|
317
|
+
from proactive_gate.types import Outcome
|
|
318
|
+
|
|
319
|
+
return Outcome("reject", "weekend: only high priority")
|
|
320
|
+
|
|
321
|
+
d = evaluate([Weekend()], now=NOON)
|
|
322
|
+
assert explain(d).summary == 'Held because the "weekend" check stopped it: weekend: only high priority.'
|
|
323
|
+
|
|
324
|
+
class Weather(checks.BaseCheck):
|
|
325
|
+
id = "weather"
|
|
326
|
+
|
|
327
|
+
def run(self, ctx: Any, values: Any) -> Any:
|
|
328
|
+
from proactive_gate.types import Outcome
|
|
329
|
+
|
|
330
|
+
return Outcome("pass", "sunny out")
|
|
331
|
+
|
|
332
|
+
passing = evaluate([Weather(), Weekend()], now=NOON)
|
|
333
|
+
assert explain(passing).checks[0].sentence == 'The "weather" check let it through (sunny out).'
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_language_is_a_parameter() -> None:
|
|
337
|
+
d = evaluate([checks.QuietHours()], now=NIGHT)
|
|
338
|
+
assert explain(d).summary == explain(d, language="en").summary
|
|
339
|
+
with pytest.raises(ValueError, match='no sentence catalog for language "tr"'):
|
|
340
|
+
explain(d, language="tr")
|
|
341
|
+
tr = {"summary.held": lambda f: f"{f['until']} kadar bekletildi, çünkü {f['clause']}"}
|
|
342
|
+
e = explain(d, language="tr", catalogs={"tr": tr})
|
|
343
|
+
assert e.summary.startswith("08:00 kadar bekletildi, çünkü")
|
|
344
|
+
assert len(EN) > 0
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_hand_built_decision_without_a_stopping_check() -> None:
|
|
348
|
+
odd = Decision(
|
|
349
|
+
id="u1:c1:2026-09-04T09:00:00.000Z#1",
|
|
350
|
+
allowed=False,
|
|
351
|
+
user_id="u1",
|
|
352
|
+
candidate_id="c1",
|
|
353
|
+
surfaces=(),
|
|
354
|
+
trace=(),
|
|
355
|
+
evaluated_at=NOON,
|
|
356
|
+
)
|
|
357
|
+
assert explain(odd).summary == "Held; the trace does not name the check that stopped it."
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_the_templates_nothing_else_in_this_file_reaches() -> None:
|
|
361
|
+
"""Each of these was silent until it was asked for; the first person to read an unrendered sentence should not be a user."""
|
|
362
|
+
from proactive_gate.types import Outcome
|
|
363
|
+
|
|
364
|
+
monthly = MemoryStore()
|
|
365
|
+
monthly.set("pg:monthlyBudget:u1:2026-09", "60")
|
|
366
|
+
spent = evaluate([checks.MonthlyBudget()], store=monthly, now=NOON)
|
|
367
|
+
assert explain(spent).summary == "Held because the user's monthly budget of 60 was already spent (60 used)."
|
|
368
|
+
|
|
369
|
+
weekly = MemoryStore()
|
|
370
|
+
weekly.set("pg:weeklyBudget:u1:2026-W36", "20")
|
|
371
|
+
week_spent = evaluate([checks.WeeklyBudget()], store=weekly, now=NOON)
|
|
372
|
+
assert explain(week_spent).summary == "Held because the user's weekly budget of 20 was already spent (20 used)."
|
|
373
|
+
|
|
374
|
+
# A budget nowhere near its limit passes without a reason, which is a
|
|
375
|
+
# different sentence from the near-limit one.
|
|
376
|
+
room = evaluate([checks.DailyBudget(limit=5)], now=NOON)
|
|
377
|
+
assert explain(room).checks[0].sentence == "The daily budget did not stop it."
|
|
378
|
+
|
|
379
|
+
rate = evaluate([checks.RateLimit(limit=20, per_seconds=60)], now=NOON)
|
|
380
|
+
assert explain(rate).checks[0].sentence == "The rate limit did not stop it."
|
|
381
|
+
|
|
382
|
+
unplaced = evaluate(
|
|
383
|
+
[checks.RequiresConsent("night", when={"start": "21:00", "end": "08:00", "timezone": "user"})],
|
|
384
|
+
u=user(timezone=None, consents={"night": True}),
|
|
385
|
+
now=NOON,
|
|
386
|
+
)
|
|
387
|
+
assert explain(unplaced).checks[0].sentence == "The user has no time zone, so the hours this consent applies could not be checked."
|
|
388
|
+
|
|
389
|
+
# Somebody else's budget: a reason shaped like one, under a label this
|
|
390
|
+
# package does not ship, keeps the caller's own words.
|
|
391
|
+
class TeamQuota(checks.BaseCheck):
|
|
392
|
+
id = "teamQuota"
|
|
393
|
+
|
|
394
|
+
def run(self, ctx: Any, values: Any) -> Any:
|
|
395
|
+
return Outcome("reject", "team quota of 5 used (5)")
|
|
396
|
+
|
|
397
|
+
assert explain(evaluate([TeamQuota()], now=NOON)).summary == "Held because the team quota was already used up (5 of 5 used)."
|
|
398
|
+
|
|
399
|
+
class TeamQuotaNear(checks.BaseCheck):
|
|
400
|
+
id = "teamQuota"
|
|
401
|
+
|
|
402
|
+
def run(self, ctx: Any, values: Any) -> Any:
|
|
403
|
+
return Outcome("pass", "4 of 5 used")
|
|
404
|
+
|
|
405
|
+
near = evaluate([TeamQuotaNear()], now=NOON)
|
|
406
|
+
assert explain(near).checks[0].sentence == (
|
|
407
|
+
"The budget had room, but only just: 4 of 5 already used; the unit is spent when the message actually goes out."
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
class Weather(checks.BaseCheck):
|
|
411
|
+
id = "weather"
|
|
412
|
+
|
|
413
|
+
def run(self, ctx: Any, values: Any) -> Any:
|
|
414
|
+
return Outcome("skip", "forecast service is down")
|
|
415
|
+
|
|
416
|
+
class Router(checks.BaseCheck):
|
|
417
|
+
id = "router"
|
|
418
|
+
|
|
419
|
+
def run(self, ctx: Any, values: Any) -> Any:
|
|
420
|
+
return Outcome("adjust", "sent via SMS instead")
|
|
421
|
+
|
|
422
|
+
odd = explain(evaluate([Weather(), Router()], now=NOON))
|
|
423
|
+
assert odd.checks[0].sentence == 'The "weather" check did not weigh in: forecast service is down.'
|
|
424
|
+
assert odd.checks[1].sentence == 'The "router" check adjusted it: sent via SMS instead.'
|
|
425
|
+
|
|
426
|
+
# A deferral whose clause does not say when: the hold instant comes from retry_at.
|
|
427
|
+
class Queue(checks.BaseCheck):
|
|
428
|
+
id = "queue"
|
|
429
|
+
|
|
430
|
+
def run(self, ctx: Any, values: Any) -> Any:
|
|
431
|
+
return Outcome("defer", "the send queue is draining", retry_at=datetime(2026, 9, 4, 10, 30, tzinfo=UTC))
|
|
432
|
+
|
|
433
|
+
deferred = evaluate([Queue()], now=NOON)
|
|
434
|
+
assert explain(deferred).summary == 'Held until 2026-09-04T10:30:00.000Z because the "queue" check stopped it: the send queue is draining.'
|
|
435
|
+
|
|
436
|
+
empty = Decision(
|
|
437
|
+
id="u1:c1:2026-09-04T09:00:00.000Z#1",
|
|
438
|
+
allowed=True,
|
|
439
|
+
user_id="u1",
|
|
440
|
+
candidate_id="c1",
|
|
441
|
+
surfaces=("feed",),
|
|
442
|
+
trace=(),
|
|
443
|
+
evaluated_at=NOON,
|
|
444
|
+
)
|
|
445
|
+
assert explain(empty).summary == "Allowed; no checks ran."
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def test_every_trace_entry_renders() -> None:
|
|
449
|
+
store = MemoryStore()
|
|
450
|
+
gate = Gate(
|
|
451
|
+
[
|
|
452
|
+
*checks.default_checks(weekly_limit=20),
|
|
453
|
+
checks.Dedupe(),
|
|
454
|
+
checks.MonthlyBudget(),
|
|
455
|
+
checks.UtilityFloor(1, 1),
|
|
456
|
+
checks.BoundedDeferral(),
|
|
457
|
+
checks.AllowedWindow("08:00", "21:00", timezone="user"),
|
|
458
|
+
checks.RecentInteraction(48),
|
|
459
|
+
checks.WindowBudget(limit=1, within_hours=48),
|
|
460
|
+
],
|
|
461
|
+
store=store,
|
|
462
|
+
)
|
|
463
|
+
for now in (NOON, NIGHT, datetime(2026, 9, 6, 7, 0, tzinfo=UTC)):
|
|
464
|
+
d = gate.evaluate(EvaluateInput(user(last_inbound_at=datetime(2026, 9, 4, 8, 0, tzinfo=UTC)), candidate(dedupe_key="evt:1", p_accept=0.7), now))
|
|
465
|
+
e = explain(d)
|
|
466
|
+
assert len(e.checks) == len(d.trace)
|
|
467
|
+
for c in e.checks:
|
|
468
|
+
assert len(c.sentence) > 10, f"empty sentence for {c.id}"
|
|
469
|
+
assert c.sentence[0].isupper(), f"not a sentence for {c.id}: {c.sentence}"
|
|
470
|
+
# A check this package ships must have a template. Quoting its machine
|
|
471
|
+
# reason back is the honest answer for somebody else's check, and a
|
|
472
|
+
# silent regression for one of ours.
|
|
473
|
+
assert not re.search(r'" check (stopped it|let it through|did not weigh in|adjusted it)', c.sentence), f"{c.id} fell back to its machine reason: {c.sentence}"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|