perceptkit 0.2.2__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.
Files changed (59) hide show
  1. perceptkit/__init__.py +85 -0
  2. perceptkit/algorithms/__init__.py +40 -0
  3. perceptkit/algorithms/attribution.py +147 -0
  4. perceptkit/algorithms/glance.py +236 -0
  5. perceptkit/algorithms/history.py +663 -0
  6. perceptkit/algorithms/identity.py +43 -0
  7. perceptkit/algorithms/observation.py +44 -0
  8. perceptkit/algorithms/streaks.py +111 -0
  9. perceptkit/algorithms/trend_models.py +184 -0
  10. perceptkit/algorithms/wake.py +149 -0
  11. perceptkit/catalog.py +252 -0
  12. perceptkit/conformance/__init__.py +28 -0
  13. perceptkit/conformance/memory.py +364 -0
  14. perceptkit/conformance/report.py +170 -0
  15. perceptkit/conformance/suite.py +419 -0
  16. perceptkit/conformance/wake.py +151 -0
  17. perceptkit/contracts/__init__.py +97 -0
  18. perceptkit/contracts/_time.py +89 -0
  19. perceptkit/contracts/availability.py +77 -0
  20. perceptkit/contracts/context.py +50 -0
  21. perceptkit/contracts/delivery.py +167 -0
  22. perceptkit/contracts/errors.py +22 -0
  23. perceptkit/contracts/event.py +137 -0
  24. perceptkit/contracts/observation.py +172 -0
  25. perceptkit/contracts/receipt.py +129 -0
  26. perceptkit/contracts/records.py +367 -0
  27. perceptkit/contracts/report.py +127 -0
  28. perceptkit/contracts/versioning.py +63 -0
  29. perceptkit/fields.py +184 -0
  30. perceptkit/kit.py +223 -0
  31. perceptkit/manifest/__init__.py +57 -0
  32. perceptkit/manifest/checks.py +323 -0
  33. perceptkit/manifest/mapping.py +96 -0
  34. perceptkit/manifest/minimal.py +1282 -0
  35. perceptkit/manifest/types.py +211 -0
  36. perceptkit/manifest/units.py +84 -0
  37. perceptkit/ports/__init__.py +19 -0
  38. perceptkit/ports/storage.py +288 -0
  39. perceptkit/ports/wake.py +43 -0
  40. perceptkit/processing/__init__.py +49 -0
  41. perceptkit/processing/aggregate.py +80 -0
  42. perceptkit/processing/dispatch.py +356 -0
  43. perceptkit/processing/normalize.py +458 -0
  44. perceptkit/processing/pipeline.py +406 -0
  45. perceptkit/processing/recompute.py +170 -0
  46. perceptkit/processing/recurrence.py +166 -0
  47. perceptkit/processing/scheduled.py +233 -0
  48. perceptkit/prompts.py +75 -0
  49. perceptkit/queries/__init__.py +32 -0
  50. perceptkit/queries/api.py +457 -0
  51. perceptkit/retention.py +84 -0
  52. perceptkit/rules/__init__.py +19 -0
  53. perceptkit/rules/engine.py +112 -0
  54. perceptkit/rules/evaluators.py +228 -0
  55. perceptkit/rules/types.py +236 -0
  56. perceptkit-0.2.2.dist-info/METADATA +439 -0
  57. perceptkit-0.2.2.dist-info/RECORD +59 -0
  58. perceptkit-0.2.2.dist-info/WHEEL +4 -0
  59. perceptkit-0.2.2.dist-info/licenses/LICENSE +202 -0
perceptkit/catalog.py ADDED
@@ -0,0 +1,252 @@
1
+ """Capability catalog — the single source of truth for Extended Perception.
2
+
3
+ Every perceptual capability is declared here ONCE. This drives all the generic
4
+ machinery:
5
+ - the report endpoint (which input fields are accepted, which permission gates
6
+ them, how raw values resolve to labels, which state fields they produce)
7
+ - the snapshot endpoint (which fields are cheap context, their freshness TTL)
8
+ - wake triggering (which capabilities are wake sources + their debounce)
9
+ - the transparency UI (label + tier + default-on per capability)
10
+
11
+ Adding a Tier 2 capability = adding rows here + (if it has query tools) a thin
12
+ MCP pass-through. No changes to service/routes logic.
13
+
14
+ Privacy: capabilities whose `resolver` is set accept RAW values (lat/lon, ssid,
15
+ bundle id) and resolve them to coarse labels via the user's perception_config.
16
+ The raw value is used transiently and never written to perception_state, so the
17
+ agent only ever sees labels.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Capability:
26
+ """One perceptual ability (always available; authorization is implicit in
27
+ whether the client reports a value — see the host's design docs for the
28
+ reasoning behind implicit, report-based authorization)."""
29
+ key: str # canonical capability key
30
+ label: str # human copy for the transparency UI
31
+ tier: int # 1 = ships with V2, 2 = follow-up
32
+ wake_source: bool = False
33
+ debounce_sec: float = 0.0
34
+ context_field: bool = False # appears in cheap wake snapshot
35
+ query_tool: bool = False # agent pulls on demand
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Signal:
40
+ """One field the client may report, mapped to its capability + processing."""
41
+ input: str # key inside report `signals`
42
+ capability: str # permission key gating it
43
+ outputs: tuple[str, ...] # state field name(s) produced
44
+ resolver: str | None = None # name in resolve.RESOLVERS, or None (store as-is)
45
+ ttl_sec: float = 600.0 # snapshot freshness; older -> null
46
+ significant: bool = True # value change can trigger a wake (if wake_source)
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Capabilities
51
+ # ---------------------------------------------------------------------------
52
+
53
+ CAPABILITIES: dict[str, Capability] = {c.key: c for c in [
54
+ # --- always-on (no iOS permission) ---
55
+ Capability("time", "本地时间", 1, context_field=True),
56
+ Capability("device", "电量", 1, context_field=True),
57
+ # Starting/stopping a user-initiated share is a discrete presence edge. A
58
+ # shared cooldown prevents rapid iOS on/off flapping from producing a burst
59
+ # of proactive turns.
60
+ Capability("broadcast", "屏幕采集状态", 1,
61
+ wake_source=True, debounce_sec=60.0, context_field=True),
62
+
63
+ # --- permissioned ---
64
+ Capability("location", "你大概在哪里(只看地点标签,不看具体地址)", 1,
65
+ wake_source=True, debounce_sec=60.0, context_field=True, query_tool=True),
66
+ # Motion remains pullable context, but is intentionally not a wake source.
67
+ # It changes too frequently to justify interrupting the user — see
68
+ # wake.NOT_WAKE_WORTHY_SIGNALS, which suppresses it for the same reason.
69
+ Capability("motion", "你在动还是静止", 1, context_field=True),
70
+ Capability("calendar", "日历下一场日程", 1, context_field=True, query_tool=True),
71
+ Capability("now_playing", "你在听的音乐", 1, context_field=True, query_tool=True),
72
+ Capability("focus", "专注模式", 1, context_field=True),
73
+ Capability("audio_route", "音频输出路由", 2, query_tool=True),
74
+ # One switch covers BOTH resolutions (current field + the recent_apps
75
+ # history tool), so the copy has to say so — otherwise the user thinks they
76
+ # are allowing "which app now" and is actually allowing a usage trajectory.
77
+ Capability("app", "你最近打开了哪些 app(通过 iOS 快捷指令上报,含最近使用记录)", 1,
78
+ context_field=True, query_tool=True),
79
+ Capability("weather", "粗天气", 2, query_tool=True),
80
+ Capability("photos", "你拍的照片", 2, wake_source=True, query_tool=True),
81
+ Capability("reminders", "提醒事项", 2, query_tool=True),
82
+ Capability("health_sleep", "睡眠", 2, query_tool=True),
83
+ Capability("health_workout", "运动", 2, query_tool=True),
84
+ Capability("health_vitals", "身体趋势", 2, query_tool=True),
85
+ Capability("health_activity", "活动量(能量/锻炼/站立/正念)", 2, query_tool=True),
86
+ Capability("health_body", "身体测量(体重/BMI/体脂/身高)", 2, query_tool=True),
87
+ Capability("health_metabolic", "代谢点值(血糖/血压)", 2, query_tool=True),
88
+ Capability("health_cycle", "经期", 2, query_tool=True),
89
+ Capability("health_mood", "心情 / State of Mind", 2, query_tool=True),
90
+ ]}
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Signals (report inputs) — keyed by the iOS context_snapshot `key`.
95
+ # `data` (a JSON string) parses into the shapes in perception-report-fields.md;
96
+ # the resolver picks out the label/state fields and DISCARDS raw/precise fields
97
+ # (coordinates, BSSID, placemark address) so the agent only sees coarse state.
98
+ # ---------------------------------------------------------------------------
99
+
100
+ SIGNALS: dict[str, Signal] = {s.input: s for s in [
101
+ # always-on
102
+ Signal("time", "time", ("local_time", "timezone", "locale"),
103
+ resolver="time", ttl_sec=300.0, significant=False),
104
+ Signal("battery", "device", ("battery_level", "charging", "low_power_mode"),
105
+ resolver="battery", ttl_sec=600.0, significant=False),
106
+ Signal("broadcast", "broadcast", ("broadcast_state", "broadcast_active"),
107
+ resolver="broadcast", ttl_sec=300.0, significant=True),
108
+ Signal("focus", "focus", ("focus_authorization_status", "in_focus"),
109
+ resolver="focus_presence", ttl_sec=300.0, significant=False),
110
+
111
+ # permissioned
112
+ Signal("location_signal", "location", ("place_label", "wifi_label", "country", "locality", "wifi_anchor_id"),
113
+ resolver="location_signal", ttl_sec=900.0),
114
+ Signal("motion_state", "motion", ("motion_state",), ttl_sec=300.0),
115
+ Signal("calendar_next_event", "calendar", ("calendar_next_event", "calendar_events", "calendar_events_truncated"),
116
+ ttl_sec=3600.0, significant=False),
117
+ Signal("playback", "now_playing", ("now_playing",),
118
+ ttl_sec=600.0, significant=False),
119
+ Signal("audio_route", "audio_route", ("output_type", "is_bluetooth", "device_name"),
120
+ resolver="audio_route", ttl_sec=600.0, significant=False),
121
+ Signal("weather", "weather",
122
+ ("condition", "temperature", "apparent_temperature", "humidity",
123
+ "precipitation_chance", "uv_index", "is_daylight", "alerts"),
124
+ resolver="weather", ttl_sec=1800.0, significant=False),
125
+ Signal("reminders", "reminders",
126
+ ("next_reminder", "reminders", "overdue_count", "due_today_count", "reminders_truncated"),
127
+ ttl_sec=3600.0, significant=False),
128
+ Signal("health_sleep", "health_sleep",
129
+ ("asleep_minutes", "core_minutes", "deep_minutes", "rem_minutes"),
130
+ resolver="health_sleep", ttl_sec=86400.0, significant=False),
131
+ Signal("health_workout", "health_workout", ("workout_type", "duration_min", "count_today"),
132
+ resolver="health_workout", ttl_sec=86400.0, significant=False),
133
+ Signal("health_vitals", "health_vitals",
134
+ ("resting_heart_rate", "step_count", "current_heart_rate", "hrv_sdnn_ms",
135
+ "respiratory_rate", "oxygen_saturation_pct", "vo2_max"),
136
+ resolver="health_vitals", ttl_sec=3600.0, significant=False),
137
+ Signal("health_activity", "health_activity",
138
+ ("active_energy_kcal", "exercise_minutes", "stand_minutes", "mindful_minutes"),
139
+ ttl_sec=3600.0, significant=False),
140
+ Signal("health_body", "health_body",
141
+ ("weight_kg", "bmi", "body_fat_pct", "height_cm"),
142
+ ttl_sec=86400.0, significant=False),
143
+ Signal("health_metabolic", "health_metabolic",
144
+ ("blood_glucose_mmol_l", "blood_pressure_systolic", "blood_pressure_diastolic"),
145
+ ttl_sec=86400.0, significant=False),
146
+ Signal("health_cycle", "health_cycle",
147
+ ("flow_level", "is_active_period"),
148
+ ttl_sec=86400.0, significant=False),
149
+ Signal("health_mood", "health_mood",
150
+ ("valence", "valence_classification", "kind", "label_count", "recorded_today"),
151
+ ttl_sec=86400.0, significant=False),
152
+ # `app` is reported via the GET /app_open + /app_close shortcut endpoints
153
+ # (not /report); this entry exists so app_name/app_category/app_state appear
154
+ # in the snapshot with a TTL. 900s, not the 300s this used to be: an app
155
+ # session routinely outlives a 5-minute window, so a shorter TTL made the
156
+ # agent see app_name=None for any turn that wasn't right after a launch.
157
+ # `app_state` is what distinguishes "still in it" (foreground) from "just
158
+ # left it" (closed); `recent_apps` covers anything older.
159
+ #
160
+ # NOTE: this `app_state` (values "foreground" / "closed" / null) is about
161
+ # some OTHER app on the user's phone — the one the iOS Shortcut just
162
+ # opened or closed. It is UNRELATED to any `app_state` field a host's own
163
+ # scene-phase tracking might use for ITS OWN foreground/background/inactive
164
+ # phase. Same name, different namespace (perception_state field vs. a
165
+ # host-internal event payload), no functional overlap — don't assume they
166
+ # can be compared or merged.
167
+ Signal("app", "app", ("app_name", "app_category", "app_state"),
168
+ ttl_sec=900.0, significant=False),
169
+ ]}
170
+
171
+
172
+ # Back-compat aliases: canonical capability names also map to the iOS key.
173
+ KEY_ALIASES = {
174
+ "location": "location_signal",
175
+ "motion": "motion_state",
176
+ "now_playing": "playback",
177
+ "calendar": "calendar_next_event",
178
+ }
179
+
180
+ # iOS keys that carry only null placeholders (frontmost_app/silent_mode/focus/
181
+ # precise_unlock — not obtainable on iOS). Accepted and silently ignored.
182
+ IGNORED_KEYS = {"unsupported"}
183
+
184
+ # Composite report keys whose `data` expands into several signals. (none now —
185
+ # battery is its own iOS key.)
186
+ COMPOSITE_KEYS: dict[str, list[str]] = {}
187
+
188
+
189
+ # perception_items kinds (collection-style data; see migration 0002) and the
190
+ # capability that gates the GENERIC /items endpoint for each kind.
191
+ # NOTE: "photo" is intentionally ABSENT from KIND_CAPABILITY — photos must go
192
+ # through the dedicated /photo/evaluate flow, which stores the encrypted
193
+ # envelope in the frame channel. Allowing kind=photo via /items would let a
194
+ # caller inject a confirmed photo doc without the envelope path.
195
+ # (calendar is reported via /report, not /items.)
196
+ KIND_CAPABILITY = {
197
+ "workout": "health_workout",
198
+ "sleep": "health_sleep",
199
+ "vitals": "health_vitals",
200
+ }
201
+
202
+ # Burst de-dup backstop. Clustering is primarily done ON DEVICE (iOS collapses a
203
+ # 30s burst and uploads only the representative frame); this window is the
204
+ # server-side safety net so a client that uploads several still wakes once.
205
+ PHOTO_CLUSTER_SEC = 30.0
206
+
207
+ # scene_hint — the canonical enum shared with the iOS Vision classifier. The
208
+ # client MUST emit one of these strings; anything else is treated as "other".
209
+ # V2 removes the old platform hard block: sensitive hints are metadata for the
210
+ # companion's expression policy, not a perception gate.
211
+ SCENE_HINTS = (
212
+ # non-sensitive — may reach the agent
213
+ "landscape", "food", "people", "pet", "activity", "object", "art",
214
+ "text_note", "other",
215
+ # contextual
216
+ "private", "receipt",
217
+ # objectively sensitive
218
+ "document", "id_card", "medical", "screenshot",
219
+ )
220
+ SENSITIVE_PHOTO_SCENES = {"private", "receipt", "document", "id_card", "medical", "screenshot"}
221
+
222
+ # Extended on-device metadata the iOS Vision pass may include. The runtime does
223
+ # not use these fields as a gate; they are passed through as context for the
224
+ # agent's own judgment and voice.
225
+ PHOTO_METADATA_FIELDS = (
226
+ "has_faces", "face_count", "scene_hint", "scene_confidence",
227
+ "time_of_day", "is_burst", "is_indoor", "has_text_block", "is_screenshot",
228
+ # Stable per-photo identity + capture time. Both are new; older clients
229
+ # simply omit them and every consumer must treat them as optional.
230
+ #
231
+ # `source_event_id` is an on-device HMAC of PHAsset.localIdentifier under a
232
+ # device-local random key -- stable across re-reports, never reversible to
233
+ # the user's library. It is deliberately NOT the envelope id: that one is
234
+ # bound into the ciphertext's AAD and must stay random.
235
+ #
236
+ # `occurred_at` is when the photo was taken, which is not when it reaches
237
+ # us -- an import backfills months of photos in one session.
238
+ "source_event_id", "occurred_at",
239
+ )
240
+ # "Back after long lock" wake threshold.
241
+ UNLOCK_BACK_THRESHOLD_SEC = 1800.0 # 30 min
242
+
243
+ # Legacy recent app-open rows surfaced in the snapshot (folds the standalone
244
+ # /app_usage read; capped to keep the wake-attached snapshot small). The merged
245
+ # TTL-bounded open/close trajectory is exposed separately as recent_app_events.
246
+ RECENT_APPS_LIMIT = 10
247
+
248
+ # Explicit `perception.recent_apps` pulls merge the existing open/close streams,
249
+ # but an agent asking "what have I been using today?" wants more than the wake
250
+ # budget.
251
+ RECENT_APPS_TOOL_LIMIT = 20
252
+ RECENT_APPS_TOOL_MAX = 100
@@ -0,0 +1,28 @@
1
+ """一致性测试工具。
2
+
3
+ memory 内存版 StoragePort —— **只是测试工具,不是生产实现**
4
+ suite storage adapter:十条一致性保证
5
+ wake wake adapter:回执形状与幂等
6
+ report report adapter:producer 产出的信封本身对不对
7
+
8
+ 产品规范 §20 把这三种 adapter conformance 并列为最低交付物。
9
+
10
+ 🔴 内存实现天然原子、天然没有并发,所以它**验不出**真实数据库的事务边界和
11
+ 隔离级别。"RuleState 和 Outbox 必须同事务"这类保证在这里永远是绿的。
12
+ 宿主必须另外用真实数据库 + 两条独立连接 + 在关键写操作之间打断点来证明。
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from .memory import InMemoryStorage
17
+ from .suite import GUARANTEES, NOT_PROVABLE_IN_MEMORY, run_storage_conformance
18
+ from .report import (
19
+ REPORT_GUARANTEES, REPORT_NOT_PROVABLE, run_report_conformance,
20
+ )
21
+ from .wake import WAKE_GUARANTEES, WAKE_NOT_PROVABLE, run_wake_conformance
22
+
23
+ __all__ = [
24
+ "InMemoryStorage",
25
+ "run_storage_conformance", "GUARANTEES", "NOT_PROVABLE_IN_MEMORY",
26
+ "run_wake_conformance", "WAKE_GUARANTEES", "WAKE_NOT_PROVABLE",
27
+ "run_report_conformance", "REPORT_GUARANTEES", "REPORT_NOT_PROVABLE",
28
+ ]
@@ -0,0 +1,364 @@
1
+ """内存版存储 —— **只是测试工具,不是生产实现。**
2
+
3
+ 它存在的意义是让宿主在写自己的 adapter 之前,先有一个能跑通的参照,
4
+ 以及让 kit 自己的管线测试不依赖任何数据库。
5
+
6
+ 🔴 **它验不出真正的事务边界和隔离级别。** 内存实现天然是原子的、天然没有
7
+ 并发 —— "RuleState 和 Outbox 必须同事务"这类保证,在这里永远是绿的,
8
+ 不代表真实数据库上也绿。宿主必须另外用真实数据库、两条独立连接、
9
+ 在关键写操作之间打断点,才能证明那条保证成立。
10
+
11
+ 在这里绿 = 端口语义、调用顺序、确定性没问题。
12
+ 仅此而已。
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from contextlib import contextmanager
17
+ from datetime import date, datetime, timezone
18
+
19
+ #: 排序时给「没有时间」的条目垫底用的哨兵,不参与任何业务判断。
20
+ _EPOCH = datetime(1, 1, 1, tzinfo=timezone.utc)
21
+ from typing import Any, Iterator, Sequence
22
+
23
+ from ..contracts import delivery as _delivery
24
+ from ..contracts.records import (
25
+ CalendarEventMirror,
26
+ CurrentProjection,
27
+ DailyAggregate,
28
+ DurableDedupeIdentity,
29
+ EventOutboxEntry,
30
+ ReminderItemMirror,
31
+ SourceSyncState,
32
+ StoredObservation,
33
+ )
34
+ from ..contracts.receipt import (
35
+ INGEST_ACCEPTED,
36
+ INGEST_CONFLICT,
37
+ INGEST_DUPLICATE,
38
+ IngestReceipt,
39
+ WakeReceipt,
40
+ )
41
+
42
+
43
+ class InMemoryStorage:
44
+ """把 :class:`~perceptkit.ports.storage.StoragePort` 实现在几个字典上。"""
45
+
46
+ def __init__(self) -> None:
47
+ self.reports: dict[tuple[str, str, str], IngestReceipt] = {}
48
+ self.observations: dict[str, StoredObservation] = {}
49
+ self.identities: set[tuple[str, str, str, str]] = set()
50
+ self.current: dict[tuple[str, str, str], CurrentProjection] = {}
51
+ self.aggregates: dict[tuple[str, str, date, str, int], DailyAggregate] = {}
52
+ self.calendar: dict[tuple, CalendarEventMirror] = {}
53
+ self.reminders: dict[tuple, ReminderItemMirror] = {}
54
+ self.sync_state: dict[tuple[str, str, str], SourceSyncState] = {}
55
+ self.rule_state: dict[tuple[str, str, str], dict[str, Any]] = {}
56
+ self.outbox: dict[str, EventOutboxEntry] = {}
57
+ self.receipts: list[WakeReceipt] = []
58
+ #: 测试用:数一数事务嵌套层数,验证调用方确实把该原子的操作包起来了。
59
+ self.transaction_depth = 0
60
+ self.transactions_opened = 0
61
+
62
+ # -- 事务 ------------------------------------------------------------
63
+
64
+ @contextmanager
65
+ def transaction(self) -> Iterator[None]:
66
+ """内存里没有真正的回滚 —— 只记录边界,供测试断言"该包起来的确实包了"。
67
+
68
+ **不要把这里的绿当成"原子性验过了"。** 见模块开头。
69
+ """
70
+ self.transaction_depth += 1
71
+ self.transactions_opened += 1
72
+ try:
73
+ yield
74
+ finally:
75
+ self.transaction_depth -= 1
76
+
77
+ # -- 批级幂等 --------------------------------------------------------
78
+
79
+ def claim_report(self, *, subject_id, producer, report_id, payload_digest,
80
+ received_at) -> IngestReceipt:
81
+ key = (subject_id, producer, report_id)
82
+ prior = self.reports.get(key)
83
+ if prior is not None:
84
+ status = (INGEST_DUPLICATE if prior.payload_digest == payload_digest
85
+ else INGEST_CONFLICT)
86
+ return IngestReceipt(
87
+ subject_id=subject_id, producer=producer, report_id=report_id,
88
+ payload_digest=prior.payload_digest, received_at=prior.received_at,
89
+ status=status,
90
+ error_code=None if status == INGEST_DUPLICATE else "digest_mismatch",
91
+ observations_applied=0,
92
+ )
93
+ fresh = IngestReceipt(
94
+ subject_id=subject_id, producer=producer, report_id=report_id,
95
+ payload_digest=payload_digest, received_at=received_at,
96
+ status=INGEST_ACCEPTED,
97
+ )
98
+ self.reports[key] = fresh
99
+ return fresh
100
+
101
+ # -- 观测 ------------------------------------------------------------
102
+
103
+ def append_observation(self, observation: StoredObservation) -> bool:
104
+ if observation.observation_id in self.observations:
105
+ return False
106
+ self.observations[observation.observation_id] = observation
107
+ return True
108
+
109
+ def list_observations(self, *, subject_id, signal, start=None, end=None,
110
+ cursor=None, limit=100):
111
+ rows = sorted(
112
+ (o for o in self.observations.values()
113
+ if o.subject_id == subject_id and o.signal == signal
114
+ and (start is None or o.occurred_at >= start)
115
+ and (end is None or o.occurred_at <= end)),
116
+ key=lambda o: (o.occurred_at, o.observation_id),
117
+ )
118
+ offset = int(cursor) if cursor else 0
119
+ page = rows[offset:offset + limit]
120
+ nxt = str(offset + limit) if offset + limit < len(rows) else None
121
+ return page, nxt
122
+
123
+ def delete_observations(self, *, subject_id, signal=None, before=None) -> int:
124
+ doomed = [
125
+ k for k, o in self.observations.items()
126
+ if o.subject_id == subject_id
127
+ and (signal is None or o.signal == signal)
128
+ and (before is None or o.occurred_at < before)
129
+ ]
130
+ for k in doomed:
131
+ del self.observations[k]
132
+ return len(doomed)
133
+
134
+ # -- 去重身份 --------------------------------------------------------
135
+
136
+ def remember_identity(self, identity: DurableDedupeIdentity) -> bool:
137
+ key = (identity.subject_id, identity.signal, identity.source,
138
+ identity.source_event_identity_digest)
139
+ if key in self.identities:
140
+ return False
141
+ self.identities.add(key)
142
+ return True
143
+
144
+ def has_seen_identity(self, *, subject_id, signal, source, digest) -> bool:
145
+ return (subject_id, signal, source, digest) in self.identities
146
+
147
+ # -- 当前值 ----------------------------------------------------------
148
+
149
+ def get_current(self, *, subject_id, signals):
150
+ out: dict[str, list[CurrentProjection]] = {s: [] for s in signals}
151
+ for (subj, sig, _dim), proj in self.current.items():
152
+ if subj == subject_id and sig in out:
153
+ out[sig].append(proj)
154
+ return out
155
+
156
+ def compare_and_put_current(self, projection, *, expected_version) -> bool:
157
+ key = (projection.subject_id, projection.signal, projection.dimension_key)
158
+ existing = self.current.get(key)
159
+ actual = existing.version if existing else -1
160
+ if actual != expected_version:
161
+ return False
162
+ self.current[key] = projection
163
+ return True
164
+
165
+ # -- 聚合 ------------------------------------------------------------
166
+
167
+ def get_aggregate(self, *, subject_id, signal, start_date, end_date,
168
+ aggregation_kind=None):
169
+ return [
170
+ a for (subj, sig, day, kind, _v), a in self.aggregates.items()
171
+ if subj == subject_id and sig == signal
172
+ and start_date <= day <= end_date
173
+ and (aggregation_kind is None or kind == aggregation_kind)
174
+ ]
175
+
176
+ def put_aggregate(self, aggregate: DailyAggregate) -> None:
177
+ self.aggregates[(
178
+ aggregate.subject_id, aggregate.signal, aggregate.local_date,
179
+ aggregate.aggregation_kind, aggregate.aggregation_version,
180
+ )] = aggregate
181
+
182
+ # -- 来源镜像 --------------------------------------------------------
183
+
184
+ def get_sync_state(self, *, subject_id, source, collection_kind):
185
+ return self.sync_state.get((subject_id, source, collection_kind))
186
+
187
+ def put_sync_state(self, state: SourceSyncState) -> None:
188
+ self.sync_state[(state.subject_id, state.source, state.collection_kind)] = state
189
+
190
+ def upsert_calendar_events(self, *, subject_id, events) -> None:
191
+ for e in events:
192
+ self.calendar[(subject_id, e.source_account_id, e.source_calendar_id,
193
+ e.source_event_id)] = e
194
+
195
+ def upsert_reminders(self, *, subject_id, items) -> None:
196
+ for r in items:
197
+ self.reminders[(subject_id, r.source_account_id, r.source_list_id,
198
+ r.source_reminder_id)] = r
199
+
200
+ def list_calendar_events(self, *, subject_id, start=None, end=None, limit=50):
201
+ rows = [v for k, v in self.calendar.items() if k[0] == subject_id]
202
+ keep = []
203
+ for item in rows:
204
+ at = item.event_fields.get("start_at")
205
+ # 时间不明的条目**保留** —— 和删除那边同一条纪律:证明不了它在
206
+ # 范围外,就不能替用户把它藏起来。
207
+ if at is not None and start is not None and at < start:
208
+ continue
209
+ if at is not None and end is not None and at > end:
210
+ continue
211
+ keep.append(item)
212
+ keep.sort(key=lambda i: (i.event_fields.get("start_at") is None,
213
+ i.event_fields.get("start_at") or _EPOCH,
214
+ i.source_event_id))
215
+ return keep[:limit]
216
+
217
+ def list_reminders(self, *, subject_id, include_completed=False, limit=50):
218
+ keep = [
219
+ v for k, v in self.reminders.items()
220
+ if k[0] == subject_id
221
+ and (include_completed or not v.reminder_fields.get("is_completed"))
222
+ ]
223
+ keep.sort(key=lambda i: (i.reminder_fields.get("due_at") is None,
224
+ i.reminder_fields.get("due_at") or _EPOCH,
225
+ i.source_reminder_id))
226
+ return keep[:limit]
227
+
228
+ def apply_source_snapshot(self, *, subject_id, source, collection_kind, sync_id,
229
+ coverage_start, coverage_end, snapshot_kind) -> int:
230
+ # 增量同步没有资格删任何东西 —— 它只知道"变了什么",不知道"还剩什么"。
231
+ if snapshot_kind != "full":
232
+ return 0
233
+ store = self.calendar if collection_kind == "calendar" else self.reminders
234
+ doomed = []
235
+ for key, item in store.items():
236
+ if key[0] != subject_id or item.last_seen_sync_id == sync_id:
237
+ continue
238
+ # 🔴 只删【能证明落在覆盖范围内】的。拿局部窗口去删窗口外的数据,
239
+ # 会让用户发现自己去年的日程凭空消失,而且不可逆。
240
+ # **时间不明的条目一律不删** —— 证明不了它在范围内,就没有资格删它。
241
+ start = (item.event_fields.get("start_at")
242
+ if isinstance(item, CalendarEventMirror)
243
+ else item.reminder_fields.get("due_at"))
244
+ if start is None or not (coverage_start <= start <= coverage_end):
245
+ continue
246
+ doomed.append(key)
247
+ for key in doomed:
248
+ del store[key]
249
+ return len(doomed)
250
+
251
+ # -- 规则状态 --------------------------------------------------------
252
+
253
+ def get_rule_state(self, *, subject_id, definition_id, scope_key):
254
+ return self.rule_state.get((subject_id, definition_id, scope_key))
255
+
256
+ def put_rule_state(self, *, subject_id, definition_id, scope_key, state) -> None:
257
+ self.rule_state[(subject_id, definition_id, scope_key)] = dict(state)
258
+
259
+ # -- 事件与投递 ------------------------------------------------------
260
+
261
+ def enqueue_event(self, entry: EventOutboxEntry) -> bool:
262
+ if entry.event_id in self.outbox:
263
+ return False
264
+ self.outbox[entry.event_id] = entry
265
+ return True
266
+
267
+ def claim_pending_event(self, *, worker_id, now, lease_seconds):
268
+ from dataclasses import replace
269
+ from datetime import timedelta
270
+ for event_id, entry in sorted(self.outbox.items()):
271
+ claimable = (
272
+ entry.delivery_state == _delivery.PENDING
273
+ or (entry.delivery_state == _delivery.CLAIMED
274
+ and entry.lease_expires_at is not None
275
+ and entry.lease_expires_at <= now)
276
+ )
277
+ if not claimable:
278
+ continue
279
+ if entry.next_attempt_at is not None and entry.next_attempt_at > now:
280
+ continue
281
+ claimed = replace(
282
+ entry,
283
+ delivery_state=_delivery.CLAIMED,
284
+ attempt_count=entry.attempt_count + 1,
285
+ lease_owner=worker_id,
286
+ lease_expires_at=now + timedelta(seconds=lease_seconds),
287
+ # 额度只是占位,不是消耗 —— delivered 时才兑现。
288
+ budget_reservation_id=f"resv_{event_id}_{entry.attempt_count + 1}",
289
+ # 每次认领换一个新令牌 —— 旧 worker 回来时手里是旧的。
290
+ claim_token=f"{worker_id}:{entry.attempt_count + 1}",
291
+ )
292
+ self.outbox[event_id] = claimed
293
+ return claimed
294
+ return None
295
+
296
+ def record_wake_receipt(self, *, receipt, next_state, claim_token=None,
297
+ next_attempt_at=None) -> bool:
298
+ from dataclasses import replace
299
+ entry = self.outbox.get(receipt.event_id)
300
+ if entry is None:
301
+ raise KeyError(f"unknown event_id {receipt.event_id!r}")
302
+ if claim_token is not None and entry.claim_token != claim_token:
303
+ # 令牌过期:这个事件已经被别人接管了。只记审计,不改状态。
304
+ self.receipts.append(receipt)
305
+ return False
306
+ _delivery.assert_transition(entry.delivery_state, next_state)
307
+ self.receipts.append(receipt)
308
+ self.outbox[receipt.event_id] = replace(
309
+ entry,
310
+ delivery_state=next_state,
311
+ next_attempt_at=next_attempt_at,
312
+ lease_owner=None,
313
+ lease_expires_at=None,
314
+ # 兑现或释放:只有 delivered 会把占位变成真正的消耗。
315
+ claim_token=None,
316
+ budget_reservation_id=(
317
+ entry.budget_reservation_id
318
+ if _delivery.consumes_budget(next_state) else None
319
+ ),
320
+ )
321
+ return True
322
+
323
+ def list_pending_events(self, *, subject_id=None, limit=100):
324
+ return [
325
+ e for e in self.outbox.values()
326
+ if not e.is_terminal and (subject_id is None or e.subject_id == subject_id)
327
+ ][:limit]
328
+
329
+ # -- 用户数据 --------------------------------------------------------
330
+
331
+ def purge_subject(self, *, subject_id) -> dict[str, int]:
332
+ doomed_event_ids = {e.event_id for e in self.outbox.values()
333
+ if e.subject_id == subject_id}
334
+
335
+ def drop(store: dict, pick) -> int:
336
+ doomed = [k for k, v in store.items() if pick(k, v) == subject_id]
337
+ for k in doomed:
338
+ del store[k]
339
+ return len(doomed)
340
+
341
+ counts = {
342
+ "reports": drop(self.reports, lambda k, v: k[0]),
343
+ "observations": drop(self.observations, lambda k, v: v.subject_id),
344
+ "current": drop(self.current, lambda k, v: k[0]),
345
+ "aggregates": drop(self.aggregates, lambda k, v: k[0]),
346
+ "calendar": drop(self.calendar, lambda k, v: k[0]),
347
+ "reminders": drop(self.reminders, lambda k, v: k[0]),
348
+ "sync_state": drop(self.sync_state, lambda k, v: k[0]),
349
+ "rule_state": drop(self.rule_state, lambda k, v: k[0]),
350
+ "outbox": drop(self.outbox, lambda k, v: v.subject_id),
351
+ }
352
+ before = len(self.identities)
353
+ self.identities = {i for i in self.identities if i[0] != subject_id}
354
+ counts["identities"] = before - len(self.identities)
355
+ # 回执按它对应事件的 subject 清理。以前这里只是原样复制了一遍列表 ——
356
+ # "删除我的数据"这件事没有部分成功。
357
+ doomed_events = {k for k in doomed_event_ids}
358
+ kept = [r for r in self.receipts if r.event_id not in doomed_events]
359
+ counts["receipts"] = len(self.receipts) - len(kept)
360
+ self.receipts = kept
361
+ return counts
362
+
363
+
364
+ __all__ = ["InMemoryStorage"]