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
@@ -0,0 +1,170 @@
1
+ """Report adapter 的一致性检查 —— 宿主用它证明自己的上报适配器产出的信封是对的。
2
+
3
+ 产品规范 §20 并列的第三种 conformance。适配器指的是把 producer 的原始载荷
4
+ (iOS 的一份快照、某个手环的一批样本)转成 ``ReportEnvelope`` 的那段代码。
5
+
6
+ 用法(在宿主自己的测试里)::
7
+
8
+ from perceptkit.conformance import run_report_conformance
9
+
10
+ def test_my_ios_adapter_is_conformant():
11
+ problems = run_report_conformance(
12
+ lambda payload: my_adapter.to_envelope(payload),
13
+ samples=[
14
+ (REAL_IOS_SNAPSHOT, "observed"),
15
+ (EMPTY_SNAPSHOT, "no_data"),
16
+ (PERMISSION_DENIED_SNAPSHOT, "unavailable"),
17
+ ],
18
+ )
19
+ assert not problems, "\\n".join(problems)
20
+
21
+ ---
22
+
23
+ ## 为什么 report 也要单独一套
24
+
25
+ 上报适配器是**唯一**能凭空造出事实的地方。它错了,后面每一层都在忠实地
26
+ 处理一份假数据 —— 管线不会报错,规则会照常触发,agent 会照常开口。
27
+
28
+ 三种最容易犯的错:
29
+
30
+ 把"没测到"变成 0 "今天 0 步"和"今天没戴表"对 agent 是两句完全不同的话
31
+ 把"没权限"变成"没数据" 前者要引导用户去开权限,后者只该闭嘴
32
+ 补一个 occurred_at 源头没给时间就不该编一个"现在" —— 编出来的时间
33
+ 会让这条数据被归到错误的一天,而且永远查不出来
34
+ """
35
+ from __future__ import annotations
36
+
37
+ from typing import Any, Callable, Iterable, Sequence
38
+
39
+ from ..contracts.availability import AVAILABILITY_STATES
40
+ from ..contracts.errors import ContractError
41
+ from ..contracts.report import ReportEnvelope
42
+
43
+ AdapterFn = Callable[[Any], Any]
44
+
45
+ REPORT_GUARANTEES: tuple[str, ...] = (
46
+ "R1 产出的东西必须能被 ReportEnvelope 解析",
47
+ "R2 每条观测的 availability 必须是三态之一",
48
+ "R3 `no_data` / `unavailable` 不许带值 —— 带了就是在编事实",
49
+ "R4 `observed` 必须带值,哪怕值是 0",
50
+ "R5 同一份载荷转两次要得到同样的信封(不许掺进当前时间、随机数)",
51
+ "R6 report_id 在一份载荷内稳定 —— 它是幂等的钥匙",
52
+ "R7 样本标了预期状态的话,产出的状态必须对得上",
53
+ )
54
+
55
+ REPORT_NOT_PROVABLE: tuple[str, ...] = (
56
+ "字段语义对不对 —— 我们能查出「这里是个数字」,查不出「这个数字是步数不是心率」",
57
+ "真机采集的完整性 —— 要拿真实设备抓下来的载荷跑,手写的样本证明不了覆盖面",
58
+ "🔴 不标预期状态的话,**查不出零填充** —— 光看信封,「真的走了 0 步」和"
59
+ "「没戴表被写成 0 步」长得一模一样。这正是 R7 存在的理由:只有你知道"
60
+ "这份载荷应该产出什么",
61
+ )
62
+
63
+
64
+ def _split(sample: Any) -> tuple[Any, str | None]:
65
+ """样本可以是裸载荷,也可以是 ``(载荷, 预期状态)``。"""
66
+ if (isinstance(sample, tuple) and len(sample) == 2
67
+ and sample[1] in AVAILABILITY_STATES):
68
+ return sample[0], sample[1]
69
+ return sample, None
70
+
71
+
72
+ def _check_one(envelope: ReportEnvelope, label: str, problems: list[str]) -> None:
73
+ if not envelope.report_id:
74
+ problems.append(f"R6 {label}:report_id 是空的。它是幂等的钥匙,"
75
+ "空的话同一份上报重传会被当成两份")
76
+ for i, obs in enumerate(envelope.observations):
77
+ where = f"{label} 第 {i + 1} 条观测({obs.signal})"
78
+ if obs.availability not in AVAILABILITY_STATES:
79
+ problems.append(
80
+ f"R2 {where}:availability={obs.availability!r} 不是三态之一"
81
+ f"({sorted(AVAILABILITY_STATES)})"
82
+ )
83
+ continue
84
+ has_value = obs.value is not None
85
+ if obs.availability != "observed" and has_value:
86
+ problems.append(
87
+ f"R3 {where}:availability={obs.availability!r} 却带了值 {obs.value!r}。"
88
+ "没测到就是没测到 —— 带上一个值等于在编事实"
89
+ )
90
+ if obs.availability == "observed" and not has_value:
91
+ # 走 parse 的路径上,信封契约自己就会拦掉这种(报成 R1)。
92
+ # 这里兜的是适配器直接返回 ReportEnvelope 对象的情况。
93
+ problems.append(
94
+ f"R4 {where}:说是 observed 却没有值。"
95
+ "如果真的测到 0,就把 0 写进来 —— 零是一个有效的观测,不是缺失"
96
+ )
97
+ if obs.occurred_at is None:
98
+ problems.append(
99
+ f"R1 {where}:没有 occurred_at。源头给不了时间的话,"
100
+ "这条观测就不该被造出来 —— 补一个「现在」会让它被归到错误的一天"
101
+ )
102
+
103
+
104
+ def run_report_conformance(adapter: AdapterFn,
105
+ samples: Sequence[Any] | Iterable[Any]) -> list[str]:
106
+ """拿几份真实载荷跑一遍适配器,返回问题清单(空 = 通过)。
107
+
108
+ ``samples`` 至少应该包含:一份正常的、一份**什么都没测到**的、
109
+ 一份**权限被拒**的。后两种正是最容易被写成「返回 0」的。
110
+
111
+ 每份样本可以是裸载荷,也可以是 ``(载荷, 预期状态)``。**强烈建议带上预期**:
112
+ 不带的话这套检查查不出零填充 —— 光看信封,「真的走了 0 步」和「没戴表被
113
+ 写成 0 步」长得一模一样,只有你知道这份载荷应该产出什么。
114
+ """
115
+ problems: list[str] = []
116
+ samples = list(samples)
117
+ if not samples:
118
+ problems.append(
119
+ "一份样本都没给。至少要三份:正常 / 什么都没测到 / 权限被拒 —— "
120
+ "后两种正是最容易被写成「返回 0」的"
121
+ )
122
+ return problems
123
+
124
+ for n, sample in enumerate(samples, 1):
125
+ payload, expected = _split(sample)
126
+ label = f"样本 {n}" + (f"(预期 {expected})" if expected else "")
127
+ try:
128
+ raw = adapter(payload)
129
+ except Exception as exc: # noqa: BLE001
130
+ problems.append(f"R1 {label}:适配器抛了 {type(exc).__name__}({exc})")
131
+ continue
132
+ try:
133
+ envelope = (raw if isinstance(raw, ReportEnvelope)
134
+ else ReportEnvelope.parse(raw))
135
+ except (ContractError, Exception) as exc: # noqa: BLE001
136
+ problems.append(f"R1 {label}:产出的东西解析不了 —— {exc}")
137
+ continue
138
+
139
+ _check_one(envelope, label, problems)
140
+
141
+ if expected is not None:
142
+ got = {o.availability for o in envelope.observations}
143
+ if got != {expected}:
144
+ problems.append(
145
+ f"R7 {label}:产出的状态是 {sorted(got)},预期 {expected!r}。"
146
+ + ("把「没测到」写成 observed 就是零填充 —— "
147
+ "「今天 0 步」和「今天没戴表」对 agent 是两句完全不同的话"
148
+ if expected != "observed" and "observed" in got else "")
149
+ )
150
+
151
+ # R5:同一份载荷转两次要一样。掺进 now() 或随机 id 的适配器会在这里露馅,
152
+ # 而那种适配器会让每次重传都变成一份"新"上报,幂等彻底失效。
153
+ try:
154
+ again = adapter(payload)
155
+ again = (again if isinstance(again, ReportEnvelope)
156
+ else ReportEnvelope.parse(again))
157
+ except Exception: # noqa: BLE001
158
+ continue
159
+ if again.report_id != envelope.report_id:
160
+ problems.append(
161
+ f"R5 {label}:同一份载荷转两次得到了两个 report_id"
162
+ f"({envelope.report_id!r} / {again.report_id!r})。"
163
+ "适配器里掺了当前时间或随机数 —— 这会让每次重传都变成一份新上报,"
164
+ "幂等彻底失效"
165
+ )
166
+
167
+ return problems
168
+
169
+
170
+ __all__ = ["REPORT_GUARANTEES", "REPORT_NOT_PROVABLE", "run_report_conformance"]
@@ -0,0 +1,419 @@
1
+ """一致性测试套件 —— 宿主用它证明自己的 adapter 是对的。
2
+
3
+ 产品规范说得很准:宿主可以用任何数据库,**但需要证明能够满足相同的查询、
4
+ 幂等、重算、删除和一致性语义**。这个模块就是那个"证明"。
5
+
6
+ 用法(在宿主自己的测试里)::
7
+
8
+ from perceptkit.conformance import run_storage_conformance
9
+
10
+ def test_my_adapter_is_conformant():
11
+ problems = run_storage_conformance(lambda: MyPostgresStorage(fresh_db()))
12
+ assert not problems, "\\n".join(problems)
13
+
14
+ ---
15
+
16
+ ## 🔴 这套东西能证明什么、不能证明什么
17
+
18
+ **能证明**:端口语义对不对、调用顺序对不对、给同样的输入是不是给同样的结果。
19
+
20
+ **不能证明**(必须宿主另外做):
21
+
22
+ 真正的事务边界 需要真实数据库 + 在关键写操作之间打断点,
23
+ 然后【从另一条连接】观察:规则状态和发件箱
24
+ 要么都旧/不存在,要么都提交
25
+ 并发下只有一个胜者 需要两条独立连接 + 同时发起,
26
+ 断言同 report / 同 event / 新旧 current 只有一个赢
27
+ 崩溃恢复 需要模拟"wake 已 accepted、回执还没存下来"就断电
28
+
29
+ 在内存实现上这三类**永远是绿的** —— 内存天然原子、天然无并发。
30
+ 把它们当验过了,是这套东西最危险的用法。
31
+ """
32
+ from __future__ import annotations
33
+
34
+ from dataclasses import replace
35
+ from datetime import date, datetime, timedelta, timezone
36
+ from typing import Any, Callable
37
+
38
+ from ..contracts import delivery as _delivery
39
+ from ..contracts import receipt as _receipt
40
+ from ..contracts.records import (
41
+ CalendarEventMirror,
42
+ CurrentProjection,
43
+ DailyAggregate,
44
+ DurableDedupeIdentity,
45
+ EventOutboxEntry,
46
+ ReminderItemMirror,
47
+ StoredObservation,
48
+ )
49
+ from ..contracts.receipt import WakeReceipt
50
+
51
+ UTC = timezone.utc
52
+ T0 = datetime(2026, 8, 27, 10, 0, tzinfo=UTC)
53
+ DAY = date(2026, 8, 27)
54
+
55
+ StorageFactory = Callable[[], Any]
56
+
57
+
58
+ def _obs(**over: Any) -> StoredObservation:
59
+ base: dict[str, Any] = dict(
60
+ observation_id="obs_1", subject_id="u1", signal="steps",
61
+ signal_schema_version=1, source="ios", occurred_at=T0, received_at=T0,
62
+ availability="observed", effective_local_date=DAY,
63
+ typed_value={"step_count": 100},
64
+ )
65
+ base.update(over)
66
+ return StoredObservation(**base)
67
+
68
+
69
+ def _current(**over: Any) -> CurrentProjection:
70
+ base: dict[str, Any] = dict(
71
+ subject_id="u1", signal="steps", dimension_key="steps",
72
+ typed_value={"step_count": 100}, availability="observed",
73
+ observed_at=T0, received_at=T0, version=0, content_digest="d1",
74
+ )
75
+ base.update(over)
76
+ return CurrentProjection(**base)
77
+
78
+
79
+ def _entry(**over: Any) -> EventOutboxEntry:
80
+ base: dict[str, Any] = dict(
81
+ event_id="evt_1", subject_id="u1", definition_id="d1", definition_version=1,
82
+ event_type="t", occurred_at=T0, detected_at=T0, fact_snapshot={},
83
+ )
84
+ base.update(over)
85
+ return EventOutboxEntry(**base)
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # 十条保证
90
+ # ---------------------------------------------------------------------------
91
+
92
+ def _g1_report_and_observation_idempotency(new: StorageFactory) -> list[str]:
93
+ """① 同一批上报、同一条观测重传,都不重复处理。"""
94
+ problems: list[str] = []
95
+ s = new()
96
+ first = s.claim_report(subject_id="u1", producer="ios", report_id="r1",
97
+ payload_digest="d1", received_at=T0)
98
+ if first.status != _receipt.INGEST_ACCEPTED:
99
+ problems.append("①: 第一次认领一批新上报应该 accepted")
100
+ again = s.claim_report(subject_id="u1", producer="ios", report_id="r1",
101
+ payload_digest="d1", received_at=T0)
102
+ if again.status != _receipt.INGEST_DUPLICATE:
103
+ problems.append("①: 同 identity 同摘要重传应该 duplicate,不重复处理")
104
+
105
+ s2 = new()
106
+ if not s2.append_observation(_obs()):
107
+ problems.append("①: 第一次写观测应该返回 True")
108
+ if s2.append_observation(_obs()):
109
+ problems.append("①: 同一个 observation_id 重复写应该返回 False 且不重复落库")
110
+ return problems
111
+
112
+
113
+ def _g2_old_does_not_overwrite_new(new: StorageFactory) -> list[str]:
114
+ """② 迟到的旧观测不能覆盖更新的当前值。"""
115
+ problems: list[str] = []
116
+ s = new()
117
+ s.compare_and_put_current(_current(observed_at=T0, version=0), expected_version=-1)
118
+ stale = _current(observed_at=T0 - timedelta(hours=1),
119
+ typed_value={"step_count": 1}, version=1)
120
+ # 用错误的版本号写 —— 应该被拒。真正的"旧不覆盖新"判断在 kit 的管线里,
121
+ # 这里验的是端口有没有提供那个把手。
122
+ if s.compare_and_put_current(stale, expected_version=99):
123
+ problems.append("②: 版本号对不上时 compare_and_put_current 必须返回 False")
124
+ got = s.get_current(subject_id="u1", signals=["steps"])["steps"]
125
+ if got and got[0].typed_value != {"step_count": 100}:
126
+ problems.append("②: 当前值被一次版本不匹配的写入改掉了")
127
+ return problems
128
+
129
+
130
+ def _g3_same_identity_different_content_conflicts(new: StorageFactory) -> list[str]:
131
+ """③ 同一个上报 identity、不同内容 —— 必须报冲突,不能静默覆盖。"""
132
+ problems: list[str] = []
133
+ s = new()
134
+ s.claim_report(subject_id="u1", producer="ios", report_id="r1",
135
+ payload_digest="d1", received_at=T0)
136
+ clash = s.claim_report(subject_id="u1", producer="ios", report_id="r1",
137
+ payload_digest="d2", received_at=T0)
138
+ if clash.status != _receipt.INGEST_CONFLICT:
139
+ problems.append(
140
+ "③: 同 report_id 不同内容必须 conflict —— 静默挑一个覆盖会让"
141
+ "「到底哪份数据生效了」永远说不清"
142
+ )
143
+ return problems
144
+
145
+
146
+ def _g4_permanent_aggregates_survive_replay(new: StorageFactory) -> list[str]:
147
+ """④ 永久聚合不会因为旧数据重放而重复累计。"""
148
+ problems: list[str] = []
149
+ s = new()
150
+ ident = DurableDedupeIdentity(
151
+ subject_id="u1", signal="steps", source="ios",
152
+ source_event_identity_digest="abc", first_applied_at=T0,
153
+ )
154
+ if not s.remember_identity(ident):
155
+ problems.append("④: 第一次记住去重身份应该返回 True")
156
+ if s.remember_identity(ident):
157
+ problems.append("④: 重复记住同一个身份应该返回 False")
158
+ if not s.has_seen_identity(subject_id="u1", signal="steps", source="ios",
159
+ digest="abc"):
160
+ problems.append("④: 记过的身份必须查得到")
161
+ # 关键:明细被保留期清理之后,身份仍然要在 —— 否则重放会把数字加两遍。
162
+ s.append_observation(_obs())
163
+ s.delete_observations(subject_id="u1", signal="steps",
164
+ before=T0 + timedelta(days=1))
165
+ if not s.has_seen_identity(subject_id="u1", signal="steps", source="ios",
166
+ digest="abc"):
167
+ problems.append(
168
+ "④: 清理明细把去重身份一起删了 —— 旧数据重放会让永久聚合的数字"
169
+ "加两遍,而且无法回滚"
170
+ )
171
+ return problems
172
+
173
+
174
+ def _g5_atomic_boundary_is_offered(new: StorageFactory) -> list[str]:
175
+ """⑤ 端口提供了原子边界这个把手。
176
+
177
+ 🔴 **这一条只验"有没有提供",验不出"真的原子"。** 真正的验证需要
178
+ 真实数据库、两条连接、在关键写操作之间打断点,然后从另一条连接观察。
179
+ """
180
+ problems: list[str] = []
181
+ s = new()
182
+ try:
183
+ with s.transaction():
184
+ s.append_observation(_obs())
185
+ except Exception as exc: # noqa: BLE001
186
+ problems.append(f"⑤: transaction() 不可用:{exc}")
187
+ return problems
188
+
189
+
190
+ def _g6_event_is_durable_before_dispatch(new: StorageFactory) -> list[str]:
191
+ """⑥ 事件在投递之前已经落地。"""
192
+ problems: list[str] = []
193
+ s = new()
194
+ if not s.enqueue_event(_entry()):
195
+ problems.append("⑥: 第一次入队应该返回 True")
196
+ pending = s.list_pending_events()
197
+ if len(pending) != 1 or pending[0].delivery_state != _delivery.PENDING:
198
+ problems.append("⑥: 刚入队的事件应该处于 pending,且能被列出来")
199
+ return problems
200
+
201
+
202
+ def _g7_delivery_is_idempotent_by_event_id(new: StorageFactory) -> list[str]:
203
+ """⑦ 投递按 event_id 幂等;租约保证同时只有一个 worker 在处理。"""
204
+ problems: list[str] = []
205
+ s = new()
206
+ s.enqueue_event(_entry())
207
+ if s.enqueue_event(_entry()):
208
+ problems.append("⑦: 同一个 event_id 重复入队应该返回 False")
209
+
210
+ first = s.claim_pending_event(worker_id="w1", now=T0, lease_seconds=60)
211
+ if first is None:
212
+ problems.append("⑦: 应该能领到那个 pending 事件")
213
+ return problems
214
+ if s.claim_pending_event(worker_id="w2", now=T0, lease_seconds=60) is not None:
215
+ problems.append(
216
+ "⑦: 租约没到期时第二个 worker 不该领到同一个事件 —— "
217
+ "两个都投出去,用户被提醒两次"
218
+ )
219
+ taken = s.claim_pending_event(worker_id="w2", now=T0 + timedelta(seconds=120),
220
+ lease_seconds=60)
221
+ if taken is None:
222
+ problems.append("⑦: 租约到期后应该能被别的 worker 接管(原持有者可能已经死了)")
223
+ return problems
224
+
225
+
226
+ def _g8_partial_sync_does_not_delete_outside_its_window(new: StorageFactory) -> list[str]:
227
+ """⑧ 局部同步不会误删覆盖范围外的条目。"""
228
+ problems: list[str] = []
229
+ s = new()
230
+ inside = CalendarEventMirror(
231
+ subject_id="u1", source_account_id="a", source_calendar_id="c",
232
+ source_event_id="e_in", event_fields={"start_at": T0},
233
+ last_seen_sync_id="old",
234
+ )
235
+ outside = CalendarEventMirror(
236
+ subject_id="u1", source_account_id="a", source_calendar_id="c",
237
+ source_event_id="e_out", event_fields={"start_at": T0 - timedelta(days=400)},
238
+ last_seen_sync_id="old",
239
+ )
240
+ s.upsert_calendar_events(subject_id="u1", events=[inside, outside])
241
+ s.apply_source_snapshot(
242
+ subject_id="u1", source="ios", collection_kind="calendar", sync_id="new",
243
+ coverage_start=T0 - timedelta(days=1), coverage_end=T0 + timedelta(days=1),
244
+ snapshot_kind="full",
245
+ )
246
+ # 🔴 用端口方法验,**不摸具体实现的内部属性**。
247
+ # 先前这里读的是 InMemoryStorage 的 `.calendar` 字典 —— 换成任何
248
+ # 真实现都读不到,于是 remaining 恒为空集,这一条对每个真 adapter
249
+ # 都报一个假失败。一套"检查别人有没有做对"的工具,自己先得走公开接口。
250
+ remaining = {
251
+ e.source_event_id
252
+ for e in s.list_calendar_events(subject_id="u1", limit=100)
253
+ }
254
+ if "e_out" not in remaining:
255
+ problems.append(
256
+ "⑧: 全量同步删掉了覆盖范围【外】的条目 —— 用户会发现自己去年的"
257
+ "日程凭空消失,而且不可逆"
258
+ )
259
+ if "e_in" in remaining:
260
+ problems.append("⑧: 覆盖范围内这轮没见到的条目应该被删掉")
261
+
262
+ # 增量同步没有资格删任何东西:它只知道"变了什么",不知道"还剩什么"。
263
+ s2 = new()
264
+ s2.upsert_calendar_events(subject_id="u1", events=[inside])
265
+ removed = s2.apply_source_snapshot(
266
+ subject_id="u1", source="ios", collection_kind="calendar", sync_id="new",
267
+ coverage_start=T0 - timedelta(days=1), coverage_end=T0 + timedelta(days=1),
268
+ snapshot_kind="incremental",
269
+ )
270
+ if removed:
271
+ problems.append("⑧: 增量同步不该删除任何条目")
272
+ return problems
273
+
274
+
275
+ def _g9_retention_cleanup_spares_what_permanent_aggregates_need(
276
+ new: StorageFactory,
277
+ ) -> list[str]:
278
+ """⑨ 保留期清理不会破坏永久聚合的正确性。"""
279
+ problems: list[str] = []
280
+ s = new()
281
+ s.append_observation(_obs())
282
+ s.remember_identity(DurableDedupeIdentity(
283
+ subject_id="u1", signal="steps", source="ios",
284
+ source_event_identity_digest="abc", first_applied_at=T0,
285
+ ))
286
+ s.put_aggregate(DailyAggregate(
287
+ subject_id="u1", signal="steps", local_date=DAY, aggregation_kind="daily",
288
+ aggregation_version=1, typed_aggregate={"step_count": {"total": 100}},
289
+ ))
290
+ s.delete_observations(subject_id="u1", before=T0 + timedelta(days=1))
291
+
292
+ if not s.get_aggregate(subject_id="u1", signal="steps",
293
+ start_date=DAY, end_date=DAY):
294
+ problems.append("⑨: 清理明细把永久聚合也删了")
295
+ if not s.has_seen_identity(subject_id="u1", signal="steps", source="ios",
296
+ digest="abc"):
297
+ problems.append("⑨: 清理明细把去重身份也删了")
298
+ return problems
299
+
300
+
301
+ def _g10_subject_isolation_and_purge(new: StorageFactory) -> list[str]:
302
+ """⑩ 用户之间互不可见;删除一个用户能删干净。"""
303
+ problems: list[str] = []
304
+ s = new()
305
+ s.append_observation(_obs(subject_id="u1", observation_id="o1"))
306
+ s.append_observation(_obs(subject_id="u2", observation_id="o2"))
307
+ s.compare_and_put_current(_current(subject_id="u1"), expected_version=-1)
308
+ s.compare_and_put_current(_current(subject_id="u2"), expected_version=-1)
309
+ s.remember_identity(DurableDedupeIdentity(
310
+ subject_id="u1", signal="steps", source="ios",
311
+ source_event_identity_digest="abc", first_applied_at=T0,
312
+ ))
313
+
314
+ # 跨用户负面测试:u2 不该看到 u1 的东西。
315
+ rows, _ = s.list_observations(subject_id="u2", signal="steps")
316
+ if any(o.subject_id != "u2" for o in rows):
317
+ problems.append("⑩: 列观测时看到了别的用户的数据")
318
+ if s.has_seen_identity(subject_id="u2", signal="steps", source="ios",
319
+ digest="abc"):
320
+ problems.append("⑩: 去重身份跨用户串了 —— u2 的新数据会被当成重复丢掉")
321
+
322
+ s.purge_subject(subject_id="u1")
323
+ left, _ = s.list_observations(subject_id="u1", signal="steps")
324
+ if left:
325
+ problems.append("⑩: 删除用户之后还留着观测")
326
+ if s.has_seen_identity(subject_id="u1", signal="steps", source="ios",
327
+ digest="abc"):
328
+ problems.append("⑩: 删除用户之后还留着去重身份")
329
+ still, _ = s.list_observations(subject_id="u2", signal="steps")
330
+ if not still:
331
+ problems.append("⑩: 删 u1 把 u2 的数据也删了")
332
+ return problems
333
+
334
+
335
+ def _g11_both_source_mirrors_round_trip(new: StorageFactory) -> list[str]:
336
+ """⑪ 两个来源镜像都能写进去、读回来。
337
+
338
+ 看起来不值一条。它值 —— **这一条是从一个真实现上倒推出来的**:
339
+
340
+ io 的 Postgres adapter 写提醒时读了 `r.source_created_at`,
341
+ 读回来时又把它当构造参数传回去。`ReminderItemMirror` 上根本没有
342
+ 这个字段(日历那个有,提醒那个没有)。写会抛、读也会抛,
343
+ **整条提醒镜像从来没通过过一次**,而这套套件全绿。
344
+
345
+ ⑧ 已经在用日历了,所以日历那半一直被覆盖着;提醒那半一次都没被碰过。
346
+ 一个只测一半的套件,给出的是「都测过了」的印象。
347
+ """
348
+ problems: list[str] = []
349
+ s = new()
350
+ s.upsert_calendar_events(subject_id="u1", events=[CalendarEventMirror(
351
+ subject_id="u1", source_account_id="a", source_calendar_id="c",
352
+ source_event_id="e1", event_fields={"title": "站会", "start_at": T0},
353
+ )])
354
+ got = list(s.list_calendar_events(subject_id="u1", limit=10))
355
+ if not any(e.source_event_id == "e1" for e in got):
356
+ problems.append("⑪: 日历条目写进去之后读不回来")
357
+
358
+ s2 = new()
359
+ s2.upsert_reminders(subject_id="u1", items=[ReminderItemMirror(
360
+ subject_id="u1", source_account_id="a", source_list_id="l",
361
+ source_reminder_id="r1", reminder_fields={"title": "买牛奶",
362
+ "is_completed": False},
363
+ )])
364
+ back = list(s2.list_reminders(subject_id="u1", limit=10))
365
+ if not any(r.source_reminder_id == "r1" for r in back):
366
+ problems.append(
367
+ "⑪: 提醒条目写进去之后读不回来 —— 提醒镜像整条不通"
368
+ )
369
+ # 已完成的默认不出现,除非明说要。
370
+ s2.upsert_reminders(subject_id="u1", items=[ReminderItemMirror(
371
+ subject_id="u1", source_account_id="a", source_list_id="l",
372
+ source_reminder_id="r2", reminder_fields={"title": "交房租",
373
+ "is_completed": True},
374
+ )])
375
+ default = {r.source_reminder_id
376
+ for r in s2.list_reminders(subject_id="u1", limit=10)}
377
+ if "r2" in default:
378
+ problems.append("⑪: 已完成的提醒默认不该出现在待办列表里")
379
+ with_done = {r.source_reminder_id for r in s2.list_reminders(
380
+ subject_id="u1", include_completed=True, limit=10)}
381
+ if "r2" not in with_done:
382
+ problems.append("⑪: include_completed=True 时应该能读到已完成的提醒")
383
+ return problems
384
+
385
+
386
+ GUARANTEES: dict[str, Callable[[StorageFactory], list[str]]] = {
387
+ "①上报与观测幂等": _g1_report_and_observation_idempotency,
388
+ "②旧数据不覆盖新当前值": _g2_old_does_not_overwrite_new,
389
+ "③同身份异内容报冲突": _g3_same_identity_different_content_conflicts,
390
+ "④永久聚合抗重放": _g4_permanent_aggregates_survive_replay,
391
+ "⑤提供原子边界": _g5_atomic_boundary_is_offered,
392
+ "⑥事件投递前已落地": _g6_event_is_durable_before_dispatch,
393
+ "⑦投递按 event_id 幂等": _g7_delivery_is_idempotent_by_event_id,
394
+ "⑧局部同步不误删": _g8_partial_sync_does_not_delete_outside_its_window,
395
+ "⑨清理不破坏永久聚合": _g9_retention_cleanup_spares_what_permanent_aggregates_need,
396
+ "⑩用户隔离与删除": _g10_subject_isolation_and_purge,
397
+ "⑪两个来源镜像都能往返": _g11_both_source_mirrors_round_trip,
398
+ }
399
+
400
+ #: 这几条在内存实现上**永远是绿的**,因为内存天然原子、天然无并发。
401
+ #: 宿主必须另外用真实数据库证明,见模块开头。
402
+ NOT_PROVABLE_IN_MEMORY: frozenset[str] = frozenset({"⑤提供原子边界"})
403
+
404
+
405
+ def run_storage_conformance(factory: StorageFactory) -> list[str]:
406
+ """跑全部十条,返回问题清单(空 = 通过)。
407
+
408
+ 返回列表而不是抛异常:一次看到全部缺口,比逐个修再重跑快得多。
409
+ """
410
+ problems: list[str] = []
411
+ for name, check in GUARANTEES.items():
412
+ try:
413
+ problems += [f"{name} {p}" for p in check(factory)]
414
+ except Exception as exc: # noqa: BLE001
415
+ problems.append(f"{name}: 检查本身抛异常了 —— {type(exc).__name__}: {exc}")
416
+ return problems
417
+
418
+
419
+ __all__ = ["run_storage_conformance", "GUARANTEES", "NOT_PROVABLE_IN_MEMORY"]