proactive-gate 0.2.1__py3-none-any.whl
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/__init__.py +31 -0
- proactive_gate/checks.py +522 -0
- proactive_gate/clock.py +40 -0
- proactive_gate/conformance.py +134 -0
- proactive_gate/gate.py +395 -0
- proactive_gate/policy.py +94 -0
- proactive_gate/presets.py +128 -0
- proactive_gate/py.typed +0 -0
- proactive_gate/stores.py +168 -0
- proactive_gate/types.py +221 -0
- proactive_gate-0.2.1.dist-info/METADATA +131 -0
- proactive_gate-0.2.1.dist-info/RECORD +13 -0
- proactive_gate-0.2.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Runs the language-neutral fixtures under ``spec/fixtures`` against ``Gate`` or ``AsyncGate``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterator, Mapping
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .gate import AsyncGate, Gate
|
|
12
|
+
from .stores import AsyncMemoryStore, MemoryStore
|
|
13
|
+
from .types import Candidate, Decision, EvaluateInput, UserState, iso_z, to_datetime
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_fixtures(directory: str | Path) -> Iterator[dict[str, Any]]:
|
|
17
|
+
for path in sorted(Path(directory).rglob("*.json")):
|
|
18
|
+
with path.open(encoding="utf-8") as handle:
|
|
19
|
+
fixture: dict[str, Any] = json.load(handle)
|
|
20
|
+
yield fixture
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_skips(path: str | Path) -> dict[str, str]:
|
|
24
|
+
skips: dict[str, str] = {}
|
|
25
|
+
try:
|
|
26
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
27
|
+
except FileNotFoundError:
|
|
28
|
+
return skips
|
|
29
|
+
for line in text.splitlines():
|
|
30
|
+
line = line.strip()
|
|
31
|
+
if not line or line.startswith("#"):
|
|
32
|
+
continue
|
|
33
|
+
name, _, reason = line.partition("#")
|
|
34
|
+
skips[name.strip()] = reason.strip()
|
|
35
|
+
return skips
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _input(test: Mapping[str, Any]) -> EvaluateInput:
|
|
39
|
+
raw = test["input"]
|
|
40
|
+
now = to_datetime(raw["now"])
|
|
41
|
+
if now is None:
|
|
42
|
+
raise ValueError(f"bad now {raw['now']!r}")
|
|
43
|
+
return EvaluateInput(UserState.from_dict(raw["user"]), Candidate.from_dict(raw["candidate"]), now)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _compare(failures: list[str], at: str, decision: Decision, expect: Mapping[str, Any]) -> None:
|
|
47
|
+
def check(field: str, actual: object, expected: object) -> None:
|
|
48
|
+
if json.dumps(actual, sort_keys=True) != json.dumps(expected, sort_keys=True):
|
|
49
|
+
failures.append(f"{at}: {field} expected {json.dumps(expected)}, got {json.dumps(actual)}")
|
|
50
|
+
|
|
51
|
+
check("allowed", decision.allowed, expect["allowed"])
|
|
52
|
+
check("trace", [t.id for t in decision.trace], expect["trace"])
|
|
53
|
+
check("rejectedBy", decision.rejected_by, expect.get("rejectedBy"))
|
|
54
|
+
check("deferredBy", decision.deferred_by, expect.get("deferredBy"))
|
|
55
|
+
check("retryAt", iso_z(decision.retry_at) if decision.retry_at else None, expect.get("retryAt"))
|
|
56
|
+
if "surfaces" in expect:
|
|
57
|
+
check("surfaces", list(decision.surfaces), expect["surfaces"])
|
|
58
|
+
check("deliverAt", iso_z(decision.deliver_at) if decision.deliver_at else None, expect.get("deliverAt"))
|
|
59
|
+
if "shadowed" in expect:
|
|
60
|
+
check("shadowed", list(decision.shadowed), expect["shadowed"])
|
|
61
|
+
if "nearLimit" in expect:
|
|
62
|
+
check("nearLimit", [asdict(n) for n in decision.near_limit], expect["nearLimit"])
|
|
63
|
+
pattern = expect.get("reason_pattern")
|
|
64
|
+
if pattern and not (decision.reason and re.search(pattern, decision.reason)):
|
|
65
|
+
failures.append(f"{at}: reason {json.dumps(decision.reason)} does not match /{pattern}/")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run_fixture(fixture: Mapping[str, Any]) -> list[str]:
|
|
69
|
+
"""Synchronous gate. Returns the mismatches; empty means the fixture conforms."""
|
|
70
|
+
failures: list[str] = []
|
|
71
|
+
store = MemoryStore()
|
|
72
|
+
prefix = fixture["policy"].get("keyPrefix", "pg:")
|
|
73
|
+
for key, value in (fixture.get("store_seed") or {}).items():
|
|
74
|
+
store.set(prefix + key, value)
|
|
75
|
+
try:
|
|
76
|
+
gate = Gate.from_policy(fixture["policy"], store)
|
|
77
|
+
except ValueError as error:
|
|
78
|
+
return _policy_error(fixture, failures, str(error))
|
|
79
|
+
for i, test in enumerate(fixture["tests"]):
|
|
80
|
+
at = f"{fixture['name']} [{i}] {test['description']}"
|
|
81
|
+
inp = _input(test)
|
|
82
|
+
decision = gate.evaluate(inp)
|
|
83
|
+
expect = test["expect"]
|
|
84
|
+
_compare(failures, at, decision, expect)
|
|
85
|
+
if test.get("commit"):
|
|
86
|
+
committed = gate.commit(decision, inp)
|
|
87
|
+
if "commit" in expect and committed != expect["commit"]:
|
|
88
|
+
failures.append(f"{at}: commit expected {expect['commit']}, got {committed}")
|
|
89
|
+
for key, value in (expect.get("store_after") or {}).items():
|
|
90
|
+
actual = store.get(prefix + key)
|
|
91
|
+
if actual != value:
|
|
92
|
+
failures.append(f"{at}: store {key} expected {json.dumps(value)}, got {json.dumps(actual)}")
|
|
93
|
+
return failures
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def run_fixture_async(fixture: Mapping[str, Any]) -> list[str]:
|
|
97
|
+
"""The same fixture through ``AsyncGate`` and ``AsyncMemoryStore``."""
|
|
98
|
+
failures: list[str] = []
|
|
99
|
+
store = AsyncMemoryStore()
|
|
100
|
+
prefix = fixture["policy"].get("keyPrefix", "pg:")
|
|
101
|
+
for key, value in (fixture.get("store_seed") or {}).items():
|
|
102
|
+
await store.set(prefix + key, value)
|
|
103
|
+
try:
|
|
104
|
+
gate = AsyncGate.from_policy(fixture["policy"], store)
|
|
105
|
+
except ValueError as error:
|
|
106
|
+
return _policy_error(fixture, failures, str(error))
|
|
107
|
+
for i, test in enumerate(fixture["tests"]):
|
|
108
|
+
at = f"{fixture['name']} [{i}] {test['description']}"
|
|
109
|
+
inp = _input(test)
|
|
110
|
+
decision = await gate.evaluate(inp)
|
|
111
|
+
expect = test["expect"]
|
|
112
|
+
_compare(failures, at, decision, expect)
|
|
113
|
+
if test.get("commit"):
|
|
114
|
+
committed = await gate.commit(decision, inp)
|
|
115
|
+
if "commit" in expect and committed != expect["commit"]:
|
|
116
|
+
failures.append(f"{at}: commit expected {expect['commit']}, got {committed}")
|
|
117
|
+
for key, value in (expect.get("store_after") or {}).items():
|
|
118
|
+
actual = await store.get(prefix + key)
|
|
119
|
+
if actual != value:
|
|
120
|
+
failures.append(f"{at}: store {key} expected {json.dumps(value)}, got {json.dumps(actual)}")
|
|
121
|
+
return failures
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _policy_error(fixture: Mapping[str, Any], failures: list[str], message: str) -> list[str]:
|
|
125
|
+
"""A fixture whose policy must fail to compile says so through a test whose expect carries ``policy_error``."""
|
|
126
|
+
for i, test in enumerate(fixture["tests"]):
|
|
127
|
+
pattern = test["expect"].get("reason_pattern")
|
|
128
|
+
if pattern and re.search(pattern, message):
|
|
129
|
+
continue
|
|
130
|
+
failures.append(f"{fixture['name']} [{i}] {test['description']}: policy failed to compile: {message}")
|
|
131
|
+
return failures
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
__all__ = ["load_fixtures", "read_skips", "run_fixture", "run_fixture_async"]
|
proactive_gate/gate.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""The decision loop exists once, as a sans-IO ``Evaluation``. ``Gate`` and
|
|
2
|
+
``AsyncGate`` only fetch keys and feed values; every rule about ordering,
|
|
3
|
+
shadow mode, deferral, hooks and idempotent commit lives here."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .checks import Budget, Check, OnlyWhen, budget_key, dismissal_key
|
|
14
|
+
from .clock import DAY_SECONDS
|
|
15
|
+
from .stores import AsyncStore, MemoryStore, Store
|
|
16
|
+
from .types import (
|
|
17
|
+
UTC,
|
|
18
|
+
Candidate,
|
|
19
|
+
ConsumePlan,
|
|
20
|
+
Context,
|
|
21
|
+
Decision,
|
|
22
|
+
EvaluateInput,
|
|
23
|
+
NearLimitNote,
|
|
24
|
+
Outcome,
|
|
25
|
+
OutcomeEvent,
|
|
26
|
+
StoreErrorMode,
|
|
27
|
+
TraceEntry,
|
|
28
|
+
UserState,
|
|
29
|
+
epoch_ms,
|
|
30
|
+
iso_z,
|
|
31
|
+
surfaces_for,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
COMMIT_TTL = 2 * DAY_SECONDS
|
|
35
|
+
Values = Mapping[str, str | None]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class Hooks:
|
|
40
|
+
"""Observation points. Hooks never change a decision; a raising hook is reported to ``error`` and ignored."""
|
|
41
|
+
|
|
42
|
+
before: Callable[[Context, Check], None] | None = None
|
|
43
|
+
after: Callable[[Context, Check, Outcome, float], None] | None = None
|
|
44
|
+
error: Callable[[Context, Check, BaseException], None] | None = None
|
|
45
|
+
finally_: Callable[[Decision], None] | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_sequence = 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _next_sequence() -> int:
|
|
52
|
+
global _sequence
|
|
53
|
+
_sequence += 1
|
|
54
|
+
return _sequence
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(slots=True)
|
|
58
|
+
class Evaluation:
|
|
59
|
+
"""Pure state machine: ask ``pending()`` which check is next, fetch ``keys()``, ``feed()`` the values, repeat."""
|
|
60
|
+
|
|
61
|
+
checks: Sequence[Check]
|
|
62
|
+
input: EvaluateInput
|
|
63
|
+
on_store_error: StoreErrorMode = "open"
|
|
64
|
+
hooks: Hooks = field(default_factory=Hooks)
|
|
65
|
+
now: datetime = field(init=False)
|
|
66
|
+
_index: int = 0
|
|
67
|
+
_trace: list[TraceEntry] = field(default_factory=list)
|
|
68
|
+
_shadowed: list[str] = field(default_factory=list)
|
|
69
|
+
_near: list[NearLimitNote] = field(default_factory=list)
|
|
70
|
+
_surfaces: tuple[str, ...] = ()
|
|
71
|
+
_deliver_at: datetime | None = None
|
|
72
|
+
_result: Decision | None = None
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
self.now = self.input.now or datetime.now(UTC)
|
|
76
|
+
self._surfaces = surfaces_for(self.input.user, self.input.candidate)
|
|
77
|
+
|
|
78
|
+
def context(self) -> Context:
|
|
79
|
+
return Context(self.input.user, self.input.candidate, self.now, self.input.candidate.priority, self._surfaces)
|
|
80
|
+
|
|
81
|
+
def pending(self) -> Check | None:
|
|
82
|
+
if self._result is not None or self._index >= len(self.checks):
|
|
83
|
+
return None
|
|
84
|
+
return self.checks[self._index]
|
|
85
|
+
|
|
86
|
+
def keys(self) -> Sequence[str]:
|
|
87
|
+
check = self.pending()
|
|
88
|
+
return check.keys(self.context()) if check else ()
|
|
89
|
+
|
|
90
|
+
def _call(self, name: str, *args: Any) -> None:
|
|
91
|
+
hook = getattr(self.hooks, name)
|
|
92
|
+
if hook is None:
|
|
93
|
+
return
|
|
94
|
+
try:
|
|
95
|
+
hook(*args)
|
|
96
|
+
except BaseException as error: # noqa: BLE001 - hooks must never break a decision
|
|
97
|
+
if name != "error" and self.hooks.error is not None and len(args) >= 2:
|
|
98
|
+
try:
|
|
99
|
+
self.hooks.error(args[0], args[1], error)
|
|
100
|
+
except BaseException: # noqa: BLE001
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
def feed(self, values: Values | None, error: BaseException | None = None) -> None:
|
|
104
|
+
check = self.pending()
|
|
105
|
+
if check is None:
|
|
106
|
+
raise RuntimeError("nothing pending")
|
|
107
|
+
ctx = self.context()
|
|
108
|
+
self._index += 1
|
|
109
|
+
self._call("before", ctx, check)
|
|
110
|
+
started = time.perf_counter()
|
|
111
|
+
outcome: Outcome
|
|
112
|
+
if error is None:
|
|
113
|
+
try:
|
|
114
|
+
outcome = check.run(ctx, values or {})
|
|
115
|
+
except BaseException as raised: # noqa: BLE001 - a check failure is a trace entry, not a crash
|
|
116
|
+
error = raised
|
|
117
|
+
if error is not None:
|
|
118
|
+
message = str(error) or error.__class__.__name__
|
|
119
|
+
self._call("error", ctx, check, error)
|
|
120
|
+
ms = _elapsed(started)
|
|
121
|
+
if self.on_store_error == "closed":
|
|
122
|
+
self._trace.append(TraceEntry(check.id, "reject", ms, f"check threw ({message}); failing closed"))
|
|
123
|
+
self._finish(rejected_by=check.id, reason=f'check "{check.id}" failed and the gate fails closed: {message}')
|
|
124
|
+
return
|
|
125
|
+
self._trace.append(TraceEntry(check.id, "skip", ms, f"check threw ({message}); failing open"))
|
|
126
|
+
self._maybe_finish()
|
|
127
|
+
return
|
|
128
|
+
ms = _elapsed(started)
|
|
129
|
+
self._call("after", ctx, check, outcome, ms)
|
|
130
|
+
if check.non_rejecting and outcome.kind in ("reject", "defer"):
|
|
131
|
+
self._trace.append(TraceEntry(check.id, "skip", ms, f"non-rejecting check returned {outcome.kind} ({outcome.reason}); ignored"))
|
|
132
|
+
self._maybe_finish()
|
|
133
|
+
return
|
|
134
|
+
stops = outcome.kind in ("reject", "defer")
|
|
135
|
+
self._trace.append(TraceEntry(check.id, outcome.kind, ms, outcome.reason or None, shadow=bool(stops and check.shadow)))
|
|
136
|
+
if outcome.kind == "pass" and outcome.near_limit is not None:
|
|
137
|
+
self._near.append(NearLimitNote(check.id, outcome.near_limit.used, outcome.near_limit.limit))
|
|
138
|
+
if stops and check.shadow:
|
|
139
|
+
self._shadowed.append(check.id)
|
|
140
|
+
self._maybe_finish()
|
|
141
|
+
return
|
|
142
|
+
if outcome.kind == "reject":
|
|
143
|
+
self._finish(rejected_by=check.id, reason=outcome.reason)
|
|
144
|
+
return
|
|
145
|
+
if outcome.kind == "defer":
|
|
146
|
+
self._finish(deferred_by=check.id, retry_at=outcome.retry_at, reason=outcome.reason)
|
|
147
|
+
return
|
|
148
|
+
if outcome.kind == "adjust":
|
|
149
|
+
if outcome.deliver_at is not None:
|
|
150
|
+
self._deliver_at = outcome.deliver_at
|
|
151
|
+
if outcome.surfaces is not None:
|
|
152
|
+
self._surfaces = tuple(outcome.surfaces)
|
|
153
|
+
self._maybe_finish()
|
|
154
|
+
|
|
155
|
+
def _maybe_finish(self) -> None:
|
|
156
|
+
if self._index >= len(self.checks):
|
|
157
|
+
self._finish(allowed=True)
|
|
158
|
+
|
|
159
|
+
def _finish(
|
|
160
|
+
self,
|
|
161
|
+
allowed: bool = False,
|
|
162
|
+
rejected_by: str | None = None,
|
|
163
|
+
deferred_by: str | None = None,
|
|
164
|
+
retry_at: datetime | None = None,
|
|
165
|
+
reason: str | None = None,
|
|
166
|
+
) -> None:
|
|
167
|
+
user, candidate = self.input.user, self.input.candidate
|
|
168
|
+
self._result = Decision(
|
|
169
|
+
id=f"{user.id}:{candidate.id}:{iso_z(self.now)}#{_next_sequence()}",
|
|
170
|
+
allowed=allowed,
|
|
171
|
+
user_id=user.id,
|
|
172
|
+
candidate_id=candidate.id,
|
|
173
|
+
surfaces=self._surfaces if allowed else (),
|
|
174
|
+
trace=tuple(self._trace),
|
|
175
|
+
evaluated_at=self.now,
|
|
176
|
+
shadowed=tuple(self._shadowed),
|
|
177
|
+
near_limit=tuple(self._near),
|
|
178
|
+
deliver_at=self._deliver_at if allowed else None,
|
|
179
|
+
rejected_by=rejected_by,
|
|
180
|
+
deferred_by=deferred_by,
|
|
181
|
+
retry_at=retry_at,
|
|
182
|
+
reason=reason,
|
|
183
|
+
)
|
|
184
|
+
self._call("finally_", self._result)
|
|
185
|
+
|
|
186
|
+
def decision(self) -> Decision:
|
|
187
|
+
if self._result is None:
|
|
188
|
+
raise RuntimeError("evaluation has not finished")
|
|
189
|
+
return self._result
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def decide(checks: Sequence[Check], input: EvaluateInput, values: Mapping[str, Values] | None = None, hooks: Hooks | None = None, on_store_error: StoreErrorMode = "open") -> Decision:
|
|
193
|
+
"""Pure decision from precomputed values, keyed by check id. No store, no clock beyond ``input.now``."""
|
|
194
|
+
ev = Evaluation(checks, input, on_store_error, hooks or Hooks())
|
|
195
|
+
while (check := ev.pending()) is not None:
|
|
196
|
+
ev.feed((values or {}).get(check.id, {}))
|
|
197
|
+
return ev.decision()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _elapsed(started: float) -> float:
|
|
201
|
+
return round((time.perf_counter() - started) * 1000, 3)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _consumers(checks: Iterable[Check]) -> list[Check]:
|
|
205
|
+
return [c for c in checks if isinstance(c, (Budget, OnlyWhen)) and hasattr(c, "consume_plan")]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _plan(check: Check, ctx: Context) -> ConsumePlan | None:
|
|
209
|
+
plan = getattr(check, "consume_plan")(ctx)
|
|
210
|
+
return plan if isinstance(plan, ConsumePlan) else None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class _Prefixed:
|
|
214
|
+
def __init__(self, prefix: str) -> None:
|
|
215
|
+
self.prefix = prefix
|
|
216
|
+
|
|
217
|
+
def key(self, key: str) -> str:
|
|
218
|
+
return self.prefix + key
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class Gate:
|
|
222
|
+
"""Synchronous gate over a ``Store``."""
|
|
223
|
+
|
|
224
|
+
def __init__(
|
|
225
|
+
self,
|
|
226
|
+
checks: Sequence[Check],
|
|
227
|
+
store: Store | None = None,
|
|
228
|
+
on_store_error: StoreErrorMode = "open",
|
|
229
|
+
key_prefix: str = "pg:",
|
|
230
|
+
on_decision: Callable[[Decision], None] | None = None,
|
|
231
|
+
hooks: Hooks | None = None,
|
|
232
|
+
) -> None:
|
|
233
|
+
self.checks: tuple[Check, ...] = tuple(checks)
|
|
234
|
+
self.store: Store = store if store is not None else MemoryStore()
|
|
235
|
+
self.on_store_error: StoreErrorMode = on_store_error
|
|
236
|
+
self._p = _Prefixed(key_prefix)
|
|
237
|
+
self.on_decision = on_decision
|
|
238
|
+
self.hooks = hooks or Hooks()
|
|
239
|
+
self._consumers = _consumers(self.checks)
|
|
240
|
+
|
|
241
|
+
@classmethod
|
|
242
|
+
def from_policy(cls, policy: Mapping[str, Any], store: Store | None = None, on_decision: Callable[[Decision], None] | None = None, hooks: Hooks | None = None) -> "Gate":
|
|
243
|
+
from .policy import compile_policy
|
|
244
|
+
|
|
245
|
+
compiled = compile_policy(policy)
|
|
246
|
+
return cls(compiled.checks, store, compiled.on_store_error, compiled.key_prefix, on_decision, hooks)
|
|
247
|
+
|
|
248
|
+
def evaluate(self, input: EvaluateInput) -> Decision:
|
|
249
|
+
ev = Evaluation(self.checks, input, self.on_store_error, self.hooks)
|
|
250
|
+
while ev.pending() is not None:
|
|
251
|
+
try:
|
|
252
|
+
values = {k: self.store.get(self._p.key(k)) for k in ev.keys()}
|
|
253
|
+
except Exception as error: # noqa: BLE001 - a store failure is a trace entry
|
|
254
|
+
ev.feed(None, error)
|
|
255
|
+
continue
|
|
256
|
+
ev.feed(values)
|
|
257
|
+
decision = ev.decision()
|
|
258
|
+
if self.on_decision:
|
|
259
|
+
self.on_decision(decision)
|
|
260
|
+
return decision
|
|
261
|
+
|
|
262
|
+
def commit(self, decision: Decision, input: EvaluateInput) -> bool:
|
|
263
|
+
if not decision.allowed:
|
|
264
|
+
return False
|
|
265
|
+
if not self._consumers:
|
|
266
|
+
return True
|
|
267
|
+
now = input.now or decision.evaluated_at
|
|
268
|
+
marker = self._p.key(f"commit:{decision.id}")
|
|
269
|
+
try:
|
|
270
|
+
seen = self.store.get(marker)
|
|
271
|
+
if seen is not None:
|
|
272
|
+
return seen == "1"
|
|
273
|
+
ok = True
|
|
274
|
+
ctx = Context(input.user, input.candidate, now, input.candidate.priority, decision.surfaces)
|
|
275
|
+
for check in self._consumers:
|
|
276
|
+
plan = _plan(check, ctx)
|
|
277
|
+
if plan is None:
|
|
278
|
+
continue
|
|
279
|
+
if self.store.incr(self._p.key(plan.key), plan.ttl_seconds) > plan.limit:
|
|
280
|
+
ok = False
|
|
281
|
+
break
|
|
282
|
+
self.store.set(marker, "1" if ok else "0", COMMIT_TTL)
|
|
283
|
+
return ok
|
|
284
|
+
except Exception: # noqa: BLE001
|
|
285
|
+
return self.on_store_error == "open"
|
|
286
|
+
|
|
287
|
+
def record(self, user: UserState, candidate: Candidate, event: OutcomeEvent, at: datetime | None = None) -> None:
|
|
288
|
+
if event != "dismissed":
|
|
289
|
+
return
|
|
290
|
+
at = at or datetime.now(UTC)
|
|
291
|
+
key = self._p.key(dismissal_key(user.id, candidate.type))
|
|
292
|
+
raw = self.store.get(key)
|
|
293
|
+
stamps: list[int] = json.loads(raw) if raw else []
|
|
294
|
+
keep_from = epoch_ms(at) - 90 * DAY_SECONDS * 1000
|
|
295
|
+
stamps = [t for t in stamps if t >= keep_from] + [epoch_ms(at)]
|
|
296
|
+
self.store.set(key, json.dumps(stamps, separators=(",", ":")), 90 * DAY_SECONDS)
|
|
297
|
+
|
|
298
|
+
def inspect(self, user: UserState, now: datetime | None = None) -> dict[str, Any]:
|
|
299
|
+
now = now or datetime.now(UTC)
|
|
300
|
+
used = int(self.store.get(self._p.key(budget_key(user.id, now, user.timezone))) or 0)
|
|
301
|
+
dismissals: dict[str, int] = {}
|
|
302
|
+
for type_ in user.muted_types:
|
|
303
|
+
raw = self.store.get(self._p.key(dismissal_key(user.id, type_)))
|
|
304
|
+
dismissals[type_] = len(json.loads(raw)) if raw else 0
|
|
305
|
+
return {"budgetUsed": used, "dismissals": dismissals}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class AsyncGate:
|
|
309
|
+
"""The same gate over an ``AsyncStore``; the decision logic is the shared ``Evaluation``."""
|
|
310
|
+
|
|
311
|
+
def __init__(
|
|
312
|
+
self,
|
|
313
|
+
checks: Sequence[Check],
|
|
314
|
+
store: AsyncStore,
|
|
315
|
+
on_store_error: StoreErrorMode = "open",
|
|
316
|
+
key_prefix: str = "pg:",
|
|
317
|
+
on_decision: Callable[[Decision], None] | None = None,
|
|
318
|
+
hooks: Hooks | None = None,
|
|
319
|
+
) -> None:
|
|
320
|
+
self.checks: tuple[Check, ...] = tuple(checks)
|
|
321
|
+
self.store = store
|
|
322
|
+
self.on_store_error: StoreErrorMode = on_store_error
|
|
323
|
+
self._p = _Prefixed(key_prefix)
|
|
324
|
+
self.on_decision = on_decision
|
|
325
|
+
self.hooks = hooks or Hooks()
|
|
326
|
+
self._consumers = _consumers(self.checks)
|
|
327
|
+
|
|
328
|
+
@classmethod
|
|
329
|
+
def from_policy(cls, policy: Mapping[str, Any], store: AsyncStore, on_decision: Callable[[Decision], None] | None = None, hooks: Hooks | None = None) -> "AsyncGate":
|
|
330
|
+
from .policy import compile_policy
|
|
331
|
+
|
|
332
|
+
compiled = compile_policy(policy)
|
|
333
|
+
return cls(compiled.checks, store, compiled.on_store_error, compiled.key_prefix, on_decision, hooks)
|
|
334
|
+
|
|
335
|
+
async def evaluate(self, input: EvaluateInput) -> Decision:
|
|
336
|
+
ev = Evaluation(self.checks, input, self.on_store_error, self.hooks)
|
|
337
|
+
while ev.pending() is not None:
|
|
338
|
+
try:
|
|
339
|
+
values = {k: await self.store.get(self._p.key(k)) for k in ev.keys()}
|
|
340
|
+
except Exception as error: # noqa: BLE001
|
|
341
|
+
ev.feed(None, error)
|
|
342
|
+
continue
|
|
343
|
+
ev.feed(values)
|
|
344
|
+
decision = ev.decision()
|
|
345
|
+
if self.on_decision:
|
|
346
|
+
self.on_decision(decision)
|
|
347
|
+
return decision
|
|
348
|
+
|
|
349
|
+
async def commit(self, decision: Decision, input: EvaluateInput) -> bool:
|
|
350
|
+
if not decision.allowed:
|
|
351
|
+
return False
|
|
352
|
+
if not self._consumers:
|
|
353
|
+
return True
|
|
354
|
+
now = input.now or decision.evaluated_at
|
|
355
|
+
marker = self._p.key(f"commit:{decision.id}")
|
|
356
|
+
try:
|
|
357
|
+
seen = await self.store.get(marker)
|
|
358
|
+
if seen is not None:
|
|
359
|
+
return seen == "1"
|
|
360
|
+
ok = True
|
|
361
|
+
ctx = Context(input.user, input.candidate, now, input.candidate.priority, decision.surfaces)
|
|
362
|
+
for check in self._consumers:
|
|
363
|
+
plan = _plan(check, ctx)
|
|
364
|
+
if plan is None:
|
|
365
|
+
continue
|
|
366
|
+
if await self.store.incr(self._p.key(plan.key), plan.ttl_seconds) > plan.limit:
|
|
367
|
+
ok = False
|
|
368
|
+
break
|
|
369
|
+
await self.store.set(marker, "1" if ok else "0", COMMIT_TTL)
|
|
370
|
+
return ok
|
|
371
|
+
except Exception: # noqa: BLE001
|
|
372
|
+
return self.on_store_error == "open"
|
|
373
|
+
|
|
374
|
+
async def record(self, user: UserState, candidate: Candidate, event: OutcomeEvent, at: datetime | None = None) -> None:
|
|
375
|
+
if event != "dismissed":
|
|
376
|
+
return
|
|
377
|
+
at = at or datetime.now(UTC)
|
|
378
|
+
key = self._p.key(dismissal_key(user.id, candidate.type))
|
|
379
|
+
raw = await self.store.get(key)
|
|
380
|
+
stamps: list[int] = json.loads(raw) if raw else []
|
|
381
|
+
keep_from = epoch_ms(at) - 90 * DAY_SECONDS * 1000
|
|
382
|
+
stamps = [t for t in stamps if t >= keep_from] + [epoch_ms(at)]
|
|
383
|
+
await self.store.set(key, json.dumps(stamps, separators=(",", ":")), 90 * DAY_SECONDS)
|
|
384
|
+
|
|
385
|
+
async def inspect(self, user: UserState, now: datetime | None = None) -> dict[str, Any]:
|
|
386
|
+
now = now or datetime.now(UTC)
|
|
387
|
+
used = int(await self.store.get(self._p.key(budget_key(user.id, now, user.timezone))) or 0)
|
|
388
|
+
dismissals: dict[str, int] = {}
|
|
389
|
+
for type_ in user.muted_types:
|
|
390
|
+
raw = await self.store.get(self._p.key(dismissal_key(user.id, type_)))
|
|
391
|
+
dismissals[type_] = len(json.loads(raw)) if raw else 0
|
|
392
|
+
return {"budgetUsed": used, "dismissals": dismissals}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
__all__ = ["AsyncGate", "Evaluation", "Gate", "Hooks", "decide"]
|
proactive_gate/policy.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""JSON policies (spec/schema/policy.schema.json) compiled into checks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Callable, Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from . import checks as c
|
|
9
|
+
from .checks import Check
|
|
10
|
+
from .presets import presets
|
|
11
|
+
from .types import StoreErrorMode
|
|
12
|
+
|
|
13
|
+
Options = Mapping[str, Any]
|
|
14
|
+
Factory = Callable[[Options], Check]
|
|
15
|
+
|
|
16
|
+
SUPPORTED_MAJOR = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _budget(cls: type[c.Budget]) -> Factory:
|
|
20
|
+
return lambda o: cls(limit=o.get("limit"), bypass_priority=o.get("bypassPriority"), near_limit=o.get("nearLimit", 0.8))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
KNOWN_CHECKS: dict[str, Factory] = {
|
|
24
|
+
"killSwitch": lambda o: c.KillSwitch(bool(o.get("on", False))),
|
|
25
|
+
"consent": lambda o: c.Consent(),
|
|
26
|
+
"enabled": lambda o: c.Enabled(),
|
|
27
|
+
"mode": lambda o: c.Mode(o.get("allow", ["normal"])),
|
|
28
|
+
"snooze": lambda o: c.Snooze(defer=bool(o.get("defer", False))),
|
|
29
|
+
"mute": lambda o: c.Mute(),
|
|
30
|
+
"intensity": lambda o: c.Intensity(o.get("floors")),
|
|
31
|
+
"quietHours": lambda o: c.QuietHours(o.get("priorityFloor", "critical")),
|
|
32
|
+
"trustRamp": lambda o: c.TrustRamp(o.get("days", 7), o.get("minPriority", "high")),
|
|
33
|
+
"dismissalCooldown": lambda o: c.DismissalCooldown(o.get("dismissals", 3), o.get("withinDays", 30), o.get("silenceDays", 7)),
|
|
34
|
+
"adaptiveTiming": lambda o: c.AdaptiveTiming(),
|
|
35
|
+
"dailyBudget": _budget(c.DailyBudget),
|
|
36
|
+
"weeklyBudget": _budget(c.WeeklyBudget),
|
|
37
|
+
"monthlyBudget": _budget(c.MonthlyBudget),
|
|
38
|
+
"utilityFloor": lambda o: c.UtilityFloor(o.get("costFalseAlarm", 1), o.get("costMissedHelp", 1)),
|
|
39
|
+
"boundedDeferral": lambda o: c.BoundedDeferral(o.get("lambda", 1 / 43), o.get("interruptCost", 1), o.get("staleness", 0.0001), o.get("boundSeconds", 240)),
|
|
40
|
+
"allowedWindow": lambda o: c.AllowedWindow(o.get("start", "08:00"), o.get("end", "21:00"), o.get("timezone", "user"), o.get("priorityFloor"), o.get("id", "allowedWindow")),
|
|
41
|
+
"requiresConsent": lambda o: c.RequiresConsent(o.get("name", "consent"), o.get("when"), o.get("id")),
|
|
42
|
+
"rateLimit": lambda o: c.RateLimit(o.get("limit", 1), o.get("perSeconds", 1), o.get("keyBy", "user"), o.get("id")),
|
|
43
|
+
"recentInteraction": lambda o: c.RecentInteraction(o.get("withinHours", 48)),
|
|
44
|
+
"windowBudget": lambda o: c.WindowBudget(o.get("limit", 1), o.get("withinHours", 48)),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class CompiledPolicy:
|
|
50
|
+
checks: tuple[Check, ...]
|
|
51
|
+
on_store_error: StoreErrorMode
|
|
52
|
+
key_prefix: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def compile_policy(policy: Mapping[str, Any]) -> CompiledPolicy:
|
|
56
|
+
"""Raises ``ValueError`` on an unknown id or preset (naming the known ones) or an unsupported spec major."""
|
|
57
|
+
version = str(policy.get("specVersion", ""))
|
|
58
|
+
major = version.split(".")[0]
|
|
59
|
+
if not major.isdigit() or int(major) != SUPPORTED_MAJOR:
|
|
60
|
+
raise ValueError(f"policy specVersion {version} is not supported; this package implements spec {SUPPORTED_MAJOR}.x")
|
|
61
|
+
entries = policy.get("checks")
|
|
62
|
+
if not isinstance(entries, list) or not entries:
|
|
63
|
+
raise ValueError("policy.checks must be a non-empty array")
|
|
64
|
+
compiled: list[Check] = []
|
|
65
|
+
for entry in entries:
|
|
66
|
+
if not isinstance(entry, Mapping):
|
|
67
|
+
raise ValueError("each policy entry needs an id or a preset")
|
|
68
|
+
options = {k: v for k, v in entry.items() if k not in ("id", "preset", "shadow")}
|
|
69
|
+
preset_name = entry.get("preset")
|
|
70
|
+
check_id = entry.get("id")
|
|
71
|
+
built: list[Check]
|
|
72
|
+
if isinstance(preset_name, str):
|
|
73
|
+
preset = presets.get(preset_name)
|
|
74
|
+
if preset is None:
|
|
75
|
+
raise ValueError(f'unknown preset "{preset_name}"; known presets: {", ".join(presets)}')
|
|
76
|
+
built = preset(options)
|
|
77
|
+
elif isinstance(check_id, str):
|
|
78
|
+
factory = KNOWN_CHECKS.get(check_id)
|
|
79
|
+
if factory is None:
|
|
80
|
+
raise ValueError(f'unknown check "{check_id}"; known checks: {", ".join(KNOWN_CHECKS)}')
|
|
81
|
+
built = [factory(options)]
|
|
82
|
+
else:
|
|
83
|
+
raise ValueError("each policy entry needs an id or a preset")
|
|
84
|
+
if entry.get("shadow"):
|
|
85
|
+
for check in built:
|
|
86
|
+
check.shadow = True
|
|
87
|
+
compiled.extend(built)
|
|
88
|
+
on_store_error: StoreErrorMode = "closed" if policy.get("onStoreError") == "closed" else "open"
|
|
89
|
+
return CompiledPolicy(tuple(compiled), on_store_error, str(policy.get("keyPrefix", "pg:")))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
load_policy = compile_policy
|
|
93
|
+
|
|
94
|
+
__all__ = ["KNOWN_CHECKS", "CompiledPolicy", "compile_policy", "load_policy"]
|