perceptkit 0.2.2__tar.gz → 0.2.4__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 (106) hide show
  1. {perceptkit-0.2.2 → perceptkit-0.2.4}/CHANGELOG.md +53 -1
  2. {perceptkit-0.2.2 → perceptkit-0.2.4}/PKG-INFO +1 -1
  3. {perceptkit-0.2.2 → perceptkit-0.2.4}/docs/reference-storage-mapping.md +1 -1
  4. {perceptkit-0.2.2 → perceptkit-0.2.4}/pyproject.toml +1 -1
  5. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/algorithms/attribution.py +34 -6
  6. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/algorithms/history.py +28 -0
  7. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/manifest/minimal.py +20 -3
  8. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/manifest/types.py +24 -2
  9. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/processing/aggregate.py +3 -0
  10. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/processing/normalize.py +5 -5
  11. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/processing/pipeline.py +56 -3
  12. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_manifest.py +4 -1
  13. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_regressions.py +298 -0
  14. {perceptkit-0.2.2 → perceptkit-0.2.4}/uv.lock +1 -1
  15. {perceptkit-0.2.2 → perceptkit-0.2.4}/.github/workflows/release.yml +0 -0
  16. {perceptkit-0.2.2 → perceptkit-0.2.4}/.gitignore +0 -0
  17. {perceptkit-0.2.2 → perceptkit-0.2.4}/LICENSE +0 -0
  18. {perceptkit-0.2.2 → perceptkit-0.2.4}/NOTES-packaging.md +0 -0
  19. {perceptkit-0.2.2 → perceptkit-0.2.4}/NOTES-quickstart.md +0 -0
  20. {perceptkit-0.2.2 → perceptkit-0.2.4}/README.md +0 -0
  21. {perceptkit-0.2.2 → perceptkit-0.2.4}/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
  22. {perceptkit-0.2.2 → perceptkit-0.2.4}/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
  23. {perceptkit-0.2.2 → perceptkit-0.2.4}/examples/end_to_end.py +0 -0
  24. {perceptkit-0.2.2 → perceptkit-0.2.4}/examples/ios_adapter.py +0 -0
  25. {perceptkit-0.2.2 → perceptkit-0.2.4}/examples/quickstart.py +0 -0
  26. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/__init__.py +0 -0
  27. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/algorithms/__init__.py +0 -0
  28. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/algorithms/glance.py +0 -0
  29. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/algorithms/identity.py +0 -0
  30. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/algorithms/observation.py +0 -0
  31. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/algorithms/streaks.py +0 -0
  32. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/algorithms/trend_models.py +0 -0
  33. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/algorithms/wake.py +0 -0
  34. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/catalog.py +0 -0
  35. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/conformance/__init__.py +0 -0
  36. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/conformance/memory.py +0 -0
  37. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/conformance/report.py +0 -0
  38. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/conformance/suite.py +0 -0
  39. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/conformance/wake.py +0 -0
  40. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/contracts/__init__.py +0 -0
  41. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/contracts/_time.py +0 -0
  42. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/contracts/availability.py +0 -0
  43. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/contracts/context.py +0 -0
  44. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/contracts/delivery.py +0 -0
  45. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/contracts/errors.py +0 -0
  46. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/contracts/event.py +0 -0
  47. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/contracts/observation.py +0 -0
  48. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/contracts/receipt.py +0 -0
  49. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/contracts/records.py +0 -0
  50. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/contracts/report.py +0 -0
  51. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/contracts/versioning.py +0 -0
  52. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/fields.py +0 -0
  53. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/kit.py +0 -0
  54. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/manifest/__init__.py +0 -0
  55. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/manifest/checks.py +0 -0
  56. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/manifest/mapping.py +0 -0
  57. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/manifest/units.py +0 -0
  58. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/ports/__init__.py +0 -0
  59. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/ports/storage.py +0 -0
  60. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/ports/wake.py +0 -0
  61. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/processing/__init__.py +0 -0
  62. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/processing/dispatch.py +0 -0
  63. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/processing/recompute.py +0 -0
  64. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/processing/recurrence.py +0 -0
  65. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/processing/scheduled.py +0 -0
  66. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/prompts.py +0 -0
  67. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/queries/__init__.py +0 -0
  68. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/queries/api.py +0 -0
  69. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/retention.py +0 -0
  70. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/rules/__init__.py +0 -0
  71. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/rules/engine.py +0 -0
  72. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/rules/evaluators.py +0 -0
  73. {perceptkit-0.2.2 → perceptkit-0.2.4}/src/perceptkit/rules/types.py +0 -0
  74. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/fixtures/README.md +0 -0
  75. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/fixtures/ios_snapshot_no_data.json +0 -0
  76. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/fixtures/ios_snapshot_normal.json +0 -0
  77. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/fixtures/ios_snapshot_unauthorized.json +0 -0
  78. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_attribution.py +0 -0
  79. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_catalog.py +0 -0
  80. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_conformance.py +0 -0
  81. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_conformance_wake_report.py +0 -0
  82. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_contracts.py +0 -0
  83. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_delivery_and_records.py +0 -0
  84. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_edge_cases.py +0 -0
  85. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_end_to_end.py +0 -0
  86. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_event_envelope.py +0 -0
  87. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_examples.py +0 -0
  88. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_export.py +0 -0
  89. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_identity.py +0 -0
  90. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_ios_fixture.py +0 -0
  91. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_isolation.py +0 -0
  92. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_no_host_leakage.py +0 -0
  93. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_observation.py +0 -0
  94. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_pipeline.py +0 -0
  95. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_projection.py +0 -0
  96. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_purity.py +0 -0
  97. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_queries.py +0 -0
  98. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_recompute.py +0 -0
  99. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_recurrence.py +0 -0
  100. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_retention.py +0 -0
  101. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_rules.py +0 -0
  102. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_scheduled.py +0 -0
  103. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_source_mirror.py +0 -0
  104. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_streaks.py +0 -0
  105. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_trend_models.py +0 -0
  106. {perceptkit-0.2.2 → perceptkit-0.2.4}/tests/test_wake.py +0 -0
@@ -1,6 +1,58 @@
1
1
  # 变更记录
2
2
 
3
- ## 0.2.2 — 未发布
3
+ ## 0.2.4 — 未发布
4
+
5
+ **第一批真机数据跑出来的。** 4052 次比对里三处不一致,这是其中属于内核的那一处。
6
+
7
+ ### 修复
8
+
9
+ - **「音乐停了」这个状态进不了系统。** `track_key` 声明成必填,而播放器停着的
10
+ 时候没有曲目 —— 于是那条观测因「缺必填字段」被**整条拒掉**:在放什么记得住,
11
+ 停了永远记不住。而「他刚把音乐关了」恰恰是这个信号最有用的那一半。
12
+ 真机上 83 次上报,83 次都是这个。
13
+
14
+ 规范 §5.3 把 `track_id` 列为字段,没说它任何时候都必须有值。
15
+
16
+
17
+ ## 0.2.3 — 2026-09-01
18
+
19
+ **七个「声明了、但没有代码做」的修复。** 都是拿规范逐条跑代码跑出来的,
20
+ 不是照着清单打勾 —— 清单把这七条全判成了成立,因为它核的是 manifest 怎么写的。
21
+
22
+ ### 修复
23
+
24
+ - 🔴 **归属日期用的是时间戳自带的 offset,不是观测声明的 IANA 时区。**
25
+ producer 完全可以用 UTC 发 `occurred_at`、把时区另放在观测里(参考的 iOS
26
+ 适配层就是这么发的)。上海用户本地 00:00–08:00 的数据**每天全部落到前一天**,
27
+ "昨天走了多少步""昨晚睡了几小时"跟着一起偏。
28
+
29
+ - 🔴 **去重身份问错了保留期。** `DurableDedupeIdentity` 单独成表的全部理由是
30
+ 「明细会过期、聚合可能永久」,而标记它的条件问的是**明细**永不永久 ——
31
+ 于是恰好在它唯一有用的四个信号上(照片、focus、motion、music)判成不用守。
32
+ 身份先于聚合被清掉之后,一次重传把永久聚合多加一遍,**明细已经不在,
33
+ 算不回去**。
34
+
35
+ - 🔴 **`comparison_strategy="state_change"` 声明了,没有任何代码读它。**
36
+ 保活上报每 5 分钟一次,「还在专注」一天写出几百条一模一样的记录。
37
+ 只跳过明细,**当前值和聚合照常走** —— `duration_by_state` 靠相邻观测的
38
+ 时间差累计,跳过聚合是拿一个 bug 换另一个。
39
+
40
+ - 🔴 **一个信号只能有一条当前值。** `dimension_key` 在主键里、却永远等于信号名。
41
+ 同时连着两个 Wi-Fi 锚点只剩一个;用户搬家、新旧网络都叫 "home" 会被合并,
42
+ 之后历史再也分不开哪段是哪个家。信号现在可以声明 `dimension_fields`,
43
+ 不声明的行为逐字节不变。
44
+
45
+ - **「今天打开了几次」永远答 1。** `open_count` 声明的是 `daily_total`(取 max),
46
+ 而每次打开各贡献 1 —— 一列 1 取最大值还是 1。新增 `occurrence_count`(求和),
47
+ 和 `cumulative` 并排放、把差别写在定义旁边。
48
+
49
+ - **每日照片数量按明细的保留期过期。** 明细和聚合是两个数,只写了一个,
50
+ 于是聚合继承了 7 天 ——「8月1日新增 5 张」是发生过的事实,不是"现在还剩几张"。
51
+
52
+ - conformance 补第 ⑪ 条已在 0.2.1;本版把上面这些各配一条会红的回归测试。
53
+
54
+
55
+ ## 0.2.2 — 2026-09-01
4
56
 
5
57
  **代码没有变化。** 这一版只换了「怎么发出去」。
6
58
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: perceptkit
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: 从设备信号判断:有没有发生值得留意的事、值不值得叫醒一次 agent、以及该怎么把此刻的状况讲给它听。不采集数据、不碰存储、不调模型。
5
5
  License:
6
6
  Apache License
@@ -24,7 +24,7 @@
24
24
  | `location_city` | CurrentProjection + StoredObservation + DailyAggregate | 900s | 永久 | 同明细 | deterministic_digest | instant |
25
25
  | `motion_state` | CurrentProjection + StoredObservation + DailyAggregate | 900s | 365 天 | 永久 | deterministic_digest | instant |
26
26
  | `music_playback` | CurrentProjection + StoredObservation + DailyAggregate | 600s | 365 天 | 永久 | deterministic_digest | instant |
27
- | `photo_library_added` | CurrentProjection + StoredObservation + DailyAggregate | — | 7 天 | 同明细 | source_event_id | instant |
27
+ | `photo_library_added` | CurrentProjection + StoredObservation + DailyAggregate | — | 7 天 | 永久 | source_event_id | instant |
28
28
  | `presence_recovery` | CurrentProjection | — | 不存 | 不适用 | source_event_id | instant |
29
29
  | `proximity_anchor` | CurrentProjection + StoredObservation | 900s | 7 天 | 同明细 | deterministic_digest | instant |
30
30
  | `screen_change` | CurrentProjection | 60s | 不存 | 不适用 | singleton | instant |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "perceptkit"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "从设备信号判断:有没有发生值得留意的事、值不值得叫醒一次 agent、以及该怎么把此刻的状况讲给它听。不采集数据、不碰存储、不调模型。"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -73,17 +73,45 @@ def _utc_naive(dtobj: _dt.datetime) -> _dt.datetime:
73
73
  return dtobj.replace(tzinfo=None) - dtobj.utcoffset()
74
74
 
75
75
 
76
- def attribute_instant(when: str) -> str:
77
- """单点测量:按它自己那个 offset 下的本地日期。"""
78
- return _aware(when).date().isoformat()
76
+ def _local(dtobj: _dt.datetime, tz: str | None) -> _dt.datetime:
77
+ """换算到观测声明的那个时区。没声明就用时间戳自带的 offset
79
78
 
79
+ ★ **这两件事不是一回事,混用会把数据归错天。**
80
80
 
81
- def attribute_episode(start: str, end: str) -> str:
82
- """区间事件(睡眠、一次运动):整体归结束那天。"""
81
+ 时间戳自带的是一个**固定 offset**;``tz`` 是一整套带换日规则的时区。
82
+ producer 完全可以(而且常常)用 UTC 发 ``occurred_at``、把 IANA 时区另放
83
+ 在观测的 ``timezone`` 字段里 —— 那时按 offset 算出来的是 UTC 日期,不是
84
+ 用户的本地日期。
85
+
86
+ 实例(这就是它被写出来的原因):上海用户早上 7 点,``occurred_at`` 是
87
+ ``T-1 23:00+00:00``。按 offset 算 = 前一天。**每天本地 00:00–08:00 的数据
88
+ 全部落到前一天**,"昨天走了多少步""昨晚睡了几小时"跟着一起偏。
89
+ """
90
+ if not tz:
91
+ return dtobj
92
+ try:
93
+ return dtobj.astimezone(_zoneinfo.ZoneInfo(tz))
94
+ except Exception: # noqa: BLE001
95
+ # 时区名不认识时按 offset 算 —— 这是降级,不是等价物,
96
+ # 但比整条观测拒收好:调用方已经在别处对时区名做了校验。
97
+ return dtobj
98
+
99
+
100
+ def attribute_instant(when: str, *, tz: str | None = None) -> str:
101
+ """单点测量:归到它发生时、**当地**的那一天。
102
+
103
+ ``tz`` 是观测声明的 IANA 时区。不传时退回时间戳自带的 offset —— 见
104
+ ``_local`` 里说明这两者为什么不等价。
105
+ """
106
+ return _local(_aware(when), tz).date().isoformat()
107
+
108
+
109
+ def attribute_episode(start: str, end: str, *, tz: str | None = None) -> str:
110
+ """区间事件(睡眠、一次运动):整体归结束那天的**当地**日期。"""
83
111
  s, e = _aware(start), _aware(end)
84
112
  if e < s:
85
113
  raise ValueError(f"区间结束早于开始:{start!r} -> {end!r}")
86
- return e.date().isoformat()
114
+ return _local(e, tz).date().isoformat()
87
115
 
88
116
 
89
117
  def split_across_midnight(start: str, end: str, *, tz: str | None = None) -> list[tuple[str, float]]:
@@ -32,6 +32,16 @@ EVENT_LIST = "event_list" # discrete items, deduped by id/key
32
32
  SUBJECTIVE = "subjective" # append each self-report entry
33
33
  PLACE_DWELL = "place_dwell" # minutes spent at each place label
34
34
  TALLY = "tally" # daily digest: total minutes + top artists/tracks
35
+ OCCURRENCE_COUNT = "occurrence_count" # per numeric field: SUM of per-event contributions
36
+
37
+ # ★ `occurrence_count` 和 `cumulative` 差一个字,语义正好相反,混用不会报错、
38
+ # 只会给出一个安静的错数:
39
+ #
40
+ # cumulative 来源自己在数(今日步数 8000 → 8300),当天代表值取 **max**
41
+ # occurrence_count 每次事件各贡献一份(每次打开 app 记 1),当天要 **sum**
42
+ #
43
+ # 拿 cumulative 去数「今天打开了几次」,每条都是 1,max 永远是 1 —— 用户开了
44
+ # 二十次,答案还是「1 次」。
35
45
 
36
46
  _TALLY_CAP = 30 # keep only the top-N artists/tracks per day
37
47
 
@@ -265,8 +275,26 @@ def _merge_subjective(doc: dict, values: Mapping, *, ts: float | None = None, **
265
275
  return out
266
276
 
267
277
 
278
+ def _merge_occurrence_count(doc: dict, values: Mapping, **_) -> dict:
279
+ """每次事件各贡献一份,当天累加。
280
+
281
+ 和 ``_merge_cumulative`` 的区别见 ``OCCURRENCE_COUNT`` 上面那段:那个取
282
+ max(来源自己在数),这个求和(我们在数)。
283
+ """
284
+ out = dict(doc)
285
+ for field, raw in values.items():
286
+ n = _numeric(raw)
287
+ if n is None:
288
+ continue
289
+ prev = out.get(field)
290
+ base = prev.get("total", 0.0) if isinstance(prev, Mapping) else 0.0
291
+ out[field] = {"total": base + n}
292
+ return out
293
+
294
+
268
295
  _MERGERS = {
269
296
  NUMERIC_DIST: _merge_numeric_dist,
297
+ OCCURRENCE_COUNT: _merge_occurrence_count,
270
298
  CUMULATIVE: _merge_cumulative,
271
299
  MAIN_OF_DAY: _merge_main_of_day,
272
300
  DURATION_BY_STATE: _merge_duration_by_state,
@@ -600,6 +600,10 @@ PHOTO_LIBRARY_ADDED = SignalDefinition(
600
600
  identity_strategy="source_event_id",
601
601
  attribution_strategy="instant",
602
602
  history_retention_days=7,
603
+ # 单条明细 7 天,**每日新增数量永久**。两个数必须分开写:不写聚合那个
604
+ # 就会继承明细的 7 天,于是「8月1日新增了 5 张」一周后被扫掉 ——
605
+ # 而那是一件发生过的事实,不是「现在还剩几张」。
606
+ aggregate_retention_days=PERMANENT,
603
607
  source_profile="device_occurrence",
604
608
  note=(
605
609
  "一张照片一条 count=1,不是「今天 5 张」报一次 —— 拆成一条条才能让照片"
@@ -973,6 +977,9 @@ PROXIMITY_ANCHOR = SignalDefinition(
973
977
  # 同上:时间点快照,时长由聚合层从相邻观测算。
974
978
  attribution_strategy="instant",
975
979
  # 产品规范 §1-15:Wi-Fi / 蓝牙连接历史保留 7 天。
980
+ # 每个 anchor 各一条当前值。同时连着家里和公司是两个答案;
981
+ # 用户搬家后新旧网络都叫 "home",按名字看是一个、按 anchor_id 看是两个。
982
+ dimension_fields=("anchor_id",),
976
983
  history_retention_days=7,
977
984
  source_profile="location",
978
985
  note=(
@@ -1086,7 +1093,15 @@ MUSIC_PLAYBACK = SignalDefinition(
1086
1093
  key="track_key",
1087
1094
  value_type="string",
1088
1095
  privacy_class="sensitive",
1089
- nullable=False,
1096
+ nullable=True,
1097
+ # ★ 「什么都没在放」是一个**正常状态**,而它没有曲目。
1098
+ #
1099
+ # 非空的话,播放器停着的那条观测因为缺必填字段被整条拒掉 ——
1100
+ # 于是 kit 永远不知道「音乐停了」,只知道「在放什么」。
1101
+ # 真机上这条占了 83 次差异里的 83 次:老路记着 stopped,
1102
+ # kit 那边一片空白。
1103
+ #
1104
+ # 规范 §5.3 把 track_id 列为字段,没说它任何时候都必须有值。,
1090
1105
  comparison_strategy="exact",
1091
1106
  wake_eligible=True,
1092
1107
  query_visibility="on_demand",
@@ -1233,9 +1248,11 @@ APP_USAGE = SignalDefinition(
1233
1248
  unit="count",
1234
1249
  privacy_class="sensitive",
1235
1250
  valid_range=(0, None),
1236
- # 每条 open 贡献 1,当天累加。**只靠 open,所以可信** ——
1251
+ # 每条 open 贡献 1,当天**求和**。**只靠 open,所以可信** ——
1237
1252
  # 这正是砍掉时长统计之后仍然答得准的那部分。
1238
- aggregation_strategy="daily_total",
1253
+ # ⚠️ 曾经写的是 daily_total(取 max),于是「今天打开了几次」
1254
+ # 永远等于 1 —— 这个信号唯一保证答得准的问题,答错了。
1255
+ aggregation_strategy="occurrence_count",
1239
1256
  comparison_strategy="none",
1240
1257
  query_visibility="on_demand",
1241
1258
  trend_model="fluctuating",
@@ -16,7 +16,7 @@ manifest 只声明**属性**,不实现算法。``normalizer`` / ``aggregation_st
16
16
  from __future__ import annotations
17
17
 
18
18
  from dataclasses import dataclass, field
19
- from typing import Any
19
+ from typing import Any, Mapping
20
20
 
21
21
  # ---------------------------------------------------------------------------
22
22
  # 值域
@@ -46,7 +46,7 @@ STORAGE_MODES: frozenset[str] = frozenset({
46
46
 
47
47
  #: 日聚合的算法。名字要能解析到实现。
48
48
  AGGREGATION_STRATEGIES: frozenset[str] = frozenset({
49
- "none", "daily_total", "numeric_dist",
49
+ "none", "daily_total", "occurrence_count", "numeric_dist",
50
50
  "duration_by_state", "event_list", "tally", "main_of_day", "cumulative",
51
51
  })
52
52
 
@@ -159,6 +159,16 @@ class SignalDefinition:
159
159
  identity_strategy: str
160
160
  attribution_strategy: str
161
161
  fields: tuple[FieldDefinition, ...]
162
+ #: 哪几个字段把这个信号的当前值**分成并列的多条**。
163
+ #:
164
+ #: 空(默认)= 一个信号一条当前值,新的覆盖旧的。绝大多数信号就该这样:
165
+ #: 电量、天气、运动状态,同一时刻只有一个答案。
166
+ #:
167
+ #: 非空 = 每个取值组合各留一条。锚点是这么来的:同时连着家里和公司两个
168
+ #: Wi-Fi,「当前连着哪些锚点」有两个答案,覆盖式写入只会剩最后一个。
169
+ #: 更糟的是用户搬家、新旧网络都叫 "home" —— 按名字看是同一个,按
170
+ #: ``anchor_id`` 看是两个,合并之后历史再也分不开哪段是哪个家。
171
+ dimension_fields: tuple[str, ...] = ()
162
172
  #: **明细**(逐条观测)保留多少天。``PERMANENT`` = 永久;``0`` = 不存历史。
163
173
  history_retention_days: int = 0
164
174
  #: **聚合**(日统计)保留多少天。默认跟着明细走 —— 但两者常常不该一样。
@@ -191,6 +201,18 @@ class SignalDefinition:
191
201
  def keeps_history_forever(self) -> bool:
192
202
  return self.history_retention_days == PERMANENT
193
203
 
204
+ def dimension_key_for(self, value: Mapping[str, Any] | None) -> str:
205
+ """这条观测落在哪一条当前值上。
206
+
207
+ 没声明 ``dimension_fields`` 就用信号名 —— 一个信号一条,和以前一样。
208
+ 声明了就按那几个字段的取值拼出来;取不到值的按空串参与,**不能退回
209
+ 信号名**:那会让「拿不到 anchor_id 的那条」去覆盖掉一条真的锚点。
210
+ """
211
+ if not self.dimension_fields:
212
+ return self.key
213
+ parts = [str((value or {}).get(f) or "") for f in self.dimension_fields]
214
+ return self.key + "\x1f" + "\x1f".join(parts)
215
+
194
216
  @property
195
217
  def effective_aggregate_retention_days(self) -> int:
196
218
  """聚合实际留多久。没单独声明就跟明细一样。"""
@@ -17,6 +17,9 @@ from ..manifest.types import SignalDefinition
17
17
  _STRATEGY_TO_SHAPE: dict[str, str] = {
18
18
  "daily_total": history.CUMULATIVE,
19
19
  "cumulative": history.CUMULATIVE,
20
+ # 每次事件贡献一份、当天求和。**不是** daily_total:那个取 max,
21
+ # 用来数「打开了几次」会永远得到 1。
22
+ "occurrence_count": history.OCCURRENCE_COUNT,
20
23
  "numeric_dist": history.NUMERIC_DIST,
21
24
  "main_of_day": history.MAIN_OF_DAY,
22
25
  "duration_by_state": history.DURATION_BY_STATE,
@@ -217,7 +217,7 @@ def effective_date(
217
217
  # 上游说好了给本地日期却没给 —— 退回按发生时刻算,并记一笔,
218
218
  # 不静默换算法。
219
219
  problems.append(f"{sig.key}: 声明了 source_local_date 但 payload 里没有 local_date")
220
- return _parse(attribution.attribute_instant(iso)), (), problems
220
+ return _parse(attribution.attribute_instant(iso, tz=timezone_name)), (), problems
221
221
 
222
222
  if strategy in ("episode_end", "split_at_midnight"):
223
223
  start, end = value.get("start_at"), value.get("end_at")
@@ -226,21 +226,21 @@ def effective_date(
226
226
  f"{sig.key}: {strategy} 需要 start_at / end_at,"
227
227
  f"退回按 occurred_at 归属"
228
228
  )
229
- return _parse(attribution.attribute_instant(iso)), (), problems
229
+ return _parse(attribution.attribute_instant(iso, tz=timezone_name)), (), problems
230
230
  # producer 可能发来不合法的区间(结束早于开始、时间戳格式不对)。
231
231
  # **只拒这一条,不能炸掉整批** —— 一批十条里一条有问题就全丢,
232
232
  # 是最容易让人骂街的设计,管线其他地方都守住了这条,这里以前漏了。
233
233
  try:
234
234
  if strategy == "episode_end":
235
- return _parse(attribution.attribute_episode(start, end)), (), problems
235
+ return _parse(attribution.attribute_episode(start, end, tz=timezone_name)), (), problems
236
236
  slices = tuple(attribution.split_across_midnight(start, end, tz=timezone_name))
237
237
  return _parse(slices[-1][0]) if slices else _parse(
238
- attribution.attribute_episode(start, end)
238
+ attribution.attribute_episode(start, end, tz=timezone_name)
239
239
  ), slices, problems
240
240
  except (ValueError, TypeError) as exc:
241
241
  raise AttributionError(f"{sig.key}: {exc}") from exc
242
242
 
243
- return _parse(attribution.attribute_instant(iso)), (), problems
243
+ return _parse(attribution.attribute_instant(iso, tz=timezone_name)), (), problems
244
244
 
245
245
 
246
246
  # ---------------------------------------------------------------------------
@@ -172,6 +172,41 @@ def ingest_report(
172
172
  return outcome
173
173
 
174
174
 
175
+ def _repeats_declared_state(
176
+ item: NormalizedObservation,
177
+ sig: SignalDefinition,
178
+ *,
179
+ context: IngestContext,
180
+ storage: StoragePort,
181
+ ) -> bool:
182
+ """这条观测是不是「和当前值同一个状态」的重复上报。
183
+
184
+ 只看声明了 ``comparison_strategy="state_change"`` 的字段。**这个声明以前
185
+ 在 manifest 里写着、却没有任何代码读它** —— 于是 focus / motion /
186
+ time_context 上那句「只在变化时追加」在文档里成立、在数据里不成立。
187
+
188
+ 保守判定:只有当前值确实存在、且**每一个**声明了 state_change 的字段都和
189
+ 当前值一样时,才算重复。任何一个字段变了、或者当前值还不存在(第一条)、
190
+ 或者这条不是 observed,都照常写明细 —— 宁可多写一条,不可漏掉一次真正的
191
+ 状态变化。
192
+ """
193
+ if item.stored.availability != "observed":
194
+ return False
195
+ watched = [f.key for f in sig.fields if f.comparison_strategy == "state_change"]
196
+ if not watched:
197
+ return False
198
+ value = item.stored.typed_value or {}
199
+ for projection in storage.get_current(
200
+ subject_id=context.subject_id, signals=[sig.key],
201
+ ).get(sig.key, ()):
202
+ if (projection.dimension_key != sig.dimension_key_for(value)
203
+ or projection.availability != "observed"):
204
+ continue
205
+ current = projection.typed_value or {}
206
+ return all(current.get(k) == value.get(k) for k in watched)
207
+ return False
208
+
209
+
175
210
  def _apply_one(
176
211
  item: NormalizedObservation,
177
212
  sig: SignalDefinition,
@@ -200,7 +235,17 @@ def _apply_one(
200
235
  return
201
236
 
202
237
  # ④ 写观测。只留当前值的信号不写明细 —— 否则 current_only 名不副实。
203
- if sig.stores_history:
238
+ #
239
+ # 声明了 `state_change` 的字段还有一条:状态没变就**不追加明细**,只刷新
240
+ # 当前值。iOS 每 5 分钟保活上报一次,「还在专注」「还在静止」会一天写出
241
+ # 几百条一模一样的记录 —— 时间线本该记的是「什么时候变了」,被同一个
242
+ # 状态刷满之后,「每日切换次数」「最长一段」这类聚合直接失去意义。
243
+ #
244
+ # ⚠️ 只跳过明细,**当前值和聚合照常走**:`duration_by_state` 靠相邻两条
245
+ # 观测的时间差累计时长,跳过聚合会把时长永远停在第一次。
246
+ if sig.stores_history and not _repeats_declared_state(
247
+ item, sig, context=context, storage=storage,
248
+ ):
204
249
  if not storage.append_observation(stored):
205
250
  outcome.duplicates.append(item)
206
251
  return
@@ -213,7 +258,15 @@ def _apply_one(
213
258
  source=stored.source,
214
259
  source_event_identity_digest=item.identity_digest,
215
260
  first_applied_at=context.received_at,
216
- aggregate_scope=sig.key if sig.keeps_history_forever else None,
261
+ # 问的是**聚合**永不永久,不是明细。
262
+ #
263
+ # 这条记录存在的全部理由就是「明细会过期、聚合可能永久」——
264
+ # 所以拿明细的保留期来判断,恰好在它唯一有用的那些信号上判成 None:
265
+ # 照片(明细 7 天 / 每日数量永久)、focus / motion / music
266
+ # (明细 1 年 / 聚合永久)。四个信号的去重身份可以先于聚合被清掉,
267
+ # 之后一次重传就把永久聚合多加一遍,**加完没法回滚**。
268
+ # 产品规范 §14-2 点名的正是这个场景。
269
+ aggregate_scope=sig.key if sig.keeps_aggregates_forever else None,
217
270
  # 永久聚合依赖的身份必须永久保留:明细删了之后,
218
271
  # 它是唯一还能挡住重放的东西。
219
272
  retain_until=None,
@@ -289,7 +342,7 @@ def _update_current(
289
342
  from ..contracts.records import decide_current_update
290
343
 
291
344
  stored = item.stored
292
- dimension = sig.key
345
+ dimension = sig.dimension_key_for(stored.typed_value)
293
346
 
294
347
  for attempt in range(MAX_CAS_RETRIES):
295
348
  existing = None
@@ -360,7 +360,10 @@ def test_app_usage_counts_opens_but_never_totals_duration():
360
360
  「今天打开了几次」只靠 open 就能答,所以配置不全也不影响。
361
361
  """
362
362
  fields = MINIMAL_SIGNALS["app_usage"].field_map()
363
- assert fields["open_count"].aggregation_strategy == "daily_total"
363
+ # `occurrence_count`(求和),**不是** `daily_total`(取 max)。
364
+ # 后者是给「来源自己在数」的量用的(今日步数 8000 → 8300);每次打开各贡献
365
+ # 1 的话,max 永远是 1 —— 用户开了二十次,答案还是「1 次」。
366
+ assert fields["open_count"].aggregation_strategy == "occurrence_count"
364
367
  # 没有任何字段在按时长聚合
365
368
  assert all(f.aggregation_strategy != "duration_by_state"
366
369
  for f in MINIMAL_SIGNALS["app_usage"].fields)
@@ -556,3 +556,301 @@ def test_a_field_that_declares_no_aggregation_is_not_aggregated():
556
556
  start_date=at.date(), end_date=at.date()
557
557
  )[0].typed_aggregate
558
558
  assert list(doc) == ["temperature_c"]
559
+
560
+
561
+ # ---------------------------------------------------------------------------
562
+ # I6 —— 归属日期用了时间戳的 offset,而不是观测声明的时区
563
+ #
564
+ # 我们**自己**在给产品方的信里写过这条(§19):「`occurred_at` 的偏移不能
565
+ # 替代时区」。代码里没做到。
566
+ # ---------------------------------------------------------------------------
567
+
568
+ def test_the_declared_timezone_decides_the_day_not_the_timestamp_offset():
569
+ """上海用户早上 7 点,那条数据属于今天,不是昨天。
570
+
571
+ producer 用 UTC 发 `occurred_at`、把 IANA 时区放在观测的 `timezone` 里 ——
572
+ 这是完全正常的发法,io 的适配层就是这么发的。按 offset 算日期的话,
573
+ **本地 00:00–08:00 的数据每天都落到前一天**:一整个凌晨加早晨,
574
+ 「昨天走了多少步」「昨晚睡了几小时」跟着一起偏。
575
+ """
576
+ storage = InMemoryStorage()
577
+ kit = PerceptionKit(storage=storage, signals=MINIMAL_SIGNALS)
578
+ at = datetime(2026, 9, 1, 16, 0, tzinfo=timezone.utc) # 上海 = 9/2 00:00
579
+ kit.ingest({
580
+ "schema_version": 1, "report_id": "r1", "producer": "ios",
581
+ "observations": [{
582
+ "signal": "motion_state", "signal_schema_version": 1,
583
+ "occurred_at": at.isoformat(), "availability": "observed",
584
+ "timezone": "Asia/Shanghai", "value": {"state": "walking"},
585
+ }],
586
+ }, context=IngestContext("u", at))
587
+ rows, _ = storage.list_observations(subject_id="u", signal="motion_state")
588
+ assert str(rows[0].effective_local_date) == "2026-09-02"
589
+
590
+
591
+ # ---------------------------------------------------------------------------
592
+ # I7 —— `comparison_strategy` 声明了,但没有任何代码读它
593
+ # ---------------------------------------------------------------------------
594
+
595
+ def _focus(kit, at, active=True):
596
+ kit.ingest({
597
+ "schema_version": 1, "report_id": f"f{at.isoformat()}", "producer": "ios",
598
+ "observations": [{
599
+ "signal": "focus_state", "signal_schema_version": 1,
600
+ "occurred_at": at.isoformat(), "availability": "observed",
601
+ "timezone": "Asia/Shanghai", "value": {"is_active": active},
602
+ }],
603
+ }, context=IngestContext("u", at))
604
+
605
+
606
+ def test_a_state_that_did_not_change_does_not_add_a_timeline_entry():
607
+ """iOS 每 5 分钟保活上报一次,「还在专注」一天能写出几百条一样的记录。
608
+
609
+ 时间线记的是**什么时候变了**。被同一个状态刷满之后,「每日切换次数」
610
+ 「最长一段」这类聚合就没有意义了。manifest 上一直写着
611
+ `comparison_strategy="state_change"`,只是没有代码读它。
612
+ """
613
+ storage = InMemoryStorage()
614
+ kit = PerceptionKit(storage=storage, signals=MINIMAL_SIGNALS)
615
+ base = datetime(2026, 9, 1, 9, 0, tzinfo=timezone.utc)
616
+ for i in range(4):
617
+ _focus(kit, base + timedelta(minutes=5 * i))
618
+ rows, _ = storage.list_observations(subject_id="u", signal="focus_state")
619
+ assert len(rows) == 1
620
+
621
+ _focus(kit, base + timedelta(minutes=25), active=False) # 真的变了
622
+ rows, _ = storage.list_observations(subject_id="u", signal="focus_state")
623
+ assert len(rows) == 2
624
+
625
+
626
+ def test_suppressing_repeats_still_lets_the_duration_aggregate_advance():
627
+ """只跳过明细,**当前值和聚合照常走**。
628
+
629
+ `duration_by_state` 靠相邻两条观测的时间差累计时长 —— 跳过聚合会让时长
630
+ 永远停在第一次,那就是用一个 bug 换另一个。
631
+ """
632
+ storage = InMemoryStorage()
633
+ kit = PerceptionKit(storage=storage, signals=MINIMAL_SIGNALS)
634
+ base = datetime(2026, 9, 1, 9, 0, tzinfo=timezone.utc)
635
+ for i in range(4):
636
+ _focus(kit, base + timedelta(minutes=5 * i))
637
+ doc = storage.get_aggregate(subject_id="u", signal="focus_state",
638
+ start_date=base.date(), end_date=base.date()
639
+ )[0].typed_aggregate
640
+ assert doc["minutes"]["focused"] == 15.0 # 三段 5 分钟
641
+
642
+
643
+ # ---------------------------------------------------------------------------
644
+ # I8 —— 一个信号只能有一条当前值,锚点因此被合并
645
+ # ---------------------------------------------------------------------------
646
+
647
+ def _anchor(kit, at, anchor_id, label):
648
+ kit.ingest({
649
+ "schema_version": 1, "report_id": f"a{anchor_id}{at.isoformat()}",
650
+ "producer": "ios", "observations": [{
651
+ "signal": "proximity_anchor", "signal_schema_version": 1,
652
+ "occurred_at": at.isoformat(), "availability": "observed",
653
+ "timezone": "Asia/Shanghai",
654
+ "value": {"anchor_id": anchor_id, "anchor_type": "wifi",
655
+ "label": label, "is_connected": True},
656
+ }],
657
+ }, context=IngestContext("u", at))
658
+
659
+
660
+ def test_two_anchors_named_home_stay_two_anchors():
661
+ """用户搬家,新旧网络都叫 "home"。
662
+
663
+ 按名字看是同一个,按 `anchor_id` 看是两个。合并之后历史再也分不开
664
+ 哪一段是哪个家 —— 规范 §12 专门列了这一条。
665
+ """
666
+ storage = InMemoryStorage()
667
+ kit = PerceptionKit(storage=storage, signals=MINIMAL_SIGNALS)
668
+ base = datetime(2026, 9, 1, 9, 0, tzinfo=timezone.utc)
669
+ _anchor(kit, base, "wifi-old-home", "home")
670
+ _anchor(kit, base + timedelta(minutes=1), "wifi-new-home", "home")
671
+ current = storage.get_current(subject_id="u", signals=["proximity_anchor"]
672
+ )["proximity_anchor"]
673
+ assert len(current) == 2
674
+ assert {p.typed_value["anchor_id"] for p in current} == {
675
+ "wifi-old-home", "wifi-new-home"}
676
+
677
+
678
+ def test_renaming_an_anchor_does_not_create_a_second_one():
679
+ """身份是 anchor_id,不是 label。改名只是改名。"""
680
+ storage = InMemoryStorage()
681
+ kit = PerceptionKit(storage=storage, signals=MINIMAL_SIGNALS)
682
+ base = datetime(2026, 9, 1, 9, 0, tzinfo=timezone.utc)
683
+ _anchor(kit, base, "wifi-1", "家")
684
+ _anchor(kit, base + timedelta(minutes=1), "wifi-1", "老家")
685
+ current = storage.get_current(subject_id="u", signals=["proximity_anchor"]
686
+ )["proximity_anchor"]
687
+ assert len(current) == 1
688
+ assert current[0].typed_value["label"] == "老家"
689
+
690
+
691
+ def test_a_signal_without_declared_dimensions_still_keeps_one_current():
692
+ """默认行为不能变:绝大多数信号同一时刻只有一个答案。"""
693
+ storage = InMemoryStorage()
694
+ kit = PerceptionKit(storage=storage, signals=MINIMAL_SIGNALS)
695
+ base = datetime(2026, 9, 1, 9, 0, tzinfo=timezone.utc)
696
+ for i, level in enumerate((0.9, 0.8)):
697
+ kit.ingest({
698
+ "schema_version": 1, "report_id": f"b{i}", "producer": "ios",
699
+ "observations": [{
700
+ "signal": "battery", "signal_schema_version": 1,
701
+ "occurred_at": (base + timedelta(minutes=i)).isoformat(),
702
+ "availability": "observed", "timezone": "Asia/Shanghai",
703
+ "value": {"level_ratio": level, "is_charging": False,
704
+ "is_low_power_mode_enabled": False},
705
+ }],
706
+ }, context=IngestContext("u", base + timedelta(minutes=i)))
707
+ current = storage.get_current(subject_id="u", signals=["battery"])["battery"]
708
+ assert len(current) == 1 and current[0].typed_value["level_ratio"] == 0.8
709
+
710
+
711
+ # ---------------------------------------------------------------------------
712
+ # I9 —— 每日照片数量按明细的保留期被扫掉
713
+ # ---------------------------------------------------------------------------
714
+
715
+ def test_the_daily_photo_count_outlives_the_individual_photos():
716
+ """单条明细 7 天、**每日数量永久**,是两个数。
717
+
718
+ 只写明细那个,聚合会继承它 —— 于是「8月1日新增了 5 张」一周后被清掉。
719
+ 那是一件发生过的事实,不是「现在还剩几张」。
720
+ """
721
+ sig = MINIMAL_SIGNALS["photo_library_added"]
722
+ assert sig.history_retention_days == 7
723
+ assert sig.keeps_aggregates_forever
724
+
725
+
726
+ # ---------------------------------------------------------------------------
727
+ # 交付清单 §15 里唯一没有测试盯着的一项:
728
+ # 「App 缺失 close、Music 采样间断」
729
+ #
730
+ # 两个都是**我们和规范不同的地方**,而且不同得有理由。没有测试的话,
731
+ # 后来的人看到「规范要求算时长,代码没算」,最可能的动作是把它补上 ——
732
+ # 补回来的是一份大概率残缺的数字,比没有更糟。
733
+ # ---------------------------------------------------------------------------
734
+
735
+ def _app(kit, at, app, action):
736
+ kit.ingest({
737
+ "schema_version": 1, "report_id": f"{app}{action}{at.isoformat()}",
738
+ "producer": "ios", "observations": [{
739
+ "signal": "app_usage", "signal_schema_version": 1,
740
+ "occurred_at": at.isoformat(), "availability": "observed",
741
+ "timezone": "Asia/Shanghai",
742
+ "value": {"app_id": app, "app_name": app, "action": action,
743
+ **({"open_count": 1} if action == "open" else {})},
744
+ }],
745
+ }, context=IngestContext("u", at))
746
+
747
+
748
+ def test_a_missing_close_never_becomes_an_invented_duration():
749
+ """只配了 open 自动化的用户,绝大多数 app 根本没有结束事件。
750
+
751
+ 拿有 close 的那部分算平均时长,得到的是一个只反映「谁配得全」的数字。
752
+ 所以这个信号**刻意不建时长统计** —— 聚合里只该有靠 open 就能答准的
753
+ 「今天打开了几次」。
754
+ """
755
+ storage = InMemoryStorage()
756
+ kit = PerceptionKit(storage=storage, signals=MINIMAL_SIGNALS)
757
+ base = datetime(2026, 9, 1, 9, 0, tzinfo=timezone.utc)
758
+ _app(kit, base, "Slack", "open")
759
+ _app(kit, base + timedelta(minutes=30), "Slack", "open") # 没有 close
760
+ doc = storage.get_aggregate(subject_id="u", signal="app_usage",
761
+ start_date=base.date(), end_date=base.date()
762
+ )[0].typed_aggregate
763
+ assert doc["open_count"]["total"] == 2
764
+ assert not any("minute" in k or "duration" in k for k in doc), \
765
+ "app_usage 不该出现任何时长统计——覆盖面残缺,那个数字不可信"
766
+
767
+
768
+ def test_music_edges_are_marked_per_record_not_all_estimated():
769
+ """规范说「只有轮询样本就标 estimated」,实际是同一个信号两种精度并存。
770
+
771
+ Apple Music 走系统播放器、切歌 2 秒后就上报,起止是准的;Spotify、
772
+ 网易云只能靠快照采到的点。一律标 estimated 会把本来准确的那一半丢掉。
773
+ """
774
+ field = {f.key: f for f in MINIMAL_SIGNALS["music_playback"].fields}["edge_quality"]
775
+ assert field.enum == ("measured", "estimated")
776
+ assert not field.nullable, "每条都必须表态,不能留空让读的人自己猜"
777
+
778
+
779
+ # ---------------------------------------------------------------------------
780
+ # I10 —— 去重身份问错了保留期:拿明细的、不是聚合的
781
+ #
782
+ # 这条记录单独成表的**全部理由**就是「明细会过期、聚合可能永久,所以身份必须
783
+ # 比它守着的明细活得久」。判断条件却问的是明细永不永久 —— 于是恰好在它唯一
784
+ # 有用的那四个信号上判成不用守。规范 §14-2 点名了这个场景。
785
+ # ---------------------------------------------------------------------------
786
+
787
+ def test_the_dedupe_identity_is_marked_by_the_aggregate_it_guards():
788
+ """明细 7 天、每日数量永久的照片,就是这条存在的理由。
789
+
790
+ 身份先于聚合被清掉之后,一次重传把永久聚合多加一遍,**加完没法回滚** ——
791
+ 「8月1日新增 5 张」变成 10 张,而原始明细已经不在了,没有任何东西能算回去。
792
+
793
+ 这里包一层记录用的 storage,直接看管线**递给** remember_identity 的那条
794
+ 记录 —— 内存实现只留了身份的键、把 scope 丢了,拿它验等于没验。
795
+ """
796
+ seen = []
797
+
798
+ class Recording(InMemoryStorage):
799
+ def remember_identity(self, identity):
800
+ seen.append(identity)
801
+ return super().remember_identity(identity)
802
+
803
+ storage = Recording()
804
+ kit = PerceptionKit(storage=storage, signals=MINIMAL_SIGNALS)
805
+ at = datetime(2026, 9, 1, 9, 0, tzinfo=timezone.utc)
806
+ kit.ingest({
807
+ "schema_version": 1, "report_id": "p1", "producer": "ios",
808
+ "observations": [{
809
+ "signal": "photo_library_added", "signal_schema_version": 1,
810
+ "occurred_at": at.isoformat(), "availability": "observed",
811
+ "timezone": "Asia/Shanghai", "source_event_id": "ph-1",
812
+ "value": {"count": 1, "added_at": at.isoformat()},
813
+ }],
814
+ }, context=IngestContext("u", at))
815
+ assert [i.aggregate_scope for i in seen] == ["photo_library_added"], (
816
+ "明细 7 天、聚合永久 —— 身份必须标明它守着一个永久聚合,"
817
+ "否则保留期清理会把它当成可以删的")
818
+
819
+
820
+ def test_every_signal_with_a_permanent_aggregate_marks_its_identity():
821
+ """不只照片。focus / motion / music 都是明细 1 年、聚合永久。"""
822
+ at_risk = [k for k, s in MINIMAL_SIGNALS.items()
823
+ if s.keeps_aggregates_forever and not s.keeps_history_forever]
824
+ assert set(at_risk) == {"focus_state", "motion_state",
825
+ "music_playback", "photo_library_added"}
826
+
827
+
828
+ # ---------------------------------------------------------------------------
829
+ # I11 —— 「音乐停了」这个状态进不了系统
830
+ #
831
+ # 真机上跑出来的:老路 83 次记着 stopped,kit 那边一片空白。
832
+ # ---------------------------------------------------------------------------
833
+
834
+ def test_a_stopped_player_is_a_state_the_system_can_hold():
835
+ """播放器停着的时候没有曲目 —— 那是正常的,不是数据缺失。
836
+
837
+ `track_key` 非空的话,这条观测因为缺必填字段被**整条拒掉**:于是
838
+ 「在放什么」记得住,「停了」永远记不住。而「他刚把音乐关了」恰恰是
839
+ 这个信号最有用的那一半。
840
+ """
841
+ storage = InMemoryStorage()
842
+ kit = PerceptionKit(storage=storage, signals=MINIMAL_SIGNALS)
843
+ at = datetime(2026, 9, 1, 9, 0, tzinfo=timezone.utc)
844
+ outcome = kit.ingest({
845
+ "schema_version": 1, "report_id": "m1", "producer": "ios",
846
+ "observations": [{
847
+ "signal": "music_playback", "signal_schema_version": 1,
848
+ "occurred_at": at.isoformat(), "availability": "observed",
849
+ "timezone": "Asia/Shanghai",
850
+ "value": {"playback_state": "stopped", "edge_quality": "estimated"},
851
+ }],
852
+ }, context=IngestContext("u", at))
853
+ assert not outcome.rejected, list(outcome.rejected)
854
+ current = storage.get_current(subject_id="u", signals=["music_playback"]
855
+ )["music_playback"][0]
856
+ assert current.typed_value["playback_state"] == "stopped"
@@ -43,7 +43,7 @@ wheels = [
43
43
 
44
44
  [[package]]
45
45
  name = "perceptkit"
46
- version = "0.2.2"
46
+ version = "0.2.4"
47
47
  source = { editable = "." }
48
48
 
49
49
  [package.optional-dependencies]
File without changes
File without changes
File without changes