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,406 @@
|
|
|
1
|
+
"""处理管线 —— 谁在什么时候被调用。
|
|
2
|
+
|
|
3
|
+
**这就是上一版真正缺的东西。** 上一版交了一堆算式(分类、去重键、排序、日聚合),
|
|
4
|
+
但"按什么顺序调它们"留在了宿主的业务代码里。结果是别人拿到一盒零件和一本
|
|
5
|
+
没有装配图的说明书 —— 装上了跑不起来,跑起来了每个宿主的行为还不一样。
|
|
6
|
+
|
|
7
|
+
顺序在这里定死,宿主只实现被调用的方法。宿主想"先投递再落地"?做不到,
|
|
8
|
+
他手里根本没有那个顺序。
|
|
9
|
+
|
|
10
|
+
这个模块实现前七步(落地为止):
|
|
11
|
+
|
|
12
|
+
① 批级幂等:这批处理过没有
|
|
13
|
+
② 按 manifest 校验、标准化
|
|
14
|
+
③ 观测级幂等:这条处理过没有
|
|
15
|
+
④ 写观测
|
|
16
|
+
⑤ 记住去重身份(明细将来被清理掉之后,靠它挡住重放)
|
|
17
|
+
⑥ 只在 (occurred_at, source_revision) 更新时才动当前值
|
|
18
|
+
⑦ 折进当天的聚合
|
|
19
|
+
|
|
20
|
+
规则求值、写发件箱、投递是后三步,在 ``dispatch`` 模块(批 3B)。
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any, Callable, Mapping, Sequence
|
|
26
|
+
|
|
27
|
+
from ..contracts import receipt as _receipt
|
|
28
|
+
from ..contracts.context import IngestContext
|
|
29
|
+
from ..contracts.records import (
|
|
30
|
+
CONFLICT,
|
|
31
|
+
IGNORE,
|
|
32
|
+
REPLACE,
|
|
33
|
+
CurrentProjection,
|
|
34
|
+
DailyAggregate,
|
|
35
|
+
DurableDedupeIdentity,
|
|
36
|
+
)
|
|
37
|
+
from ..contracts.report import ReportEnvelope
|
|
38
|
+
from ..manifest.types import SignalDefinition
|
|
39
|
+
from ..ports.storage import StoragePort
|
|
40
|
+
from ..rules.types import EventDefinition
|
|
41
|
+
from . import aggregate as _aggregate
|
|
42
|
+
from .dispatch import evaluate_and_enqueue
|
|
43
|
+
from .normalize import NormalizedObservation, _canonical, normalize_observations
|
|
44
|
+
|
|
45
|
+
#: 聚合算法的版本。改了口径就加这个数并重算,**不原地改写旧统计的语义** ——
|
|
46
|
+
#: 否则同一张表里一半是老口径一半是新口径,而且看不出来。
|
|
47
|
+
AGGREGATION_VERSION = 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class IngestOutcome:
|
|
52
|
+
"""一批上报处理完的结果。"""
|
|
53
|
+
|
|
54
|
+
receipt: _receipt.IngestReceipt
|
|
55
|
+
applied: list[NormalizedObservation] = field(default_factory=list)
|
|
56
|
+
#: 去重挡掉的(这条之前处理过)。不是错误,是重传的正常结果。
|
|
57
|
+
duplicates: list[NormalizedObservation] = field(default_factory=list)
|
|
58
|
+
#: 校验没过的:``(下标, 问题清单)``。**不影响同一批里其他观测**。
|
|
59
|
+
rejected: list[tuple[int, tuple[str, ...]]] = field(default_factory=list)
|
|
60
|
+
#: 同一时刻同一版本但内容不同 —— 不静默挑一个,交给宿主决定。
|
|
61
|
+
conflicts: list[NormalizedObservation] = field(default_factory=list)
|
|
62
|
+
warnings: list[str] = field(default_factory=list)
|
|
63
|
+
#: 这批上报产生的事件(已经落进发件箱,还没投)。
|
|
64
|
+
events: list[Any] = field(default_factory=list)
|
|
65
|
+
#: 求值了但没触发的规则,带原因。排查"为什么没提醒我"时要用。
|
|
66
|
+
rule_misses: list[tuple[str, str | None]] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def ok(self) -> bool:
|
|
70
|
+
return not self.rejected and not self.conflicts
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _epoch(dt: Any) -> float:
|
|
74
|
+
return dt.timestamp()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ingest_report(
|
|
78
|
+
report: ReportEnvelope,
|
|
79
|
+
*,
|
|
80
|
+
context: IngestContext,
|
|
81
|
+
storage: StoragePort,
|
|
82
|
+
signals: Mapping[str, SignalDefinition],
|
|
83
|
+
definitions: Sequence[EventDefinition] = (),
|
|
84
|
+
extra_evaluators: Mapping[str, Callable[..., Any]] | None = None,
|
|
85
|
+
timezone_fallback: str | None = None,
|
|
86
|
+
max_observations: int = 200,
|
|
87
|
+
max_payload_bytes: int = 256 * 1024,
|
|
88
|
+
) -> IngestOutcome:
|
|
89
|
+
"""把一批上报走完前七步。
|
|
90
|
+
|
|
91
|
+
``max_observations`` / ``max_payload_bytes`` 是资源上限。前台每 30 秒一次
|
|
92
|
+
上报,不设上限的话,一个构造过的 report 就能让单次 ingest 跑很久。
|
|
93
|
+
超限**拒收整批**而不是截断 —— 截断会静默丢数据,比拒收难查得多。
|
|
94
|
+
"""
|
|
95
|
+
payload_digest = _batch_digest(report)
|
|
96
|
+
|
|
97
|
+
# 真的量一下,而不是摆一个从不生效的参数。
|
|
98
|
+
# ⚠️ 这里量的是【已经解析完】的结构 —— 真正的防线必须在宿主的 HTTP 层
|
|
99
|
+
# (Content-Length / 流式读取上限)。到这一步内存和解析 CPU 已经花掉了。
|
|
100
|
+
approx_bytes = len(payload_digest) + sum(
|
|
101
|
+
len(_canonical(o.value or {})) for o in report.observations
|
|
102
|
+
)
|
|
103
|
+
if approx_bytes > max_payload_bytes:
|
|
104
|
+
return IngestOutcome(
|
|
105
|
+
receipt=_receipt.IngestReceipt(
|
|
106
|
+
subject_id=context.subject_id, producer=report.producer,
|
|
107
|
+
report_id=report.report_id, payload_digest=payload_digest,
|
|
108
|
+
received_at=context.received_at, status=_receipt.INGEST_REJECTED,
|
|
109
|
+
error_code="payload_too_large",
|
|
110
|
+
),
|
|
111
|
+
rejected=[(-1, (
|
|
112
|
+
f"payload 约 {approx_bytes} 字节,超过上限 {max_payload_bytes}",
|
|
113
|
+
))],
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if len(report.observations) > max_observations:
|
|
117
|
+
return IngestOutcome(
|
|
118
|
+
receipt=_receipt.IngestReceipt(
|
|
119
|
+
subject_id=context.subject_id, producer=report.producer,
|
|
120
|
+
report_id=report.report_id, payload_digest=payload_digest,
|
|
121
|
+
received_at=context.received_at, status=_receipt.INGEST_REJECTED,
|
|
122
|
+
error_code="too_many_observations",
|
|
123
|
+
),
|
|
124
|
+
rejected=[(-1, (
|
|
125
|
+
f"一批最多 {max_observations} 条观测,收到 {len(report.observations)} 条",
|
|
126
|
+
))],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# 🔴 【整批一个事务】。认领、全部观测、最终回执必须一起成功或一起不生效。
|
|
130
|
+
#
|
|
131
|
+
# 之前是"先认领、再逐条各自提交":第 1 条提交后崩溃,重试会直接拿到
|
|
132
|
+
# duplicate,剩下的观测【永久丢失】——批级幂等把一次中断伪装成了"已处理完"。
|
|
133
|
+
# 代价是单个事务变长,所以 max_observations 是必须的,不是可选的。
|
|
134
|
+
with storage.transaction():
|
|
135
|
+
# ① 批级幂等。同 identity 同摘要 -> 返回原结果不重复处理;
|
|
136
|
+
# 同 identity 异摘要 -> conflict,不静默覆盖。
|
|
137
|
+
claim = storage.claim_report(
|
|
138
|
+
subject_id=context.subject_id,
|
|
139
|
+
producer=report.producer,
|
|
140
|
+
report_id=report.report_id,
|
|
141
|
+
payload_digest=payload_digest,
|
|
142
|
+
received_at=context.received_at,
|
|
143
|
+
)
|
|
144
|
+
if claim.status != _receipt.INGEST_ACCEPTED:
|
|
145
|
+
return IngestOutcome(receipt=claim)
|
|
146
|
+
|
|
147
|
+
# ② 校验 + 标准化。
|
|
148
|
+
normalized = normalize_observations(
|
|
149
|
+
report.observations,
|
|
150
|
+
context=context,
|
|
151
|
+
signals=signals,
|
|
152
|
+
source=report.producer,
|
|
153
|
+
timezone_fallback=timezone_fallback,
|
|
154
|
+
)
|
|
155
|
+
outcome = IngestOutcome(
|
|
156
|
+
receipt=claim,
|
|
157
|
+
rejected=list(normalized.rejected),
|
|
158
|
+
warnings=list(normalized.warnings),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
for item in normalized.normalized:
|
|
162
|
+
sig = signals[item.stored.signal]
|
|
163
|
+
_apply_one(item, sig, context=context, storage=storage, outcome=outcome,
|
|
164
|
+
definitions=definitions, extra_evaluators=extra_evaluators)
|
|
165
|
+
|
|
166
|
+
outcome.receipt = _receipt.IngestReceipt(
|
|
167
|
+
subject_id=claim.subject_id, producer=claim.producer,
|
|
168
|
+
report_id=claim.report_id, payload_digest=claim.payload_digest,
|
|
169
|
+
received_at=claim.received_at, status=claim.status,
|
|
170
|
+
observations_applied=len(outcome.applied),
|
|
171
|
+
)
|
|
172
|
+
return outcome
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _apply_one(
|
|
176
|
+
item: NormalizedObservation,
|
|
177
|
+
sig: SignalDefinition,
|
|
178
|
+
*,
|
|
179
|
+
context: IngestContext,
|
|
180
|
+
storage: StoragePort,
|
|
181
|
+
outcome: IngestOutcome,
|
|
182
|
+
definitions: Sequence[EventDefinition] = (),
|
|
183
|
+
extra_evaluators: Mapping[str, Callable[..., Any]] | None = None,
|
|
184
|
+
) -> None:
|
|
185
|
+
"""③~⑨:一条观测的落地,以及命中规则时写发件箱。
|
|
186
|
+
|
|
187
|
+
整条包在一个事务里:观测、去重身份、当前值、聚合要么一起成功,要么一起
|
|
188
|
+
不生效。分开写的话会出现"观测写了但去重身份没写"——下次重传就会重复累计。
|
|
189
|
+
"""
|
|
190
|
+
stored = item.stored
|
|
191
|
+
|
|
192
|
+
# ③ 观测级幂等。问的是【投递身份】不是【事实身份】——用事实身份去重会把
|
|
193
|
+
# "同一条事实的新版本"(电量的新读数、样本的修订)误判成重传丢掉。
|
|
194
|
+
# 也不是问"这条观测还在不在":明细可能已经按保留期删掉了。
|
|
195
|
+
if storage.has_seen_identity(
|
|
196
|
+
subject_id=context.subject_id, signal=stored.signal,
|
|
197
|
+
source=stored.source, digest=item.identity_digest,
|
|
198
|
+
):
|
|
199
|
+
outcome.duplicates.append(item)
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
# ④ 写观测。只留当前值的信号不写明细 —— 否则 current_only 名不副实。
|
|
203
|
+
if sig.stores_history:
|
|
204
|
+
if not storage.append_observation(stored):
|
|
205
|
+
outcome.duplicates.append(item)
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
# ⑤ 记住身份。并发下可能有另一个事务刚记过同一条 —— 那说明它赢了,
|
|
209
|
+
# 我们退出,避免两边都往聚合里加一遍。
|
|
210
|
+
if not storage.remember_identity(DurableDedupeIdentity(
|
|
211
|
+
subject_id=context.subject_id,
|
|
212
|
+
signal=stored.signal,
|
|
213
|
+
source=stored.source,
|
|
214
|
+
source_event_identity_digest=item.identity_digest,
|
|
215
|
+
first_applied_at=context.received_at,
|
|
216
|
+
aggregate_scope=sig.key if sig.keeps_history_forever else None,
|
|
217
|
+
# 永久聚合依赖的身份必须永久保留:明细删了之后,
|
|
218
|
+
# 它是唯一还能挡住重放的东西。
|
|
219
|
+
retain_until=None,
|
|
220
|
+
)):
|
|
221
|
+
outcome.duplicates.append(item)
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
# ⑥ 当前值。
|
|
225
|
+
#
|
|
226
|
+
# `observed` 更新数值
|
|
227
|
+
# `no_data` / `unavailable` **不覆盖最后一次可靠值**,但要把状态记下来
|
|
228
|
+
#
|
|
229
|
+
# 后半句以前没做,后果很具体:用户 09:10 撤销了步数权限,09:20 去查
|
|
230
|
+
# 还在说「fresh,8000 步」—— 把一个已经读不到的值当成当前事实报出去。
|
|
231
|
+
# 规范 §12-12 要的是两件事:不覆盖最后可靠数值,**并且**查询时能表达
|
|
232
|
+
# 当前不可用。
|
|
233
|
+
decision = _update_current(item, sig, context=context, storage=storage,
|
|
234
|
+
outcome=outcome)
|
|
235
|
+
|
|
236
|
+
# 🔴 冲突时暂停一切派生。"到底哪份数据生效了"都说不清的时候,
|
|
237
|
+
# 再去更新聚合、推进规则状态、产生事件,只会把错误扩散。
|
|
238
|
+
if decision == CONFLICT:
|
|
239
|
+
outcome.applied.append(item)
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
# ⑦ 聚合。迟到的旧数据(IGNORE)【要】进历史 —— 它只是不该改当前值。
|
|
243
|
+
if sig.stores_history and stored.availability == "observed":
|
|
244
|
+
_update_aggregate(item, sig, context=context, storage=storage)
|
|
245
|
+
|
|
246
|
+
# ⑧⑨ 求值 + 写发件箱。和上面同事务 —— 事件落地了但观测没落地(或反过来),
|
|
247
|
+
# 都会让"为什么会有这个事件"永远解释不清。
|
|
248
|
+
#
|
|
249
|
+
# 只在 observed 且当前值真的推进了(REPLACE)时才跑值变化型规则:
|
|
250
|
+
# 拿 no_data 去喂 `changed`,会把"100 → 没数据"当成一次变化,
|
|
251
|
+
# 还会把 previous 推成 None,之后的 threshold_crossing 全废。
|
|
252
|
+
# 迟到数据(IGNORE)同理 —— 它的 previous/current 讲的不是当前故事。
|
|
253
|
+
if definitions and decision == REPLACE and stored.availability == "observed":
|
|
254
|
+
rules = evaluate_and_enqueue(
|
|
255
|
+
item, context=context, storage=storage,
|
|
256
|
+
definitions=definitions, extra_evaluators=extra_evaluators,
|
|
257
|
+
signal_definition=sig,
|
|
258
|
+
)
|
|
259
|
+
outcome.events.extend(rules.events)
|
|
260
|
+
outcome.rule_misses.extend(rules.misses)
|
|
261
|
+
elif definitions:
|
|
262
|
+
outcome.rule_misses.append(
|
|
263
|
+
("*", f"未求值:availability={stored.availability},当前值决策={decision}")
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
outcome.applied.append(item)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
#: compare-and-put 失败后重读重判几次。并发下"读到旧版本 -> 写失败"是正常的,
|
|
270
|
+
#: 但不能无限重试 —— 那会在热点上把一个事务拖到超时。
|
|
271
|
+
MAX_CAS_RETRIES = 3
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _update_current(
|
|
275
|
+
item: NormalizedObservation,
|
|
276
|
+
sig: SignalDefinition,
|
|
277
|
+
*,
|
|
278
|
+
context: IngestContext,
|
|
279
|
+
storage: StoragePort,
|
|
280
|
+
outcome: IngestOutcome,
|
|
281
|
+
) -> str:
|
|
282
|
+
"""更新当前值,返回决策(``REPLACE`` / ``IGNORE`` / ``CONFLICT``)。
|
|
283
|
+
|
|
284
|
+
**compare-and-put 的返回值必须认。** 忽略它的话,两个并发事务都读到旧版本、
|
|
285
|
+
较新的那个 CAS 失败被静默丢掉 —— 当前值停在旧数据上,没有任何地方报错。
|
|
286
|
+
"""
|
|
287
|
+
from datetime import timedelta
|
|
288
|
+
|
|
289
|
+
from ..contracts.records import decide_current_update
|
|
290
|
+
|
|
291
|
+
stored = item.stored
|
|
292
|
+
dimension = sig.key
|
|
293
|
+
|
|
294
|
+
for attempt in range(MAX_CAS_RETRIES):
|
|
295
|
+
existing = None
|
|
296
|
+
for candidate in storage.get_current(
|
|
297
|
+
subject_id=context.subject_id, signals=[stored.signal],
|
|
298
|
+
).get(stored.signal, ()):
|
|
299
|
+
if candidate.dimension_key == dimension:
|
|
300
|
+
existing = candidate
|
|
301
|
+
break
|
|
302
|
+
|
|
303
|
+
decision = decide_current_update(
|
|
304
|
+
new_occurred_at=stored.occurred_at,
|
|
305
|
+
new_revision=stored.source_revision,
|
|
306
|
+
new_digest=item.content_digest,
|
|
307
|
+
existing=existing,
|
|
308
|
+
)
|
|
309
|
+
if decision == IGNORE:
|
|
310
|
+
return IGNORE
|
|
311
|
+
if decision == CONFLICT:
|
|
312
|
+
# 不静默挑一个。"到底哪份数据生效了"说不清,比多一条冲突记录糟得多。
|
|
313
|
+
outcome.conflicts.append(item)
|
|
314
|
+
return CONFLICT
|
|
315
|
+
|
|
316
|
+
expires = (
|
|
317
|
+
stored.occurred_at + timedelta(seconds=sig.current_ttl_sec)
|
|
318
|
+
if sig.current_ttl_sec > 0 else None
|
|
319
|
+
)
|
|
320
|
+
if storage.compare_and_put_current(
|
|
321
|
+
CurrentProjection(
|
|
322
|
+
subject_id=context.subject_id,
|
|
323
|
+
signal=stored.signal,
|
|
324
|
+
dimension_key=dimension,
|
|
325
|
+
# 非 observed 时保留上一次的可靠值 —— 它变成 last_known。
|
|
326
|
+
# 写 None 进去等于把"我们曾经知道什么"一起抹掉,
|
|
327
|
+
# agent 就只能说"你没有步数数据",而不是
|
|
328
|
+
# "你上次是 8000 步,现在读不到了"。
|
|
329
|
+
typed_value=(stored.typed_value if stored.availability == "observed"
|
|
330
|
+
else (existing.typed_value if existing else None)),
|
|
331
|
+
availability=stored.availability,
|
|
332
|
+
observed_at=stored.occurred_at,
|
|
333
|
+
received_at=stored.received_at,
|
|
334
|
+
expires_at=expires,
|
|
335
|
+
source_observation_id=stored.observation_id,
|
|
336
|
+
source_revision=stored.source_revision,
|
|
337
|
+
version=(existing.version + 1) if existing else 0,
|
|
338
|
+
content_digest=item.content_digest,
|
|
339
|
+
),
|
|
340
|
+
expected_version=existing.version if existing else -1,
|
|
341
|
+
):
|
|
342
|
+
return REPLACE
|
|
343
|
+
# 有人在我们读之后写了。重读、重新判断 —— 说不定这次该 IGNORE 了。
|
|
344
|
+
|
|
345
|
+
outcome.warnings.append(
|
|
346
|
+
f"{stored.signal}: 当前值连续 {MAX_CAS_RETRIES} 次写入竞争失败,本次放弃"
|
|
347
|
+
)
|
|
348
|
+
return IGNORE
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _update_aggregate(
|
|
352
|
+
item: NormalizedObservation,
|
|
353
|
+
sig: SignalDefinition,
|
|
354
|
+
*,
|
|
355
|
+
context: IngestContext,
|
|
356
|
+
storage: StoragePort,
|
|
357
|
+
) -> None:
|
|
358
|
+
stored = item.stored
|
|
359
|
+
day = stored.effective_local_date
|
|
360
|
+
kind = "daily"
|
|
361
|
+
# 按当前算法版本挑。算法升级后旧版本的文档要留着(供对照/回滚),
|
|
362
|
+
# 但绝不能拿旧口径的文档继续 fold 新数据 —— 那会得到一份两种口径混合的统计,
|
|
363
|
+
# 而且看不出来。
|
|
364
|
+
existing = [
|
|
365
|
+
a for a in storage.get_aggregate(
|
|
366
|
+
subject_id=context.subject_id, signal=stored.signal,
|
|
367
|
+
start_date=day, end_date=day, aggregation_kind=kind,
|
|
368
|
+
)
|
|
369
|
+
if a.aggregation_version == AGGREGATION_VERSION
|
|
370
|
+
]
|
|
371
|
+
prev = existing[0].typed_aggregate if existing else None
|
|
372
|
+
doc = _aggregate.fold_into_day(
|
|
373
|
+
prev, sig, stored.typed_value or {}, ts=_epoch(stored.occurred_at),
|
|
374
|
+
)
|
|
375
|
+
coverage = dict((existing[0].source_coverage if existing else {}) or {})
|
|
376
|
+
coverage["observations"] = int(coverage.get("observations", 0)) + 1
|
|
377
|
+
storage.put_aggregate(DailyAggregate(
|
|
378
|
+
subject_id=context.subject_id,
|
|
379
|
+
signal=stored.signal,
|
|
380
|
+
local_date=day,
|
|
381
|
+
aggregation_kind=kind,
|
|
382
|
+
aggregation_version=AGGREGATION_VERSION,
|
|
383
|
+
typed_aggregate=doc,
|
|
384
|
+
timezone_attribution=stored.timezone,
|
|
385
|
+
source_coverage=coverage,
|
|
386
|
+
updated_at=stored.received_at,
|
|
387
|
+
))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _batch_digest(report: ReportEnvelope) -> str:
|
|
391
|
+
from .normalize import _canonical, _digest
|
|
392
|
+
return _digest(
|
|
393
|
+
report.report_id,
|
|
394
|
+
report.producer,
|
|
395
|
+
_canonical([
|
|
396
|
+
{
|
|
397
|
+
"signal": o.signal, "occurred_at": o.occurred_at.isoformat(),
|
|
398
|
+
"availability": o.availability, "value": o.value,
|
|
399
|
+
"source_event_id": o.source_event_id,
|
|
400
|
+
}
|
|
401
|
+
for o in report.observations
|
|
402
|
+
]),
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
__all__ = ["IngestOutcome", "ingest_report", "AGGREGATION_VERSION"]
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""按新算法版本重算日聚合。
|
|
2
|
+
|
|
3
|
+
产品规范 §12「聚合算法升级 | 使用新 aggregation_version 重算,不静默改写语义」。
|
|
4
|
+
先前只做了一半:换版本号、按版本过滤(所以不会两种口径混着 fold),
|
|
5
|
+
**但没有任何东西真的把历史重算出来** —— 升级之后旧日子永远停在旧版本,
|
|
6
|
+
新旧数据一起查就是一段一段口径不同的曲线。
|
|
7
|
+
|
|
8
|
+
## 这件事唯一真正危险的地方
|
|
9
|
+
|
|
10
|
+
重算是**从明细重新折出来**的。而明细有保留期,聚合可以永久 ——
|
|
11
|
+
典型形态就是"明细 1 年、聚合永久"。所以:
|
|
12
|
+
|
|
13
|
+
2023 年的那一天 明细早就清理了 → 能读到的只有零星几条,甚至一条没有
|
|
14
|
+
照样折一遍 → 得到一个"当天走了 200 步"的永久统计
|
|
15
|
+
→ 数字错了一个数量级,没有任何地方报错,而且旧值已被覆盖
|
|
16
|
+
|
|
17
|
+
**所以默认拒绝重算明细可能已经不完整的日子**,而不是尽力而为。
|
|
18
|
+
真要重算那些日子,调用方必须显式说"我知道明细不全,仍然要"
|
|
19
|
+
(``allow_incomplete=True``),并且结果里会标出来。
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import date, datetime, timedelta
|
|
25
|
+
from typing import Mapping
|
|
26
|
+
|
|
27
|
+
from ..contracts.records import DailyAggregate
|
|
28
|
+
from ..manifest.types import PERMANENT, SignalDefinition
|
|
29
|
+
from ..ports.storage import StoragePort
|
|
30
|
+
from . import aggregate as _aggregate
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class RecomputeOutcome:
|
|
35
|
+
"""重算了哪些天、拒绝了哪些天、为什么拒绝。"""
|
|
36
|
+
|
|
37
|
+
rebuilt: list[date] = field(default_factory=list)
|
|
38
|
+
#: ``(那一天, 为什么没算)``
|
|
39
|
+
skipped: list[tuple[date, str]] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def ok(self) -> bool:
|
|
43
|
+
return not self.skipped
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _epoch(dt: datetime) -> float:
|
|
47
|
+
return dt.timestamp()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def details_may_be_incomplete(
|
|
51
|
+
sig: SignalDefinition, day: date, *, today: date,
|
|
52
|
+
) -> bool:
|
|
53
|
+
"""这一天的明细有没有可能已经被保留期清掉了。
|
|
54
|
+
|
|
55
|
+
**宁可误判成"可能不全"**:误判的代价是拒绝重算一天(可恢复),
|
|
56
|
+
反过来的代价是写下一个错了一个数量级的永久统计(不可恢复)。
|
|
57
|
+
"""
|
|
58
|
+
if sig.history_retention_days == PERMANENT:
|
|
59
|
+
return False
|
|
60
|
+
if not sig.stores_history:
|
|
61
|
+
return True # 根本不存明细,无从重算
|
|
62
|
+
cutoff = today - timedelta(days=sig.history_retention_days)
|
|
63
|
+
return day < cutoff
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def recompute_day(
|
|
67
|
+
storage: StoragePort,
|
|
68
|
+
sig: SignalDefinition,
|
|
69
|
+
*,
|
|
70
|
+
subject_id: str,
|
|
71
|
+
day: date,
|
|
72
|
+
version: int,
|
|
73
|
+
updated_at: datetime,
|
|
74
|
+
) -> DailyAggregate:
|
|
75
|
+
"""把某一天的明细重新折成一份聚合文档。**不写库**,由调用方决定写不写。"""
|
|
76
|
+
# 刻意**不按 occurred_at 开时间窗**:一条观测归到哪一天由
|
|
77
|
+
# effective_local_date 决定,跨午夜的睡眠整段归到醒来那天,
|
|
78
|
+
# 按 occurred_at 截窗口会把它切掉一半。宁可多读再过滤。
|
|
79
|
+
rows, cursor = storage.list_observations(
|
|
80
|
+
subject_id=subject_id, signal=sig.key, cursor=None, limit=500,
|
|
81
|
+
)
|
|
82
|
+
# 明细可能不止一页。重算必须读全 —— 只读第一页就是在算一份残缺统计。
|
|
83
|
+
page = list(rows)
|
|
84
|
+
while cursor:
|
|
85
|
+
more, cursor = storage.list_observations(
|
|
86
|
+
subject_id=subject_id, signal=sig.key, cursor=cursor, limit=500,
|
|
87
|
+
)
|
|
88
|
+
page.extend(more)
|
|
89
|
+
|
|
90
|
+
same_day = [o for o in page
|
|
91
|
+
if o.effective_local_date == day and o.availability == "observed"]
|
|
92
|
+
same_day.sort(key=lambda o: (o.occurred_at, o.observation_id))
|
|
93
|
+
|
|
94
|
+
doc: dict = {}
|
|
95
|
+
for obs in same_day:
|
|
96
|
+
doc = _aggregate.fold_into_day(
|
|
97
|
+
doc, sig, obs.typed_value or {}, ts=_epoch(obs.occurred_at),
|
|
98
|
+
)
|
|
99
|
+
return DailyAggregate(
|
|
100
|
+
subject_id=subject_id,
|
|
101
|
+
signal=sig.key,
|
|
102
|
+
local_date=day,
|
|
103
|
+
aggregation_kind="daily",
|
|
104
|
+
aggregation_version=version,
|
|
105
|
+
typed_aggregate=doc,
|
|
106
|
+
timezone_attribution=same_day[0].timezone if same_day else None,
|
|
107
|
+
source_coverage={
|
|
108
|
+
"observations": len(same_day),
|
|
109
|
+
# 标出来这份统计是重算的,不是随观测一条条折出来的。
|
|
110
|
+
# 排查"这个数字怎么变了"时,第一件事就是看它是不是被重算过。
|
|
111
|
+
"recomputed": True,
|
|
112
|
+
},
|
|
113
|
+
updated_at=updated_at,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def recompute_range(
|
|
118
|
+
storage: StoragePort,
|
|
119
|
+
signals: Mapping[str, SignalDefinition],
|
|
120
|
+
*,
|
|
121
|
+
subject_id: str,
|
|
122
|
+
signal: str,
|
|
123
|
+
start_date: date,
|
|
124
|
+
end_date: date,
|
|
125
|
+
version: int,
|
|
126
|
+
now: datetime,
|
|
127
|
+
allow_incomplete: bool = False,
|
|
128
|
+
) -> RecomputeOutcome:
|
|
129
|
+
"""按 ``version`` 重算一段日期的聚合。
|
|
130
|
+
|
|
131
|
+
``now`` 由调用方传 —— 这个包不读时钟。它只用来判断"这一天的明细
|
|
132
|
+
是不是已经过了保留期"。
|
|
133
|
+
|
|
134
|
+
``allow_incomplete=False``(默认)时,明细可能已经不全的日子**不算**,
|
|
135
|
+
并在 ``skipped`` 里说明原因。
|
|
136
|
+
"""
|
|
137
|
+
outcome = RecomputeOutcome()
|
|
138
|
+
sig = signals.get(signal)
|
|
139
|
+
if sig is None:
|
|
140
|
+
outcome.skipped.append((start_date, f"manifest 里没有 {signal}"))
|
|
141
|
+
return outcome
|
|
142
|
+
if not sig.stores_history:
|
|
143
|
+
outcome.skipped.append((start_date, f"{signal} 不存明细,无从重算"))
|
|
144
|
+
return outcome
|
|
145
|
+
|
|
146
|
+
today = now.date()
|
|
147
|
+
day = start_date
|
|
148
|
+
while day <= end_date:
|
|
149
|
+
if not allow_incomplete and details_may_be_incomplete(sig, day, today=today):
|
|
150
|
+
outcome.skipped.append((
|
|
151
|
+
day,
|
|
152
|
+
f"{day} 早于明细保留期({sig.history_retention_days} 天),"
|
|
153
|
+
"明细可能已被清理。拿残缺明细重算会写下一个数量级都不对的统计,"
|
|
154
|
+
"而且旧值会被覆盖、不可恢复。确实要算就传 allow_incomplete=True",
|
|
155
|
+
))
|
|
156
|
+
day += timedelta(days=1)
|
|
157
|
+
continue
|
|
158
|
+
storage.put_aggregate(recompute_day(
|
|
159
|
+
storage, sig, subject_id=subject_id, day=day,
|
|
160
|
+
version=version, updated_at=now,
|
|
161
|
+
))
|
|
162
|
+
outcome.rebuilt.append(day)
|
|
163
|
+
day += timedelta(days=1)
|
|
164
|
+
return outcome
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
__all__ = [
|
|
168
|
+
"RecomputeOutcome", "details_may_be_incomplete",
|
|
169
|
+
"recompute_day", "recompute_range",
|
|
170
|
+
]
|