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.
- perceptkit/__init__.py +85 -0
- perceptkit/algorithms/__init__.py +40 -0
- perceptkit/algorithms/attribution.py +147 -0
- perceptkit/algorithms/glance.py +236 -0
- perceptkit/algorithms/history.py +663 -0
- perceptkit/algorithms/identity.py +43 -0
- perceptkit/algorithms/observation.py +44 -0
- perceptkit/algorithms/streaks.py +111 -0
- perceptkit/algorithms/trend_models.py +184 -0
- perceptkit/algorithms/wake.py +149 -0
- perceptkit/catalog.py +252 -0
- perceptkit/conformance/__init__.py +28 -0
- perceptkit/conformance/memory.py +364 -0
- perceptkit/conformance/report.py +170 -0
- perceptkit/conformance/suite.py +419 -0
- perceptkit/conformance/wake.py +151 -0
- perceptkit/contracts/__init__.py +97 -0
- perceptkit/contracts/_time.py +89 -0
- perceptkit/contracts/availability.py +77 -0
- perceptkit/contracts/context.py +50 -0
- perceptkit/contracts/delivery.py +167 -0
- perceptkit/contracts/errors.py +22 -0
- perceptkit/contracts/event.py +137 -0
- perceptkit/contracts/observation.py +172 -0
- perceptkit/contracts/receipt.py +129 -0
- perceptkit/contracts/records.py +367 -0
- perceptkit/contracts/report.py +127 -0
- perceptkit/contracts/versioning.py +63 -0
- perceptkit/fields.py +184 -0
- perceptkit/kit.py +223 -0
- perceptkit/manifest/__init__.py +57 -0
- perceptkit/manifest/checks.py +323 -0
- perceptkit/manifest/mapping.py +96 -0
- perceptkit/manifest/minimal.py +1282 -0
- perceptkit/manifest/types.py +211 -0
- perceptkit/manifest/units.py +84 -0
- perceptkit/ports/__init__.py +19 -0
- perceptkit/ports/storage.py +288 -0
- perceptkit/ports/wake.py +43 -0
- perceptkit/processing/__init__.py +49 -0
- perceptkit/processing/aggregate.py +80 -0
- perceptkit/processing/dispatch.py +356 -0
- perceptkit/processing/normalize.py +458 -0
- perceptkit/processing/pipeline.py +406 -0
- perceptkit/processing/recompute.py +170 -0
- perceptkit/processing/recurrence.py +166 -0
- perceptkit/processing/scheduled.py +233 -0
- perceptkit/prompts.py +75 -0
- perceptkit/queries/__init__.py +32 -0
- perceptkit/queries/api.py +457 -0
- perceptkit/retention.py +84 -0
- perceptkit/rules/__init__.py +19 -0
- perceptkit/rules/engine.py +112 -0
- perceptkit/rules/evaluators.py +228 -0
- perceptkit/rules/types.py +236 -0
- perceptkit-0.2.2.dist-info/METADATA +439 -0
- perceptkit-0.2.2.dist-info/RECORD +59 -0
- perceptkit-0.2.2.dist-info/WHEEL +4 -0
- perceptkit-0.2.2.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""逻辑存储对象 —— 宿主必须能映射出来的东西。
|
|
2
|
+
|
|
3
|
+
**这些是逻辑职责,不是表结构。** 一项不必对应一张 SQL 表:宿主可以合并、
|
|
4
|
+
可以用文档数据库、可以用 SQLite。但必须能满足同样的查询、幂等、重算、
|
|
5
|
+
删除和一致性语义 —— 这一点由一致性测试来证明,不靠自觉。
|
|
6
|
+
|
|
7
|
+
八个对象,各自回答一个问题:
|
|
8
|
+
|
|
9
|
+
StoredObservation 发生过什么(唯一的事实来源,其余都是它的派生)
|
|
10
|
+
CurrentProjection 现在是什么状态
|
|
11
|
+
DailyAggregate 这一天/这一段汇总起来是什么
|
|
12
|
+
CalendarEventMirror 外部日历现在有哪些条目
|
|
13
|
+
ReminderItemMirror 外部提醒现在有哪些条目
|
|
14
|
+
SourceSyncState 跟外部来源同步到哪儿了
|
|
15
|
+
DurableDedupeIdentity 这条我处理过没有(明细删了也要能回答)
|
|
16
|
+
EventOutboxEntry 哪些事件还没送到
|
|
17
|
+
|
|
18
|
+
``IngestReceipt`` / ``WakeReceipt`` 在 :mod:`~perceptkit.contracts.receipt`,
|
|
19
|
+
``EventDefinition`` / ``EventRuleState`` 属于规则层。
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import date, datetime
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from . import delivery
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# 事实
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class StoredObservation:
|
|
35
|
+
"""一条落库的标准观测。
|
|
36
|
+
|
|
37
|
+
唯一身份推荐 ``(subject_id, source, signal, source_event_id)``;
|
|
38
|
+
上游给不了稳定 id 的,由 manifest 声明确定性 identity 策略。
|
|
39
|
+
|
|
40
|
+
**这是唯一的事实来源。** current 是它的投影、日聚合是它的派生,
|
|
41
|
+
两者都能从它重算,反过来不行 —— 聚合是压缩过的,压缩不可逆。
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
observation_id: str
|
|
45
|
+
subject_id: str
|
|
46
|
+
signal: str
|
|
47
|
+
signal_schema_version: int
|
|
48
|
+
source: str
|
|
49
|
+
occurred_at: datetime
|
|
50
|
+
received_at: datetime
|
|
51
|
+
availability: str
|
|
52
|
+
#: 按发生时的本地时区归到哪一天。跨时区飞行后**不重排历史** ——
|
|
53
|
+
#: 8 月 20 日永远是"上海时间的 8 月 20 日"。
|
|
54
|
+
effective_local_date: date
|
|
55
|
+
typed_value: dict[str, Any] | None = None
|
|
56
|
+
timezone: str | None = None
|
|
57
|
+
source_event_id: str | None = None
|
|
58
|
+
source_revision: str | int | None = None
|
|
59
|
+
created_at: datetime | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# 当前值
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
#: 新观测该不该替换掉当前值的三种结论。
|
|
67
|
+
REPLACE = "replace"
|
|
68
|
+
IGNORE = "ignore"
|
|
69
|
+
CONFLICT = "conflict"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class CurrentProjection:
|
|
74
|
+
"""某个 subject / signal / 维度的当前值。
|
|
75
|
+
|
|
76
|
+
唯一身份 ``(subject_id, signal, dimension_key)``。``dimension_key`` 用于
|
|
77
|
+
同一信号下有多个并列实体的情况(每个 Wi-Fi 锚点一条、每个健康指标一条);
|
|
78
|
+
没有这种情况的信号固定用信号名。
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
subject_id: str
|
|
82
|
+
signal: str
|
|
83
|
+
dimension_key: str
|
|
84
|
+
typed_value: dict[str, Any] | None
|
|
85
|
+
availability: str
|
|
86
|
+
observed_at: datetime
|
|
87
|
+
received_at: datetime
|
|
88
|
+
#: 超过这个时刻就不能再叫"当前" —— 但仍可作为带 ``as_of`` 的 last known 返回。
|
|
89
|
+
expires_at: datetime | None = None
|
|
90
|
+
source_observation_id: str | None = None
|
|
91
|
+
source_revision: str | int | None = None
|
|
92
|
+
#: 乐观并发用的版本号。宿主的 compare-and-put 靠它。
|
|
93
|
+
version: int = 0
|
|
94
|
+
#: 内容摘要。用来分辨"同一时刻的重传"和"同一时刻的不同内容"。
|
|
95
|
+
content_digest: str | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _compare_revisions(new: str | int | None, old: str | int | None) -> int | None:
|
|
99
|
+
"""比较两个 revision。返回 ``1 / 0 / -1``,**比不了时返回 ``None``**。
|
|
100
|
+
|
|
101
|
+
比不了就说比不了,不要编一个顺序出来。之前的写法给"任何字符串都大于
|
|
102
|
+
任何整数"、字符串之间按字典序 —— 那是**稳定**,不是**正确**:
|
|
103
|
+
``"10" < "2"``,而 HealthKit 的 revision 恰恰可能是数字字符串。
|
|
104
|
+
编出来的顺序会让一次错误的覆盖看起来完全正常。
|
|
105
|
+
|
|
106
|
+
比不了的情况交给调用方当 conflict 处理,由人或宿主决定。
|
|
107
|
+
"""
|
|
108
|
+
if new is None and old is None:
|
|
109
|
+
return 0
|
|
110
|
+
if old is None:
|
|
111
|
+
return 1 # 有版本的比没版本的新
|
|
112
|
+
if new is None:
|
|
113
|
+
return -1
|
|
114
|
+
|
|
115
|
+
def as_int(v: Any) -> int | None:
|
|
116
|
+
if isinstance(v, bool):
|
|
117
|
+
return None
|
|
118
|
+
if isinstance(v, int):
|
|
119
|
+
return v
|
|
120
|
+
if isinstance(v, str) and v.strip().lstrip("-").isdigit():
|
|
121
|
+
return int(v)
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
a, b = as_int(new), as_int(old)
|
|
125
|
+
if a is not None and b is not None:
|
|
126
|
+
return (a > b) - (a < b)
|
|
127
|
+
if isinstance(new, str) and isinstance(old, str):
|
|
128
|
+
# 都是不可解释的字符串(etag 之类):只能判等,判不了大小。
|
|
129
|
+
return 0 if new == old else None
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def decide_current_update(
|
|
134
|
+
*,
|
|
135
|
+
new_occurred_at: datetime,
|
|
136
|
+
new_revision: str | int | None,
|
|
137
|
+
new_digest: str | None,
|
|
138
|
+
existing: CurrentProjection | None,
|
|
139
|
+
) -> str:
|
|
140
|
+
"""新观测该 ``REPLACE`` / ``IGNORE`` / 还是报 ``CONFLICT``。
|
|
141
|
+
|
|
142
|
+
比较键是 **(occurred_at, source_revision)**,不是只有 occurred_at。
|
|
143
|
+
|
|
144
|
+
产品规范这里有个缺口:§7.3 说"只有 occurred_at 更新的数据才能覆盖 Current",
|
|
145
|
+
但 §4.2 定义了 ``source_revision``、§5.5 又要求"来源侧样本修订应更新对应
|
|
146
|
+
canonical sample"。两条组合起来,**同一时刻的纠错版本永远覆盖不了当前值** ——
|
|
147
|
+
用户在健康 App 里改掉一个误录的体重,我们这边还显示旧的。
|
|
148
|
+
(见 OPEN-QUESTIONS B15,已提给产品方确认。)
|
|
149
|
+
|
|
150
|
+
规则:
|
|
151
|
+
|
|
152
|
+
更晚的 occurred_at → replace
|
|
153
|
+
同 occurred_at + 更高 revision → replace(这就是修订)
|
|
154
|
+
同 occurred_at + 同 revision + 同内容 → ignore(就是重传)
|
|
155
|
+
同 occurred_at + 同 revision + 异内容 → conflict(不能静默挑一个)
|
|
156
|
+
更早的 occurred_at → ignore(迟到数据进历史,不动当前值)
|
|
157
|
+
"""
|
|
158
|
+
if existing is None:
|
|
159
|
+
return REPLACE
|
|
160
|
+
if new_occurred_at > existing.observed_at:
|
|
161
|
+
return REPLACE
|
|
162
|
+
if new_occurred_at < existing.observed_at:
|
|
163
|
+
return IGNORE
|
|
164
|
+
|
|
165
|
+
order = _compare_revisions(new_revision, existing.source_revision)
|
|
166
|
+
if order is None:
|
|
167
|
+
# 版本形态对不上(一个整数一个 etag、或两个不可解释的字符串各不相同)。
|
|
168
|
+
# 编一个顺序出来会让一次错误的覆盖看起来完全正常。
|
|
169
|
+
return CONFLICT
|
|
170
|
+
if order > 0:
|
|
171
|
+
return REPLACE
|
|
172
|
+
if order < 0:
|
|
173
|
+
return IGNORE
|
|
174
|
+
|
|
175
|
+
# 同时刻、同版本:只可能是重传,或者两个来源打架。
|
|
176
|
+
if new_digest is not None and existing.content_digest is not None:
|
|
177
|
+
return IGNORE if new_digest == existing.content_digest else CONFLICT
|
|
178
|
+
# 摘要缺失时不敢断言是重传 —— 静默覆盖会让"到底哪份生效了"永远说不清。
|
|
179
|
+
return CONFLICT
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# 派生
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True)
|
|
187
|
+
class DailyAggregate:
|
|
188
|
+
"""某一天(或某个窗口)的派生统计。
|
|
189
|
+
|
|
190
|
+
唯一身份 ``(subject_id, signal, local_date, aggregation_kind, aggregation_version)``。
|
|
191
|
+
|
|
192
|
+
``aggregation_version`` 是为算法升级准备的:改了口径就换版本号重算,
|
|
193
|
+
**不原地改写旧统计的语义** —— 否则同一张表里的历史数据一半是老口径、
|
|
194
|
+
一半是新口径,而且看不出来。
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
subject_id: str
|
|
198
|
+
signal: str
|
|
199
|
+
local_date: date
|
|
200
|
+
aggregation_kind: str
|
|
201
|
+
aggregation_version: int
|
|
202
|
+
typed_aggregate: dict[str, Any]
|
|
203
|
+
#: 归属用的时区。跨时区之后旧记录保持原时区,不重排。
|
|
204
|
+
timezone_attribution: str | None = None
|
|
205
|
+
#: 这个聚合覆盖了哪些观测(数量、时间范围)。重算时用来判断完整性。
|
|
206
|
+
source_coverage: dict[str, Any] = field(default_factory=dict)
|
|
207
|
+
updated_at: datetime | None = None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# 外部来源镜像
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
@dataclass(frozen=True)
|
|
215
|
+
class CalendarEventMirror:
|
|
216
|
+
"""外部日历里现在还存在的一条日程。
|
|
217
|
+
|
|
218
|
+
**镜像不是快照历史。** 存的是"来源现在有哪些条目",条目自己带着
|
|
219
|
+
过去或未来的时间;不存"我们每次同步时看到了什么"。来源删除 → 本地删除。
|
|
220
|
+
|
|
221
|
+
唯一身份必须**包含来源账户和日历**:不同账户碰巧用同一个 event id
|
|
222
|
+
是完全可能的。
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
subject_id: str
|
|
226
|
+
source_account_id: str
|
|
227
|
+
source_calendar_id: str
|
|
228
|
+
source_event_id: str
|
|
229
|
+
event_fields: dict[str, Any]
|
|
230
|
+
source_revision: str | int | None = None
|
|
231
|
+
#: 重复日程的系列身份。无限重复的日程存规则,不展开到无限未来。
|
|
232
|
+
recurrence_identity: str | None = None
|
|
233
|
+
source_created_at: datetime | None = None
|
|
234
|
+
source_updated_at: datetime | None = None
|
|
235
|
+
#: 最后一次在哪轮同步里见过它。用来判断"这次全量同步没见到 = 来源删了"。
|
|
236
|
+
last_seen_sync_id: str | None = None
|
|
237
|
+
updated_at: datetime | None = None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass(frozen=True)
|
|
241
|
+
class ReminderItemMirror:
|
|
242
|
+
"""外部提醒里现在还存在的一条待办。"""
|
|
243
|
+
|
|
244
|
+
subject_id: str
|
|
245
|
+
source_account_id: str
|
|
246
|
+
source_list_id: str
|
|
247
|
+
source_reminder_id: str
|
|
248
|
+
reminder_fields: dict[str, Any]
|
|
249
|
+
source_revision: str | int | None = None
|
|
250
|
+
last_seen_sync_id: str | None = None
|
|
251
|
+
updated_at: datetime | None = None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass(frozen=True)
|
|
255
|
+
class SourceSyncState:
|
|
256
|
+
"""跟某个外部来源同步到哪儿了。
|
|
257
|
+
|
|
258
|
+
``coverage_start`` / ``coverage_end`` 是这次同步**明确覆盖**的范围。
|
|
259
|
+
全量同步只能删除这个范围内消失的条目 —— 拿一个局部窗口去删窗口外的数据,
|
|
260
|
+
是同步实现最容易犯的错,而且删完不可逆。
|
|
261
|
+
|
|
262
|
+
``last_successful_sync_at`` 必须可查询:同步长期失败时,应该显示
|
|
263
|
+
"日历数据已过期",而不是继续声称它是最新完整的。
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
subject_id: str
|
|
267
|
+
source: str
|
|
268
|
+
collection_kind: str
|
|
269
|
+
sync_cursor: str | None = None
|
|
270
|
+
coverage_start: datetime | None = None
|
|
271
|
+
coverage_end: datetime | None = None
|
|
272
|
+
#: ``full`` 或 ``incremental``。全量才有资格删条目。
|
|
273
|
+
snapshot_kind: str | None = None
|
|
274
|
+
last_attempted_at: datetime | None = None
|
|
275
|
+
last_successful_sync_at: datetime | None = None
|
|
276
|
+
last_error_code: str | None = None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
# 去重身份
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
@dataclass(frozen=True)
|
|
284
|
+
class DurableDedupeIdentity:
|
|
285
|
+
"""明细已经按保留期删掉了,但永久聚合仍不能被旧数据重放重复累计。
|
|
286
|
+
|
|
287
|
+
典型场景:照片单条明细只留 7 天,"每日新增几张"永久保存。第 8 天设备
|
|
288
|
+
重放了一批旧上报 —— 明细查不到了,拿什么判断这批处理过?
|
|
289
|
+
|
|
290
|
+
答案是一个**不可逆的指纹**:看不出是哪张照片(所以不敏感、可以永久留),
|
|
291
|
+
只回答一个问题——见过没有。
|
|
292
|
+
|
|
293
|
+
``retain_until`` 为 ``None`` 表示永久。**清理任务绝不能删掉它所保护的
|
|
294
|
+
永久聚合还在用的那些身份** —— 那会让重放静默地把数字加两遍。
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
subject_id: str
|
|
298
|
+
signal: str
|
|
299
|
+
source: str
|
|
300
|
+
#: 上游身份的不可逆摘要。存摘要不存原 id:原 id 能反查到用户的相册/健康记录。
|
|
301
|
+
source_event_identity_digest: str
|
|
302
|
+
first_applied_at: datetime
|
|
303
|
+
#: 它保护的是哪个聚合范围(如 ``daily_added_count``)。
|
|
304
|
+
aggregate_scope: str | None = None
|
|
305
|
+
retain_until: datetime | None = None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
# 待投递
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
@dataclass(frozen=True)
|
|
313
|
+
class EventOutboxEntry:
|
|
314
|
+
"""一个已经落地、但还没确认送达的事件。
|
|
315
|
+
|
|
316
|
+
**先落地再投递**是这套东西唯一的可靠性保证:走到这条记录被提交那一刻,
|
|
317
|
+
事件就丢不了了,之后崩多少次都能重投。
|
|
318
|
+
|
|
319
|
+
``budget_reservation_id`` 是冷却额度的**占位**,不是消耗。它在
|
|
320
|
+
``claimed`` 时创建、``delivered`` 时兑现、其余终态释放 —— 产品规范只说了
|
|
321
|
+
"accepted 之后提交额度",没说 accepted 之前那段窗口怎么防并发重复投递。
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
event_id: str
|
|
325
|
+
subject_id: str
|
|
326
|
+
definition_id: str
|
|
327
|
+
definition_version: int
|
|
328
|
+
event_type: str
|
|
329
|
+
occurred_at: datetime
|
|
330
|
+
detected_at: datetime
|
|
331
|
+
#: 事件的完整快照。规则后来被改被删,这个事件仍然解释得通。
|
|
332
|
+
fact_snapshot: dict[str, Any]
|
|
333
|
+
delivery_state: str = delivery.PENDING
|
|
334
|
+
#: 同一件事的去重键。runtime 崩溃重投时靠它认出是同一个。
|
|
335
|
+
dedupe_key: str | None = None
|
|
336
|
+
attempt_count: int = 0
|
|
337
|
+
next_attempt_at: datetime | None = None
|
|
338
|
+
#: 当前租约的持有者和到期时间。到期没进展 → 别人可以接管。
|
|
339
|
+
lease_owner: str | None = None
|
|
340
|
+
lease_expires_at: datetime | None = None
|
|
341
|
+
#: 这一次认领的令牌。**每次认领都必须换一个新的。**
|
|
342
|
+
#: 存回执时要比对:旧 worker 租约过期后回来,手里拿的是旧令牌,
|
|
343
|
+
#: 不能推进新 worker 已经接管的记录 —— 否则一次超时会变成一次错误的
|
|
344
|
+
#: 状态覆盖,而且看起来完全正常。
|
|
345
|
+
claim_token: str | None = None
|
|
346
|
+
budget_reservation_id: str | None = None
|
|
347
|
+
created_at: datetime | None = None
|
|
348
|
+
|
|
349
|
+
def __post_init__(self) -> None:
|
|
350
|
+
if self.delivery_state not in delivery.DELIVERY_STATES:
|
|
351
|
+
raise ValueError(
|
|
352
|
+
f"delivery_state={self.delivery_state!r} 不在 "
|
|
353
|
+
f"{sorted(delivery.DELIVERY_STATES)}"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
@property
|
|
357
|
+
def is_terminal(self) -> bool:
|
|
358
|
+
return delivery.is_terminal(self.delivery_state)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
__all__ = [
|
|
362
|
+
"StoredObservation",
|
|
363
|
+
"CurrentProjection", "REPLACE", "IGNORE", "CONFLICT", "decide_current_update",
|
|
364
|
+
"DailyAggregate",
|
|
365
|
+
"CalendarEventMirror", "ReminderItemMirror", "SourceSyncState",
|
|
366
|
+
"DurableDedupeIdentity", "EventOutboxEntry",
|
|
367
|
+
]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""上报契约 —— 宿主交给 kit 的东西长什么样。
|
|
2
|
+
|
|
3
|
+
这一层刻意很薄。它只回答一个问题:**一个宿主怎样把一批已经采集到的数据,
|
|
4
|
+
以稳定、可校验、可版本化的方式交给 kit。**
|
|
5
|
+
|
|
6
|
+
不在这里的(属于 adapter,不属于协议):
|
|
7
|
+
|
|
8
|
+
HTTP 路由 · 掉线重传队列 · 加密 / 解密 / enclave / 密钥管理 ·
|
|
9
|
+
设备怎么读 HealthKit / Core Location / EventKit
|
|
10
|
+
|
|
11
|
+
kit 收到的是**已经过宿主认证、解密和基本传输校验**之后的东西。
|
|
12
|
+
|
|
13
|
+
两个字段刻意不在信封里:
|
|
14
|
+
|
|
15
|
+
subject_id 由宿主从已认证的连接注入,不能信客户端自报身份 ——
|
|
16
|
+
否则任何人都能往别人账号里写观测。
|
|
17
|
+
received_at 由宿主的接收层生成。producer 报的时间(``reported_at``)
|
|
18
|
+
可能不准(设备时钟错、离线补传),审计要用宿主自己的钟。
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from typing import Any, Iterable
|
|
25
|
+
|
|
26
|
+
from . import versioning
|
|
27
|
+
from ._time import TimestampError, parse_timestamp
|
|
28
|
+
from .errors import ContractError
|
|
29
|
+
from .observation import Observation
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class ReportEnvelope:
|
|
34
|
+
"""一批上报。
|
|
35
|
+
|
|
36
|
+
``observations`` 允许为空:producer 有时只是想说"我还活着,这轮没有新东西",
|
|
37
|
+
这也是有效信息(可以用来更新 coverage),不该被当成错误。
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
schema_version: int
|
|
41
|
+
report_id: str
|
|
42
|
+
producer: str
|
|
43
|
+
observations: tuple[Observation, ...] = ()
|
|
44
|
+
#: 宿主生成或匿名化的设备实例标识。**不该是可直接追踪的硬件序列号。**
|
|
45
|
+
producer_instance_id: str | None = None
|
|
46
|
+
#: producer 自己说的上报时刻。仅作参考 —— 权威的是宿主注入的 received_at。
|
|
47
|
+
reported_at: datetime | None = None
|
|
48
|
+
#: 协议没定义的额外字段。原样保留,便于排查,但不参与任何判断。
|
|
49
|
+
extensions: dict[str, Any] = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def parse(cls, payload: object) -> "ReportEnvelope":
|
|
53
|
+
"""从一个 dict 解析并校验。
|
|
54
|
+
|
|
55
|
+
未知的顶层字段进 ``extensions`` 而不是报错 —— 见
|
|
56
|
+
:mod:`perceptkit.contracts.versioning`,这是有意的向前兼容。
|
|
57
|
+
"""
|
|
58
|
+
errors: list[str] = []
|
|
59
|
+
if not isinstance(payload, dict):
|
|
60
|
+
raise ContractError([f"report must be an object, got {type(payload).__name__}"])
|
|
61
|
+
|
|
62
|
+
# 版本先判:版本不对,底下的字段语义就无从谈起,继续校验没有意义。
|
|
63
|
+
try:
|
|
64
|
+
schema_version = versioning.check_report_version(payload.get("schema_version"))
|
|
65
|
+
except versioning.UnsupportedSchemaVersion as exc:
|
|
66
|
+
raise ContractError([str(exc)]) from exc
|
|
67
|
+
|
|
68
|
+
report_id = payload.get("report_id")
|
|
69
|
+
if not isinstance(report_id, str) or not report_id.strip():
|
|
70
|
+
errors.append("report_id: required, must be a non-empty string")
|
|
71
|
+
|
|
72
|
+
producer = payload.get("producer")
|
|
73
|
+
if not isinstance(producer, str) or not producer.strip():
|
|
74
|
+
errors.append("producer: required, must be a non-empty string (e.g. 'ios')")
|
|
75
|
+
|
|
76
|
+
instance_id = payload.get("producer_instance_id")
|
|
77
|
+
if instance_id is not None and not isinstance(instance_id, str):
|
|
78
|
+
errors.append("producer_instance_id: must be a string when present")
|
|
79
|
+
|
|
80
|
+
reported_at: datetime | None = None
|
|
81
|
+
raw_reported = payload.get("reported_at")
|
|
82
|
+
if raw_reported is not None:
|
|
83
|
+
try:
|
|
84
|
+
reported_at = parse_timestamp(raw_reported, field="reported_at")
|
|
85
|
+
except TimestampError as exc:
|
|
86
|
+
errors.append(str(exc))
|
|
87
|
+
|
|
88
|
+
raw_observations = payload.get("observations", [])
|
|
89
|
+
observations: list[Observation] = []
|
|
90
|
+
if not isinstance(raw_observations, (list, tuple)):
|
|
91
|
+
errors.append("observations: must be an array")
|
|
92
|
+
else:
|
|
93
|
+
for index, item in enumerate(raw_observations):
|
|
94
|
+
try:
|
|
95
|
+
observations.append(Observation.parse(item))
|
|
96
|
+
except ContractError as exc:
|
|
97
|
+
errors.extend(f"observations[{index}].{e}" for e in exc.errors)
|
|
98
|
+
|
|
99
|
+
if errors:
|
|
100
|
+
raise ContractError(errors)
|
|
101
|
+
|
|
102
|
+
known = {
|
|
103
|
+
"schema_version", "report_id", "producer",
|
|
104
|
+
"producer_instance_id", "reported_at", "observations",
|
|
105
|
+
}
|
|
106
|
+
extensions = {k: v for k, v in payload.items() if k not in known}
|
|
107
|
+
|
|
108
|
+
return cls(
|
|
109
|
+
schema_version=schema_version,
|
|
110
|
+
report_id=report_id.strip(), # type: ignore[union-attr]
|
|
111
|
+
producer=producer.strip(), # type: ignore[union-attr]
|
|
112
|
+
observations=tuple(observations),
|
|
113
|
+
producer_instance_id=instance_id,
|
|
114
|
+
reported_at=reported_at,
|
|
115
|
+
extensions=extensions,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def signals(self) -> Iterable[str]:
|
|
119
|
+
"""这批上报涉及哪些 signal。用来只加载相关的 EventDefinition。"""
|
|
120
|
+
seen: set[str] = set()
|
|
121
|
+
for obs in self.observations:
|
|
122
|
+
if obs.signal not in seen:
|
|
123
|
+
seen.add(obs.signal)
|
|
124
|
+
yield obs.signal
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
__all__ = ["ContractError", "ReportEnvelope"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Schema 版本与兼容规则。
|
|
2
|
+
|
|
3
|
+
三个互不相干的版本号,别混:
|
|
4
|
+
|
|
5
|
+
REPORT_SCHEMA_VERSION 上报信封的解释版本。producer 写,kit 读。
|
|
6
|
+
EVENT_SCHEMA_VERSION 事件信封的解释版本。kit 写,宿主 runtime 读。
|
|
7
|
+
signal_schema_version 某个 signal 的 payload 版本。逐信号独立演进,
|
|
8
|
+
在 manifest 里声明,不在这里。
|
|
9
|
+
|
|
10
|
+
**跨版本客户端一定会同时在线。** 用户不会同时升级 App —— 老版本 iOS 还在
|
|
11
|
+
发 v1 的时候,后端已经是 v2 了。所以"版本不认识时怎么办"必须是协议的一部分,
|
|
12
|
+
不能留给每个宿主自己拍。
|
|
13
|
+
|
|
14
|
+
规则(**待 Seven 确认,见 OPEN-QUESTIONS B6**):
|
|
15
|
+
|
|
16
|
+
主版本不认识 拒收,返回明确的错误码。宁可让 producer 看到失败并重试/升级,
|
|
17
|
+
也不要用猜出来的语义去解释一批数据 —— 猜错会静默污染历史。
|
|
18
|
+
主版本认识 接收。payload 里多出来的字段忽略掉(向前兼容:新版 producer
|
|
19
|
+
发了新字段,老版 kit 读不懂但不该因此拒收整批)。
|
|
20
|
+
字段缺失 按各自契约的必填规则判,和版本无关。
|
|
21
|
+
|
|
22
|
+
"忽略未知字段"是有意的:它让 producer 可以先发新字段、宿主后升级,
|
|
23
|
+
两边不用同步发版。代价是拼错的字段名不会报错 —— 由 manifest 校验去兜。
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
#: 上报信封的当前版本。
|
|
28
|
+
REPORT_SCHEMA_VERSION = 1
|
|
29
|
+
|
|
30
|
+
#: 事件信封的当前版本。
|
|
31
|
+
EVENT_SCHEMA_VERSION = 1
|
|
32
|
+
|
|
33
|
+
#: 能解释的上报主版本。收到不在这里面的,拒收整批。
|
|
34
|
+
SUPPORTED_REPORT_VERSIONS: frozenset[int] = frozenset({1})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UnsupportedSchemaVersion(ValueError):
|
|
38
|
+
"""收到了看不懂的 schema 版本。
|
|
39
|
+
|
|
40
|
+
带上收到的版本和支持的版本,让 adapter 能把这个信息回给 producer ——
|
|
41
|
+
producer 据此决定是升级还是降级重发,而不是盲目重试。
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, got: object, supported: frozenset[int]) -> None:
|
|
45
|
+
self.got = got
|
|
46
|
+
self.supported = supported
|
|
47
|
+
super().__init__(
|
|
48
|
+
f"unsupported schema_version {got!r}; this build understands "
|
|
49
|
+
f"{sorted(supported)}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check_report_version(version: object) -> int:
|
|
54
|
+
"""校验上报信封的版本,返回归一后的整数版本号。
|
|
55
|
+
|
|
56
|
+
不认识就抛 :class:`UnsupportedSchemaVersion` —— 见模块开头,这是有意的:
|
|
57
|
+
用猜出来的语义解释一批数据,错了是静默污染历史,比拒收贵得多。
|
|
58
|
+
"""
|
|
59
|
+
if isinstance(version, bool) or not isinstance(version, int):
|
|
60
|
+
raise UnsupportedSchemaVersion(version, SUPPORTED_REPORT_VERSIONS)
|
|
61
|
+
if version not in SUPPORTED_REPORT_VERSIONS:
|
|
62
|
+
raise UnsupportedSchemaVersion(version, SUPPORTED_REPORT_VERSIONS)
|
|
63
|
+
return version
|