perceptkit 0.2.5__tar.gz → 0.2.7__tar.gz

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 (111) hide show
  1. {perceptkit-0.2.5 → perceptkit-0.2.7}/CHANGELOG.md +47 -0
  2. {perceptkit-0.2.5 → perceptkit-0.2.7}/PKG-INFO +1 -1
  3. {perceptkit-0.2.5 → perceptkit-0.2.7}/pyproject.toml +1 -1
  4. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/conformance/memory.py +8 -0
  5. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/kit.py +54 -1
  6. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/ports/storage.py +15 -0
  7. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/processing/recompute.py +57 -2
  8. perceptkit-0.2.7/src/perceptkit/retention.py +194 -0
  9. perceptkit-0.2.7/tests/test_retention_entry.py +178 -0
  10. perceptkit-0.2.7/tests/test_revision_recompute.py +103 -0
  11. {perceptkit-0.2.5 → perceptkit-0.2.7}/uv.lock +2 -2
  12. perceptkit-0.2.5/src/perceptkit/retention.py +0 -84
  13. {perceptkit-0.2.5 → perceptkit-0.2.7}/.github/workflows/ci.yml +0 -0
  14. {perceptkit-0.2.5 → perceptkit-0.2.7}/.github/workflows/release.yml +0 -0
  15. {perceptkit-0.2.5 → perceptkit-0.2.7}/.gitignore +0 -0
  16. {perceptkit-0.2.5 → perceptkit-0.2.7}/LICENSE +0 -0
  17. {perceptkit-0.2.5 → perceptkit-0.2.7}/NOTES-packaging.md +0 -0
  18. {perceptkit-0.2.5 → perceptkit-0.2.7}/NOTES-quickstart.md +0 -0
  19. {perceptkit-0.2.5 → perceptkit-0.2.7}/README.md +0 -0
  20. {perceptkit-0.2.5 → perceptkit-0.2.7}/docs/PerceptKit-/344/272/247/345/223/201/347/233/256/346/240/207/344/270/216/345/275/223/345/211/215/345/256/236/347/216/260/345/267/256/350/267/235/345/217/215/351/246/210.md" +0 -0
  21. {perceptkit-0.2.5 → perceptkit-0.2.7}/docs/PerceptKit-/346/204/237/347/237/245/345/255/227/346/256/265/344/270/216/345/255/230/345/202/250-/345/267/245/347/250/213/345/257/271/351/275/220/350/241/245/345/205/205/350/256/250/350/256/272.md" +0 -0
  22. {perceptkit-0.2.5 → perceptkit-0.2.7}/docs/reference-storage-mapping.md +0 -0
  23. {perceptkit-0.2.5 → perceptkit-0.2.7}/examples/end_to_end.py +0 -0
  24. {perceptkit-0.2.5 → perceptkit-0.2.7}/examples/ios_adapter.py +0 -0
  25. {perceptkit-0.2.5 → perceptkit-0.2.7}/examples/quickstart.py +0 -0
  26. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/__init__.py +0 -0
  27. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/algorithms/__init__.py +0 -0
  28. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/algorithms/attribution.py +0 -0
  29. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/algorithms/glance.py +0 -0
  30. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/algorithms/history.py +0 -0
  31. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/algorithms/identity.py +0 -0
  32. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/algorithms/observation.py +0 -0
  33. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/algorithms/streaks.py +0 -0
  34. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/algorithms/trend_models.py +0 -0
  35. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/algorithms/wake.py +0 -0
  36. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/catalog.py +0 -0
  37. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/conformance/__init__.py +0 -0
  38. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/conformance/report.py +0 -0
  39. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/conformance/suite.py +0 -0
  40. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/conformance/wake.py +0 -0
  41. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/contracts/__init__.py +0 -0
  42. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/contracts/_time.py +0 -0
  43. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/contracts/availability.py +0 -0
  44. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/contracts/context.py +0 -0
  45. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/contracts/delivery.py +0 -0
  46. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/contracts/errors.py +0 -0
  47. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/contracts/event.py +0 -0
  48. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/contracts/observation.py +0 -0
  49. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/contracts/receipt.py +0 -0
  50. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/contracts/records.py +0 -0
  51. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/contracts/report.py +0 -0
  52. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/contracts/versioning.py +0 -0
  53. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/fields.py +0 -0
  54. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/manifest/__init__.py +0 -0
  55. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/manifest/checks.py +0 -0
  56. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/manifest/mapping.py +0 -0
  57. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/manifest/minimal.py +0 -0
  58. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/manifest/types.py +0 -0
  59. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/manifest/units.py +0 -0
  60. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/ports/__init__.py +0 -0
  61. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/ports/wake.py +0 -0
  62. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/processing/__init__.py +0 -0
  63. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/processing/aggregate.py +0 -0
  64. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/processing/dispatch.py +0 -0
  65. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/processing/normalize.py +0 -0
  66. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/processing/pipeline.py +0 -0
  67. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/processing/recurrence.py +0 -0
  68. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/processing/scheduled.py +0 -0
  69. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/prompts.py +0 -0
  70. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/queries/__init__.py +0 -0
  71. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/queries/api.py +0 -0
  72. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/rules/__init__.py +0 -0
  73. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/rules/engine.py +0 -0
  74. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/rules/evaluators.py +0 -0
  75. {perceptkit-0.2.5 → perceptkit-0.2.7}/src/perceptkit/rules/types.py +0 -0
  76. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/fixtures/README.md +0 -0
  77. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/fixtures/ios_snapshot_no_data.json +0 -0
  78. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/fixtures/ios_snapshot_normal.json +0 -0
  79. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/fixtures/ios_snapshot_unauthorized.json +0 -0
  80. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_attribution.py +0 -0
  81. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_catalog.py +0 -0
  82. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_conformance.py +0 -0
  83. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_conformance_wake_report.py +0 -0
  84. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_contracts.py +0 -0
  85. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_delivery_and_records.py +0 -0
  86. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_docs_match_code.py +0 -0
  87. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_edge_cases.py +0 -0
  88. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_end_to_end.py +0 -0
  89. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_event_envelope.py +0 -0
  90. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_examples.py +0 -0
  91. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_export.py +0 -0
  92. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_identity.py +0 -0
  93. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_ios_fixture.py +0 -0
  94. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_isolation.py +0 -0
  95. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_manifest.py +0 -0
  96. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_no_host_leakage.py +0 -0
  97. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_observation.py +0 -0
  98. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_pipeline.py +0 -0
  99. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_projection.py +0 -0
  100. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_purity.py +0 -0
  101. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_queries.py +0 -0
  102. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_recompute.py +0 -0
  103. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_recurrence.py +0 -0
  104. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_regressions.py +0 -0
  105. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_retention.py +0 -0
  106. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_rules.py +0 -0
  107. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_scheduled.py +0 -0
  108. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_source_mirror.py +0 -0
  109. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_streaks.py +0 -0
  110. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_trend_models.py +0 -0
  111. {perceptkit-0.2.5 → perceptkit-0.2.7}/tests/test_wake.py +0 -0
@@ -1,5 +1,52 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.2.7 — 2026-09-02
4
+
5
+ - **跳过原因带上稳定的 `code`(`SkippedSignal`)。** 0.2.6 的清理计划里,
6
+ 跳过理由只有一句中文 —— 这个包的注释都是中文,但**宿主的运维界面不一定是**,
7
+ 直接印进一个英文报告里就成了半中半英。一个库不该替宿主决定报告用什么语言:
8
+ `code` 稳定、机器可读,文案归宿主。`SkippedSignal` 仍然能按 `(signal, detail)`
9
+ 解包,老调用方不用改。
10
+ (0.2.6 发出去之后接 io 时当场撞到的 —— 那份英文运维报告里蹦出中文。)
11
+
12
+ ## 0.2.6 — 2026-09-02
13
+
14
+ 外部审查那份清单里剩下的两条(P0-1 / P0-6 的可做部分)。
15
+
16
+ ### 新增
17
+
18
+ - **`PerceptionKit.run_retention()` —— 清理终于有了统一入口(P0-1)。**
19
+ 以前包里只有保留期表和查询函数,没有任何东西真的执行,于是每个宿主自己
20
+ 照 manifest 推导一遍。**什么时候跑仍然是宿主的事**(这里没有调度),
21
+ 但规则只该有一份 —— 这条路上每个坑错了都不报错:明细和聚合是两个保留期、
22
+ PERMANENT 要跳过、没声明的不许猜一个、去重身份不能跟着明细删。
23
+ 默认 `dry_run=True`:这是包里唯一会永久删用户数据的动作。
24
+ - **`StoragePort.delete_aggregates()`。** 端口原来只有明细的删除口,
25
+ 日聚合根本删不掉 —— 宿主要么自己写 SQL,要么干脆不清,而
26
+ 「有限保留期的聚合永远不删」不会报错,只会让库一直长。
27
+
28
+ ### 修复
29
+
30
+ - **修订过的那天,重算会把错值和改正值一起折进去(P0-6 的一半)。**
31
+ `source_revision` 原来**只**用在当前值上,历史和聚合完全没读它:
32
+ 心率 90(revision 1)被改成 60(revision 2),重算那天折出来是 75 ——
33
+ 一个从来没发生过的数字。而这个错只有重算时才现形,当前值那条路是对的,
34
+ 所以"改完之后当前显示对了"会让人以为整条链路都对了。
35
+ 修订号按数字比不按字符串比(否则 `"10" < "9"`,第 10 版被第 9 版盖掉)。
36
+ **一组观测全都没有修订号时一条都不合并** —— 那等于替它们编一个
37
+ "后来的覆盖先来的"顺序,而那恰好不是 `cumulative` 现在的规则(它取 max)。
38
+
39
+ ### 已知未做
40
+
41
+ - **来源侧删除没有 tombstone 契约。** 修订带着 `availability` 一起来时,
42
+ 上面那条已经能把事实从当天移掉;但 HealthKit 真正的删除走的是
43
+ `deletedObjects`,那是一条完全没接的路。
44
+ - **`cumulative` 取 max,分不清「迟到的旧数据」和「向下的修正」。**
45
+ 用户删掉一次运动、当日活动能量从 500 降到 300 —— 当前值会跟着降,
46
+ 日聚合停在 500,同一天同一个指标两个数。改成"最新的赢"能修这个,
47
+ 但会让来源侧的一次归零(比如重装后当日步数从头算)把那天清掉。
48
+ 两种规则各有一个失败模式,要产品拍板,不从代码里溜进去。
49
+
3
50
  ## 0.2.5 — 2026-09-02
4
51
 
5
52
  **外部审查(2026-09-02)逐条核出来的。** 四条里三条是"查询静默给错答案",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: perceptkit
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: 从设备信号判断:有没有发生值得留意的事、值不值得叫醒一次 agent、以及该怎么把此刻的状况讲给它听。不采集数据、不选数据库、不调模型 —— 存储由宿主实现 StoragePort,编排在包内。
5
5
  License:
6
6
  Apache License
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "perceptkit"
7
- version = "0.2.5"
7
+ version = "0.2.7"
8
8
  description = "从设备信号判断:有没有发生值得留意的事、值不值得叫醒一次 agent、以及该怎么把此刻的状况讲给它听。不采集数据、不选数据库、不调模型 —— 存储由宿主实现 StoragePort,编排在包内。"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -164,6 +164,14 @@ class InMemoryStorage:
164
164
 
165
165
  # -- 聚合 ------------------------------------------------------------
166
166
 
167
+ def delete_aggregates(self, *, subject_id, signal, before) -> int:
168
+ doomed = [k for k, v in self.aggregates.items()
169
+ if v.subject_id == subject_id and v.signal == signal
170
+ and v.local_date < before]
171
+ for k in doomed:
172
+ del self.aggregates[k]
173
+ return len(doomed)
174
+
167
175
  def get_aggregate(self, *, subject_id, signal, start_date, end_date,
168
176
  aggregation_kind=None):
169
177
  return [
@@ -14,7 +14,7 @@
14
14
  from __future__ import annotations
15
15
 
16
16
  from dataclasses import dataclass, field
17
- from datetime import date, datetime
17
+ from datetime import date, datetime, time, timezone
18
18
  from typing import Any, Callable, Mapping, Sequence
19
19
 
20
20
  from .contracts.context import IngestContext
@@ -26,6 +26,7 @@ from .ports.wake import WakePort
26
26
  from .processing.dispatch import DispatchOutcome, drain
27
27
  from .processing.pipeline import AGGREGATION_VERSION, IngestOutcome, ingest_report
28
28
  from .processing.recompute import RecomputeOutcome, recompute_range
29
+ from .retention import plan_retention
29
30
  from .processing.scheduled import ScheduledOutcome, evaluate_absence, evaluate_daily
30
31
  from .queries import api as _queries
31
32
  from .rules.types import EventDefinition
@@ -142,6 +143,58 @@ class PerceptionKit:
142
143
  now=now, allow_incomplete=allow_incomplete,
143
144
  )
144
145
 
146
+ def run_retention(
147
+ self, *, subject_id: str, now: datetime, dry_run: bool = True,
148
+ ) -> dict[str, Any]:
149
+ """按 manifest 清理过期数据。**默认只试跑。**
150
+
151
+ 规则在 ``retention.plan_retention``(纯函数),删除走存储端口 ——
152
+ **什么时候跑仍然是宿主的事**,这里没有任何调度。给这个入口是因为
153
+ 规则只该有一份:早先 kit 只声明保留期、不提供执行,于是每个宿主
154
+ 自己照 manifest 推导一遍,而这条路上每个坑错了都不报错
155
+ (明细和聚合是两个保留期、PERMANENT 要跳过、没声明的不许猜、
156
+ 去重身份不能跟着明细删)。
157
+
158
+ ``dry_run=True`` 是默认值,不是谨慎癖:这是这个包里**唯一**会永久
159
+ 删用户数据的动作,而保留期的 bug 从外面完全看不见 —— 系统照常工作,
160
+ 用户只是安静地少了历史,直到有人问一个数据已经答不出的问题。
161
+ 先看一眼数字,再决定要不要真删。
162
+
163
+ 按 subject 清,和这个端口所有其他方法一样。宿主要全量清就自己循环 ——
164
+ 跨用户的一条 DELETE 少写一个 WHERE 就会删掉别人的数据,
165
+ 而这个包里没有一个地方允许那种写法存在。
166
+ """
167
+ plan = plan_retention(self.signals, now=now)
168
+ removed: dict[str, int] = {}
169
+ if not dry_run:
170
+ for action in plan.actions:
171
+ if action.kind == "observations":
172
+ n = self.storage.delete_observations(
173
+ subject_id=subject_id, signal=action.signal,
174
+ before=datetime.combine(action.before, time.min,
175
+ tzinfo=timezone.utc),
176
+ )
177
+ else:
178
+ n = self.storage.delete_aggregates(
179
+ subject_id=subject_id, signal=action.signal,
180
+ before=action.before,
181
+ )
182
+ if n:
183
+ removed[f"{action.signal}.{action.kind}"] = (
184
+ removed.get(f"{action.signal}.{action.kind}", 0) + int(n))
185
+ return {
186
+ "applied": not dry_run,
187
+ "planned": [
188
+ {"signal": a.signal, "kind": a.kind, "before": a.before.isoformat()}
189
+ for a in plan.actions
190
+ ],
191
+ # 故意不删的也要列出来 —— 一份只说"删了 0 条"的报告,读不出
192
+ # 「是没到期,还是规则写错了」。
193
+ "skipped": [{"signal": s.signal, "code": s.code, "detail": s.detail}
194
+ for s in plan.skipped],
195
+ "removed": removed,
196
+ }
197
+
145
198
  def evaluate_absence(
146
199
  self, *, subject_id: str, now: datetime,
147
200
  ) -> ScheduledOutcome:
@@ -125,6 +125,21 @@ class StoragePort(Protocol):
125
125
 
126
126
  # -- 聚合 ------------------------------------------------------------
127
127
 
128
+ def delete_aggregates(
129
+ self, *, subject_id: str, signal: str, before: date,
130
+ ) -> int:
131
+ """按**聚合的**保留期清理日聚合,返回删了多少条。
132
+
133
+ 和 :meth:`delete_observations` 是两个动作,因为是两个保留期:典型形态
134
+ 就是「明细 1 年、聚合永久」。没有这个方法的话,宿主要么自己写 SQL
135
+ (于是每个宿主各自重新推导一遍规则),要么干脆不清 —— 而
136
+ 「有限保留期的聚合永远不删」不会报错,只会让库一直长。
137
+
138
+ 🔴 **``aggregate_retention_days`` 是 PERMANENT 的信号绝不能进来。**
139
+ 判定在 kit 里(``run_retention``),不指望每个宿主自己记得。
140
+ """
141
+ ...
142
+
128
143
  def get_aggregate(
129
144
  self, *, subject_id: str, signal: str,
130
145
  start_date: date, end_date: date,
@@ -63,6 +63,60 @@ def details_may_be_incomplete(
63
63
  return day < cutoff
64
64
 
65
65
 
66
+ def _revision_key(raw: object) -> tuple[int, str]:
67
+ """修订号的比较键。数字按数字比,其余按字符串比,两者不混。
68
+
69
+ ``"10"`` 和 ``10`` 是不同的东西:全按字符串比的话 ``"10" < "9"``,
70
+ 第 10 版会被第 9 版盖掉。所以数字排一档、字符串排另一档,
71
+ 并且**数字档永远小于字符串档**——不给两个不可比的值编一个假的顺序。
72
+ """
73
+ if raw is None:
74
+ return (0, "")
75
+ text = str(raw)
76
+ try:
77
+ return (1, f"{int(text):020d}")
78
+ except ValueError:
79
+ return (2, text)
80
+
81
+
82
+ def _canonical(rows: list, sig: SignalDefinition) -> list:
83
+ """同一件源事实的多个修订,只留最新那一版。
84
+
85
+ ``source_revision`` 原来只用在当前值上(高版本替换低版本),**历史和聚合
86
+ 完全没用它**。于是修订过的那天会同时折进错值和改正值:
87
+
88
+ 体重 70.5kg(revision 1)→ 用户在健康 app 里改成 68.5(revision 2)
89
+ 重算那天 → 两条都在 → 平均值 69.5,一个从来没发生过的数字
90
+
91
+ 而且这个错**只有重算时才现形**:当前值那条路是对的,所以"改过之后当前
92
+ 显示对了"会让人以为整条链路都对了。
93
+
94
+ 只对按 ``source_event_id`` 定身份的信号生效 —— 别的信号一条观测就是
95
+ 一件独立的事,本来就不该合并。
96
+ """
97
+ if sig.identity_strategy != "source_event_id":
98
+ return rows
99
+ groups: dict[str, list] = {}
100
+ for o in rows:
101
+ if o.source_event_id is not None: # 没有源身份的,各算各的
102
+ groups.setdefault(o.source_event_id, []).append(o)
103
+
104
+ winners = set()
105
+ for key, members in groups.items():
106
+ # **一组里全都没有修订号 = 没有"哪版更新"的信息,一条都不合并。**
107
+ # 合并的话就等于替这些观测编一个"后来的覆盖先来的"的顺序,而那正好
108
+ # 是 `cumulative` 现在**不**采用的规则(它取 max)—— 重算和增量折叠
109
+ # 会给出两个不同的数,同一份数据看你从哪条路读。
110
+ # 修订语义该不该改成"最新的赢"是另一件事,得先定,不能从这里溜进去。
111
+ if all(o.source_revision is None for o in members):
112
+ winners.update(id(o) for o in members)
113
+ continue
114
+ best = max(members, key=lambda o: (_revision_key(o.source_revision),
115
+ o.occurred_at, o.observation_id))
116
+ winners.add(id(best))
117
+ return [o for o in rows if o.source_event_id is None or id(o) in winners]
118
+
119
+
66
120
  def recompute_day(
67
121
  storage: StoragePort,
68
122
  sig: SignalDefinition,
@@ -87,8 +141,9 @@ def recompute_day(
87
141
  )
88
142
  page.extend(more)
89
143
 
90
- same_day = [o for o in page
91
- if o.effective_local_date == day and o.availability == "observed"]
144
+ same_day = [o for o in page if o.effective_local_date == day]
145
+ same_day = _canonical(same_day, sig)
146
+ same_day = [o for o in same_day if o.availability == "observed"]
92
147
  same_day.sort(key=lambda o: (o.occurred_at, o.observation_id))
93
148
 
94
149
  doc: dict = {}
@@ -0,0 +1,194 @@
1
+ """保留期(存多久才删)与保质期(多久之后不再采信)。
2
+
3
+ ★ **接定时器仍然是宿主的事**,这里没有任何调度。但「删什么、留什么、
4
+ 为什么跳过」是**规则**,规则只该有一份 —— 早先这里连规则都不给,
5
+ 于是每个宿主自己照 manifest 重新推导一遍,而这条路上的每个坑
6
+ (两个保留期要分开、永久的要跳过、去重身份不能跟着明细删)
7
+ 错了都不报错,只是安静地少数据或多数据。
8
+ ``plan_retention`` 给规则,``PerceptionKit.run_retention`` 走端口执行。
9
+
10
+ ★ 保留期的判据:这条数据在 N 个月后,还会改变 agent 对这个人的理解吗。
11
+
12
+ ★ 保质期这一块只列「改判测量时间之后需要改的」。改判之前它判的是
13
+ 「距这次上报多久」,改判之后判「这条数据多久前测的」—— 沿用旧值会把功能
14
+ 杀死:体重 24 小时 = 除非今天刚称过否则永远 null。
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import datetime as _dt
19
+ from dataclasses import dataclass, field as _field
20
+ from typing import TYPE_CHECKING, Mapping as _Mapping
21
+
22
+ from .manifest.types import PERMANENT
23
+
24
+ if TYPE_CHECKING: # 仅为类型标注,运行时不引入依赖
25
+ from .manifest.types import SignalDefinition
26
+
27
+ KEEP_FOREVER = None
28
+
29
+ _DAY = 86400.0
30
+
31
+ # signal -> 保留天数;None = 永久;不在表里 = 不进历史表
32
+ RETENTION_DAYS: dict[str, int | None] = {
33
+ # 永久:趋势本身就是价值
34
+ "health_body": KEEP_FOREVER,
35
+ "health_sleep": KEEP_FOREVER,
36
+ "health_vitals": KEEP_FOREVER,
37
+ "health_activity": KEEP_FOREVER,
38
+ "health_workout": KEEP_FOREVER,
39
+ "health_metabolic": KEEP_FOREVER,
40
+ "health_mood": KEEP_FOREVER,
41
+ "health_cycle": KEEP_FOREVER,
42
+ "location_signal": KEEP_FOREVER,
43
+ # 一年:年度口味有价值,再久没人问
44
+ "playback": 365,
45
+ # 90 天:瞬时状态,回看价值掉得快
46
+ "motion_state": 90,
47
+ "focus": 90,
48
+ "audio_route": 90,
49
+ # weather 现在的 SHAPE 是 NUMERIC_DIST(history.py),仍在产生 rollup,
50
+ # 所以现在必须有真实保留期 —— 跟瞬时状态同档。
51
+ # ⚠️ Codex code_review 2026-08-23 抓到:早先按"weather 即将改成仅当前+预报、
52
+ # 不再存历史"的未来态把这条声明成了 KEEP_FOREVER 之外/None,
53
+ # 但 SHAPE/history.record_daily 从未真的改过去,导致四张声明表互相矛盾。
54
+ # 真要把 weather 改成不存历史时,这一行、attribution.ATTRIBUTION 里的
55
+ # weather 条目、history.SHAPE 里的 weather 条目,三处必须在同一批一起删,
56
+ # 不许只删一处。
57
+ "weather": 90,
58
+ # 60 天:采集窗口是前后 14 天,够覆盖「未来的会 → 过去的会 → 再留一个月回看」
59
+ "calendar_next_event": 60,
60
+ "reminders": 60,
61
+ }
62
+
63
+ # 改判「测量时间」之后的保质期。只列与 catalog 现值不同的;
64
+ # 「现在测现在传」的信号(位置/运动/专注/音频/播放)不需要改。
65
+ MEASURED_AT_TTL_SEC: dict[str, float] = {
66
+ "health_body": 90 * _DAY, # 三个月内称过就还算数
67
+ "health_metabolic": 30 * _DAY, # 一个月内测过就还算数
68
+ "health_cycle": 60 * _DAY, # 两个月内有记录就还算数
69
+ "health_vitals": 7 * _DAY, # 一周内测过就还算数
70
+ }
71
+
72
+
73
+ # ⚠️ 「永久保存」不等于「不可删除」(Codex 评审修订 K)。
74
+ # 以下生命周期动作必须由消费方实现,本表只是保留期,不是删除策略的全部:
75
+ # · 账号删除时清空
76
+ # · 用户主动清空
77
+ # · 健康权限关闭后:禁用读取与 wake(不是继续用存量)
78
+ # · 第三方撤权
79
+ # · 来源侧删除 / 纠正如何传播到我们的汇总
80
+ # 本模块不实现这些 —— 它零 I/O。放在这里是为了让读到保留期的人
81
+ # 不会把「永久」误解成「不可删」。
82
+ LIFECYCLE_NOTE = "retention != undeletable; see design doc 修订 K"
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class RetentionAction:
87
+ """一个信号、一类数据、一条截止线。"""
88
+
89
+ signal: str
90
+ #: ``"observations"`` 或 ``"aggregates"``。
91
+ kind: str
92
+ #: 早于这个时间点的删掉。
93
+ before: _dt.date
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class SkippedSignal:
98
+ """故意不清的一个信号,以及为什么。
99
+
100
+ ``code`` 是稳定的机器可读标识,``detail`` 是给人看的一句话。
101
+ **宿主的运维界面该用 code 自己渲染** —— 一个库不该替宿主决定报告用什么
102
+ 语言。这里的 detail 是中文(这个包的注释都是中文),直接印进一个英文
103
+ 运维报告里就成了半中半英。
104
+ """
105
+
106
+ signal: str
107
+ code: str
108
+ detail: str
109
+
110
+ def __iter__(self):
111
+ """还能按 ``(signal, detail)` 解包 —— 老调用方不用一次全改。"""
112
+ return iter((self.signal, self.detail))
113
+
114
+
115
+ #: 稳定的跳过原因。宿主按这个渲染自己的文案。
116
+ SKIP_NO_HISTORY = "no_history"
117
+ SKIP_DETAILS_PERMANENT = "details_permanent"
118
+ SKIP_DETAILS_UNDECLARED = "details_undeclared"
119
+ SKIP_AGGREGATES_PERMANENT = "aggregates_permanent"
120
+ SKIP_AGGREGATES_UNDECLARED = "aggregates_undeclared"
121
+
122
+
123
+ @dataclass
124
+ class RetentionPlan:
125
+ """要删什么,以及**故意不删什么、为什么**。
126
+
127
+ 跳过的理由必须列出来 —— 一份只说"删了 0 条"的报告,和一份说
128
+ "这些信号是永久保存的所以跳过"的报告,看起来一样,但前者读不出
129
+ 「是没到期,还是规则写错了」。
130
+ """
131
+
132
+ actions: list[RetentionAction] = _field(default_factory=list)
133
+ skipped: list[SkippedSignal] = _field(default_factory=list)
134
+
135
+
136
+ def plan_retention(
137
+ signals: _Mapping[str, "SignalDefinition"], *, now: _dt.datetime,
138
+ ) -> RetentionPlan:
139
+ """按 manifest 算出这一轮该删什么。**纯函数,零 I/O,不读时钟。**
140
+
141
+ 规则一共四条,每条都对应一个"错了不报错"的坑:
142
+
143
+ 明细和聚合是**两个**保留期 典型形态是「明细 1 年、聚合永久」。
144
+ 不分开写就会继承明细的天数,于是
145
+ 「8月1日新增了 5 张照片」一年后被扫掉,
146
+ 而那是一件发生过的事实。
147
+ PERMANENT 的一律跳过 判定放这里,不指望每个宿主记得。
148
+ 没声明保留期的**跳过,不默认** 猜一个数字就是拿真实数据去赌。
149
+ 去重身份**不在这里删** 明细没了之后,它是「重放的上报会不会
150
+ 把永久聚合数两遍」之间唯一的东西,
151
+ 而那个错是不可逆的。
152
+ """
153
+ plan = RetentionPlan()
154
+ for key in sorted(signals):
155
+ sig = signals[key]
156
+ if not sig.stores_history:
157
+ plan.skipped.append(SkippedSignal(key, SKIP_NO_HISTORY, "不进历史表,没东西可清"))
158
+ continue
159
+
160
+ if sig.history_retention_days == PERMANENT:
161
+ plan.skipped.append(SkippedSignal(key, SKIP_DETAILS_PERMANENT, "明细永久保存"))
162
+ elif sig.history_retention_days is None:
163
+ plan.skipped.append(SkippedSignal(key, SKIP_DETAILS_UNDECLARED, "没声明明细保留期 —— 跳过,不替它猜一个"))
164
+ else:
165
+ plan.actions.append(RetentionAction(
166
+ key, "observations",
167
+ (now - _dt.timedelta(days=sig.history_retention_days)).date()))
168
+
169
+ agg_days = sig.effective_aggregate_retention_days
170
+ if agg_days == PERMANENT:
171
+ plan.skipped.append(SkippedSignal(key, SKIP_AGGREGATES_PERMANENT, "日聚合永久保存"))
172
+ elif agg_days is None:
173
+ plan.skipped.append(SkippedSignal(key, SKIP_AGGREGATES_UNDECLARED, "没声明聚合保留期 —— 跳过,不替它猜一个"))
174
+ else:
175
+ plan.actions.append(RetentionAction(
176
+ key, "aggregates",
177
+ (now - _dt.timedelta(days=agg_days)).date()))
178
+ return plan
179
+
180
+
181
+ def stores_history(signal: str) -> bool:
182
+ """这个信号进不进历史表。"""
183
+ return signal in RETENTION_DAYS
184
+
185
+
186
+ def retention_days(signal: str) -> int | None:
187
+ """保留天数;``KEEP_FOREVER``(None)表示永久。
188
+
189
+ 不进历史表的信号调用这个是调用方的错,直接抛 —— 静默返回 None 会被
190
+ 误当成「永久」,那是最坏的一种默认值。
191
+ """
192
+ if signal not in RETENTION_DAYS:
193
+ raise KeyError(f"signal {signal!r} 不进历史表,先用 stores_history() 判断")
194
+ return RETENTION_DAYS[signal]
@@ -0,0 +1,178 @@
1
+ """统一的清理入口 —— 规则只有一份。
2
+
3
+ 外部审查(2026-09-02,P0-1):"Retention 现在是声明,不是完整执行链路"。
4
+ 成立:包里只有保留期表和查询函数,没有任何东西真的执行;于是每个宿主自己
5
+ 照 manifest 推导一遍。io 那边写了一百多行 —— 下一个宿主还得再写一遍,
6
+ 而这条路上的每个坑**错了都不报错**:
7
+
8
+ 明细和聚合当成一个保留期 「8月1日新增了 5 张照片」一年后被扫掉
9
+ 忘了跳过 PERMANENT 永久聚合被删,且不可逆
10
+ 没声明保留期就默认一个 拿真实数据去赌一个猜出来的天数
11
+ 去重身份跟着明细一起删 重放的上报把永久聚合数两遍,无法回滚
12
+
13
+ 所以这个文件测的是**规则**,不是"函数能跑"。
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from datetime import date, datetime, timedelta, timezone
18
+
19
+ import pytest
20
+
21
+ from perceptkit import IngestContext, PerceptionKit
22
+ from perceptkit.conformance import InMemoryStorage
23
+ from perceptkit.manifest import MINIMAL_SIGNALS
24
+ from perceptkit.manifest.types import PERMANENT
25
+ from perceptkit.retention import plan_retention
26
+
27
+ NOW = datetime(2026, 9, 2, 12, 0, tzinfo=timezone.utc)
28
+
29
+
30
+ def _plan():
31
+ return plan_retention(MINIMAL_SIGNALS, now=NOW)
32
+
33
+
34
+ def test_details_and_aggregates_get_their_own_cutoffs():
35
+ """两个保留期是两条截止线,不是一条。
36
+
37
+ 典型形态是「明细短、聚合永久」——合成一条的话,要么把该留的永久聚合
38
+ 删了,要么把该清的明细一直留着。
39
+ """
40
+ plan = _plan()
41
+ photo = [a for a in plan.actions if a.signal == "photo_library_added"]
42
+ # 明细 7 天要清,聚合永久不许出现在动作里。
43
+ assert [a.kind for a in photo] == ["observations"]
44
+ assert photo[0].before == (NOW - timedelta(days=7)).date()
45
+ assert any(sk.signal == "photo_library_added"
46
+ and sk.code == "aggregates_permanent" for sk in plan.skipped)
47
+
48
+
49
+ def test_permanent_never_appears_as_something_to_delete():
50
+ plan = _plan()
51
+ doomed = {(a.signal, a.kind) for a in plan.actions}
52
+ for key, sig in MINIMAL_SIGNALS.items():
53
+ if sig.history_retention_days == PERMANENT:
54
+ assert (key, "observations") not in doomed, f"{key} 的明细是永久的"
55
+ if sig.effective_aggregate_retention_days == PERMANENT:
56
+ assert (key, "aggregates") not in doomed, f"{key} 的聚合是永久的"
57
+
58
+
59
+ def test_a_signal_with_no_declared_retention_is_skipped_not_defaulted():
60
+ """没声明就跳过。默认一个天数 = 拿真实数据赌一个猜出来的数字。"""
61
+ from dataclasses import replace
62
+ sig = replace(MINIMAL_SIGNALS["audio_route"],
63
+ history_retention_days=None, aggregate_retention_days=None)
64
+ plan = plan_retention({"audio_route": sig}, now=NOW)
65
+ assert plan.actions == []
66
+ assert all(sk.code.endswith("undeclared") for sk in plan.skipped)
67
+
68
+
69
+ def test_the_plan_says_why_it_skipped_things():
70
+ """只说"删了 0 条"的报告,读不出「是没到期,还是规则写错了」。"""
71
+ plan = _plan()
72
+ assert plan.skipped
73
+ assert all(sk.detail.strip() for sk in plan.skipped)
74
+
75
+
76
+ def test_skip_reasons_carry_a_stable_code_for_the_host_to_render():
77
+ """理由的**文字**是中文(这个包的注释都是中文)。宿主的运维界面不一定是。
78
+
79
+ 直接把 detail 印进一个英文报告里就成了半中半英 —— 一个库不该替宿主
80
+ 决定报告用什么语言。code 是稳定的,文案归宿主。
81
+ """
82
+ from perceptkit.retention import (
83
+ SKIP_AGGREGATES_PERMANENT, SKIP_DETAILS_PERMANENT, SKIP_NO_HISTORY,
84
+ SKIP_DETAILS_UNDECLARED, SKIP_AGGREGATES_UNDECLARED,
85
+ )
86
+ known = {SKIP_NO_HISTORY, SKIP_DETAILS_PERMANENT, SKIP_DETAILS_UNDECLARED,
87
+ SKIP_AGGREGATES_PERMANENT, SKIP_AGGREGATES_UNDECLARED}
88
+ plan = _plan()
89
+ assert {sk.code for sk in plan.skipped} <= known
90
+ assert all(sk.code for sk in plan.skipped)
91
+
92
+
93
+ def test_a_dry_run_removes_nothing():
94
+ """这是包里唯一会永久删用户数据的动作。默认不删。"""
95
+ storage = InMemoryStorage()
96
+ kit = PerceptionKit(storage=storage, signals=MINIMAL_SIGNALS)
97
+ at = NOW - timedelta(days=400)
98
+ kit.ingest({
99
+ "schema_version": 1, "report_id": "r1", "producer": "ios",
100
+ "observations": [{
101
+ "signal": "audio_route", "signal_schema_version": 1,
102
+ "occurred_at": at.isoformat(), "availability": "observed",
103
+ "timezone": "Asia/Shanghai",
104
+ "value": {"output_type": "bluetooth_a2dp", "is_bluetooth": True},
105
+ }],
106
+ }, context=IngestContext("u", at))
107
+ before = len(storage.observations)
108
+ assert before
109
+
110
+ out = kit.run_retention(subject_id="u", now=NOW) # 默认 dry_run
111
+ assert out["applied"] is False and out["removed"] == {}
112
+ assert len(storage.observations) == before, "试跑不该删任何东西"
113
+
114
+ out = kit.run_retention(subject_id="u", now=NOW, dry_run=False)
115
+ assert out["applied"] is True and out["removed"]
116
+ assert len(storage.observations) < before
117
+
118
+
119
+ def test_the_sweep_does_not_touch_dedupe_identities():
120
+ """明细没了之后,去重身份是「重放的上报会不会把永久聚合数两遍」
121
+ 之间唯一的东西 —— 而那个错不可逆。"""
122
+ storage = InMemoryStorage()
123
+ kit = PerceptionKit(storage=storage, signals=MINIMAL_SIGNALS)
124
+ at = NOW - timedelta(days=400)
125
+ kit.ingest({
126
+ "schema_version": 1, "report_id": "p1", "producer": "ios",
127
+ "observations": [{
128
+ "signal": "photo_library_added", "signal_schema_version": 1,
129
+ "occurred_at": at.isoformat(), "availability": "observed",
130
+ "timezone": "Asia/Shanghai", "source_event_id": "asset-1",
131
+ "value": {"count": 1, "added_at": at.isoformat()},
132
+ }],
133
+ }, context=IngestContext("u", at))
134
+ identities = set(storage.identities)
135
+ assert identities
136
+
137
+ kit.run_retention(subject_id="u", now=NOW, dry_run=False)
138
+ assert set(storage.identities) == identities, "去重身份被清理带走了"
139
+
140
+ # 明细被清掉之后重放同一条上报:永久新增数**不能**变成 2。
141
+ def daily():
142
+ rows = storage.get_aggregate(subject_id="u", signal="photo_library_added",
143
+ start_date=at.date(), end_date=at.date())
144
+ return rows[0].typed_aggregate["count"]["total"] if rows else 0
145
+ was = daily()
146
+ kit.ingest({
147
+ "schema_version": 1, "report_id": "p1-replay", "producer": "ios",
148
+ "observations": [{
149
+ "signal": "photo_library_added", "signal_schema_version": 1,
150
+ "occurred_at": at.isoformat(), "availability": "observed",
151
+ "timezone": "Asia/Shanghai", "source_event_id": "asset-1",
152
+ "value": {"count": 1, "added_at": at.isoformat()},
153
+ }],
154
+ }, context=IngestContext("u", NOW))
155
+ assert daily() == was, "清理之后重放把永久聚合数了两遍"
156
+
157
+
158
+ def test_the_sweep_stays_inside_one_subject():
159
+ """跨用户的一条 DELETE 少写一个 WHERE 就会删掉别人的数据。"""
160
+ storage = InMemoryStorage()
161
+ kit = PerceptionKit(storage=storage, signals=MINIMAL_SIGNALS)
162
+ at = NOW - timedelta(days=400)
163
+ for who in ("u1", "u2"):
164
+ kit.ingest({
165
+ "schema_version": 1, "report_id": f"r-{who}", "producer": "ios",
166
+ "observations": [{
167
+ "signal": "audio_route", "signal_schema_version": 1,
168
+ "occurred_at": at.isoformat(), "availability": "observed",
169
+ "timezone": "Asia/Shanghai",
170
+ "value": {"output_type": "bluetooth_a2dp", "is_bluetooth": True},
171
+ }],
172
+ }, context=IngestContext(who, at))
173
+ others = [o for o in storage.observations.values() if o.subject_id == "u2"]
174
+ assert others
175
+
176
+ kit.run_retention(subject_id="u1", now=NOW, dry_run=False)
177
+ still = [o for o in storage.observations.values() if o.subject_id == "u2"]
178
+ assert len(still) == len(others), "清 u1 把 u2 的数据也删了"
@@ -0,0 +1,103 @@
1
+ """修订过的那天,重算不能把错值和改正值一起折进去。
2
+
3
+ 外部审查(2026-09-02,P0-6):"当前 source_revision 可以帮助较高 revision
4
+ 替换 Current,但不足以正确处理历史和聚合"。核下来成立:``source_revision``
5
+ 原来**只**用在当前值上,历史和聚合完全没读它。于是
6
+
7
+ 体重 70.5kg(revision 1)→ 用户在健康 app 里改成 68.5(revision 2)
8
+ 两条都在明细里 → 重算那天 → 69.5,一个从来没发生过的数字
9
+
10
+ 而且这个错**只有重算时才现形**:当前值那条路是对的,所以"改完之后当前显示
11
+ 对了"会让人以为整条链路都对了。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from datetime import date, datetime, timedelta, timezone
16
+
17
+ import pytest
18
+
19
+ from perceptkit.contracts.records import StoredObservation
20
+ from perceptkit.conformance import InMemoryStorage
21
+ from perceptkit.manifest import MINIMAL_SIGNALS
22
+ from perceptkit.processing.recompute import recompute_day
23
+
24
+ DAY = date(2026, 9, 2)
25
+ T = datetime(2026, 9, 2, 9, 0, tzinfo=timezone.utc)
26
+
27
+
28
+ def _obs(storage, oid, *, value, event_id, revision=None, minutes=0,
29
+ availability="observed"):
30
+ storage.append_observation(StoredObservation(
31
+ subject_id="u", observation_id=oid, signal="health_vitals",
32
+ signal_schema_version=1, source="ios",
33
+ occurred_at=T + timedelta(minutes=minutes), received_at=T,
34
+ availability=availability, effective_local_date=DAY,
35
+ typed_value=value, timezone="Asia/Shanghai",
36
+ source_event_id=event_id, source_revision=revision, created_at=T,
37
+ ))
38
+
39
+
40
+ def _rhr(storage):
41
+ agg = recompute_day(storage, MINIMAL_SIGNALS["health_vitals"],
42
+ subject_id="u", day=DAY, version=1, updated_at=T)
43
+ return (agg.typed_aggregate.get("resting_heart_rate"),
44
+ agg.source_coverage["observations"])
45
+
46
+
47
+ def test_a_corrected_sample_replaces_the_wrong_one_instead_of_averaging_with_it():
48
+ s = InMemoryStorage()
49
+ _obs(s, "o1", value={"resting_heart_rate": 90}, event_id="hk-sample-A", revision=1)
50
+ _obs(s, "o2", value={"resting_heart_rate": 60}, event_id="hk-sample-A", revision=2,
51
+ minutes=5)
52
+ stats, n = _rhr(s)
53
+ assert n == 1, "错值和改正值被一起折进去了"
54
+ assert stats["min"] == stats["max"] == 60, (
55
+ f"折出来是 {stats} —— 90 和 60 一起折成了平均值 75,一个从来没发生过的心率")
56
+
57
+
58
+ def test_revisions_are_compared_as_numbers_not_as_text():
59
+ """全按字符串比的话 "10" < "9",第 10 版会被第 9 版盖掉。"""
60
+ s = InMemoryStorage()
61
+ _obs(s, "o1", value={"resting_heart_rate": 90}, event_id="hk-A", revision=9)
62
+ _obs(s, "o2", value={"resting_heart_rate": 60}, event_id="hk-A", revision=10,
63
+ minutes=5)
64
+ stats, n = _rhr(s)
65
+ assert n == 1 and stats["max"] == 60
66
+
67
+
68
+ def test_different_source_facts_are_never_merged():
69
+ """两个不同的样本是两件事,不是一件事的两个版本。"""
70
+ s = InMemoryStorage()
71
+ _obs(s, "o1", value={"resting_heart_rate": 70}, event_id="hk-A", revision=1)
72
+ _obs(s, "o2", value={"resting_heart_rate": 80}, event_id="hk-B", revision=1,
73
+ minutes=5)
74
+ _, n = _rhr(s)
75
+ assert n == 2
76
+
77
+
78
+ def test_a_group_with_no_revisions_at_all_is_left_alone():
79
+ """没有修订号 = 没有「哪版更新」的信息。
80
+
81
+ 这时候合并就等于替这些观测编一个"后来的覆盖先来的"顺序 —— 而那恰好
82
+ **不是** cumulative 现在采用的规则(它取 max)。编了的话,同一份数据
83
+ 重算和增量折叠会给出两个不同的数。
84
+ """
85
+ s = InMemoryStorage()
86
+ _obs(s, "o1", value={"resting_heart_rate": 70}, event_id="hk-A")
87
+ _obs(s, "o2", value={"resting_heart_rate": 80}, event_id="hk-A", minutes=5)
88
+ _, n = _rhr(s)
89
+ assert n == 2
90
+
91
+
92
+ def test_a_revision_marked_unavailable_removes_the_fact_from_the_day():
93
+ """来源侧撤回:最高修订说"读不到了",那天就不该再有这条事实。
94
+
95
+ ⚠️ 这条只覆盖「修订带着 availability 一起来」的形态。iOS 真正的删除走的是
96
+ HealthKit 的 deletedObjects,**现在根本没接**,见 P0-6 的交付说明。
97
+ """
98
+ s = InMemoryStorage()
99
+ _obs(s, "o1", value={"resting_heart_rate": 90}, event_id="hk-A", revision=1)
100
+ _obs(s, "o2", value=None, event_id="hk-A", revision=2, minutes=5,
101
+ availability="unavailable")
102
+ _, n = _rhr(s)
103
+ assert n == 0
@@ -16,7 +16,7 @@ name = "exceptiongroup"
16
16
  version = "1.3.1"
17
17
  source = { registry = "https://pypi.org/simple" }
18
18
  dependencies = [
19
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
19
+ { name = "typing-extensions" },
20
20
  ]
21
21
  sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
22
22
  wheels = [
@@ -43,7 +43,7 @@ wheels = [
43
43
 
44
44
  [[package]]
45
45
  name = "perceptkit"
46
- version = "0.2.5"
46
+ version = "0.2.7"
47
47
  source = { editable = "." }
48
48
 
49
49
  [package.optional-dependencies]
@@ -1,84 +0,0 @@
1
- """保留期(存多久才删)与保质期(多久之后不再采信)的声明表。
2
-
3
- ★ 本模块只声明,不含任何删除逻辑 —— 真正的清理任务要接定时器,属于消费方。
4
-
5
- ★ 保留期的判据:这条数据在 N 个月后,还会改变 agent 对这个人的理解吗。
6
-
7
- ★ 保质期这一块只列「改判测量时间之后需要改的」。改判之前它判的是
8
- 「距这次上报多久」,改判之后判「这条数据多久前测的」—— 沿用旧值会把功能
9
- 杀死:体重 24 小时 = 除非今天刚称过否则永远 null。
10
- """
11
- from __future__ import annotations
12
-
13
- KEEP_FOREVER = None
14
-
15
- _DAY = 86400.0
16
-
17
- # signal -> 保留天数;None = 永久;不在表里 = 不进历史表
18
- RETENTION_DAYS: dict[str, int | None] = {
19
- # 永久:趋势本身就是价值
20
- "health_body": KEEP_FOREVER,
21
- "health_sleep": KEEP_FOREVER,
22
- "health_vitals": KEEP_FOREVER,
23
- "health_activity": KEEP_FOREVER,
24
- "health_workout": KEEP_FOREVER,
25
- "health_metabolic": KEEP_FOREVER,
26
- "health_mood": KEEP_FOREVER,
27
- "health_cycle": KEEP_FOREVER,
28
- "location_signal": KEEP_FOREVER,
29
- # 一年:年度口味有价值,再久没人问
30
- "playback": 365,
31
- # 90 天:瞬时状态,回看价值掉得快
32
- "motion_state": 90,
33
- "focus": 90,
34
- "audio_route": 90,
35
- # weather 现在的 SHAPE 是 NUMERIC_DIST(history.py),仍在产生 rollup,
36
- # 所以现在必须有真实保留期 —— 跟瞬时状态同档。
37
- # ⚠️ Codex code_review 2026-08-23 抓到:早先按"weather 即将改成仅当前+预报、
38
- # 不再存历史"的未来态把这条声明成了 KEEP_FOREVER 之外/None,
39
- # 但 SHAPE/history.record_daily 从未真的改过去,导致四张声明表互相矛盾。
40
- # 真要把 weather 改成不存历史时,这一行、attribution.ATTRIBUTION 里的
41
- # weather 条目、history.SHAPE 里的 weather 条目,三处必须在同一批一起删,
42
- # 不许只删一处。
43
- "weather": 90,
44
- # 60 天:采集窗口是前后 14 天,够覆盖「未来的会 → 过去的会 → 再留一个月回看」
45
- "calendar_next_event": 60,
46
- "reminders": 60,
47
- }
48
-
49
- # 改判「测量时间」之后的保质期。只列与 catalog 现值不同的;
50
- # 「现在测现在传」的信号(位置/运动/专注/音频/播放)不需要改。
51
- MEASURED_AT_TTL_SEC: dict[str, float] = {
52
- "health_body": 90 * _DAY, # 三个月内称过就还算数
53
- "health_metabolic": 30 * _DAY, # 一个月内测过就还算数
54
- "health_cycle": 60 * _DAY, # 两个月内有记录就还算数
55
- "health_vitals": 7 * _DAY, # 一周内测过就还算数
56
- }
57
-
58
-
59
- # ⚠️ 「永久保存」不等于「不可删除」(Codex 评审修订 K)。
60
- # 以下生命周期动作必须由消费方实现,本表只是保留期,不是删除策略的全部:
61
- # · 账号删除时清空
62
- # · 用户主动清空
63
- # · 健康权限关闭后:禁用读取与 wake(不是继续用存量)
64
- # · 第三方撤权
65
- # · 来源侧删除 / 纠正如何传播到我们的汇总
66
- # 本模块不实现这些 —— 它零 I/O。放在这里是为了让读到保留期的人
67
- # 不会把「永久」误解成「不可删」。
68
- LIFECYCLE_NOTE = "retention != undeletable; see design doc 修订 K"
69
-
70
-
71
- def stores_history(signal: str) -> bool:
72
- """这个信号进不进历史表。"""
73
- return signal in RETENTION_DAYS
74
-
75
-
76
- def retention_days(signal: str) -> int | None:
77
- """保留天数;``KEEP_FOREVER``(None)表示永久。
78
-
79
- 不进历史表的信号调用这个是调用方的错,直接抛 —— 静默返回 None 会被
80
- 误当成「永久」,那是最坏的一种默认值。
81
- """
82
- if signal not in RETENTION_DAYS:
83
- raise KeyError(f"signal {signal!r} 不进历史表,先用 stores_history() 判断")
84
- return RETENTION_DAYS[signal]
File without changes
File without changes
File without changes