perceptkit 0.2.2__tar.gz → 0.2.3__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.
- {perceptkit-0.2.2 → perceptkit-0.2.3}/CHANGELOG.md +39 -1
- {perceptkit-0.2.2 → perceptkit-0.2.3}/PKG-INFO +1 -1
- {perceptkit-0.2.2 → perceptkit-0.2.3}/docs/reference-storage-mapping.md +1 -1
- {perceptkit-0.2.2 → perceptkit-0.2.3}/pyproject.toml +1 -1
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/algorithms/attribution.py +34 -6
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/algorithms/history.py +28 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/manifest/minimal.py +11 -2
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/manifest/types.py +24 -2
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/processing/aggregate.py +3 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/processing/normalize.py +5 -5
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/processing/pipeline.py +56 -3
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_manifest.py +4 -1
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_regressions.py +267 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/uv.lock +1 -1
- {perceptkit-0.2.2 → perceptkit-0.2.3}/.github/workflows/release.yml +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/.gitignore +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/LICENSE +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/NOTES-packaging.md +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/NOTES-quickstart.md +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/README.md +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/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
- {perceptkit-0.2.2 → perceptkit-0.2.3}/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
- {perceptkit-0.2.2 → perceptkit-0.2.3}/examples/end_to_end.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/examples/ios_adapter.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/examples/quickstart.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/__init__.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/algorithms/__init__.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/algorithms/glance.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/algorithms/identity.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/algorithms/observation.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/algorithms/streaks.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/algorithms/trend_models.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/algorithms/wake.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/catalog.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/conformance/__init__.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/conformance/memory.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/conformance/report.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/conformance/suite.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/conformance/wake.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/contracts/__init__.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/contracts/_time.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/contracts/availability.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/contracts/context.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/contracts/delivery.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/contracts/errors.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/contracts/event.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/contracts/observation.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/contracts/receipt.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/contracts/records.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/contracts/report.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/contracts/versioning.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/fields.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/kit.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/manifest/__init__.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/manifest/checks.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/manifest/mapping.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/manifest/units.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/ports/__init__.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/ports/storage.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/ports/wake.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/processing/__init__.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/processing/dispatch.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/processing/recompute.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/processing/recurrence.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/processing/scheduled.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/prompts.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/queries/__init__.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/queries/api.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/retention.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/rules/__init__.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/rules/engine.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/rules/evaluators.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/src/perceptkit/rules/types.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/fixtures/README.md +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/fixtures/ios_snapshot_no_data.json +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/fixtures/ios_snapshot_normal.json +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/fixtures/ios_snapshot_unauthorized.json +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_attribution.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_catalog.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_conformance.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_conformance_wake_report.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_contracts.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_delivery_and_records.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_edge_cases.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_end_to_end.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_event_envelope.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_examples.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_export.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_identity.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_ios_fixture.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_isolation.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_no_host_leakage.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_observation.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_pipeline.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_projection.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_purity.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_queries.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_recompute.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_recurrence.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_retention.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_rules.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_scheduled.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_source_mirror.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_streaks.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_trend_models.py +0 -0
- {perceptkit-0.2.2 → perceptkit-0.2.3}/tests/test_wake.py +0 -0
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
# 变更记录
|
|
2
2
|
|
|
3
|
-
## 0.2.
|
|
3
|
+
## 0.2.3 — 未发布
|
|
4
|
+
|
|
5
|
+
**七个「声明了、但没有代码做」的修复。** 都是拿规范逐条跑代码跑出来的,
|
|
6
|
+
不是照着清单打勾 —— 清单把这七条全判成了成立,因为它核的是 manifest 怎么写的。
|
|
7
|
+
|
|
8
|
+
### 修复
|
|
9
|
+
|
|
10
|
+
- 🔴 **归属日期用的是时间戳自带的 offset,不是观测声明的 IANA 时区。**
|
|
11
|
+
producer 完全可以用 UTC 发 `occurred_at`、把时区另放在观测里(参考的 iOS
|
|
12
|
+
适配层就是这么发的)。上海用户本地 00:00–08:00 的数据**每天全部落到前一天**,
|
|
13
|
+
"昨天走了多少步""昨晚睡了几小时"跟着一起偏。
|
|
14
|
+
|
|
15
|
+
- 🔴 **去重身份问错了保留期。** `DurableDedupeIdentity` 单独成表的全部理由是
|
|
16
|
+
「明细会过期、聚合可能永久」,而标记它的条件问的是**明细**永不永久 ——
|
|
17
|
+
于是恰好在它唯一有用的四个信号上(照片、focus、motion、music)判成不用守。
|
|
18
|
+
身份先于聚合被清掉之后,一次重传把永久聚合多加一遍,**明细已经不在,
|
|
19
|
+
算不回去**。
|
|
20
|
+
|
|
21
|
+
- 🔴 **`comparison_strategy="state_change"` 声明了,没有任何代码读它。**
|
|
22
|
+
保活上报每 5 分钟一次,「还在专注」一天写出几百条一模一样的记录。
|
|
23
|
+
只跳过明细,**当前值和聚合照常走** —— `duration_by_state` 靠相邻观测的
|
|
24
|
+
时间差累计,跳过聚合是拿一个 bug 换另一个。
|
|
25
|
+
|
|
26
|
+
- 🔴 **一个信号只能有一条当前值。** `dimension_key` 在主键里、却永远等于信号名。
|
|
27
|
+
同时连着两个 Wi-Fi 锚点只剩一个;用户搬家、新旧网络都叫 "home" 会被合并,
|
|
28
|
+
之后历史再也分不开哪段是哪个家。信号现在可以声明 `dimension_fields`,
|
|
29
|
+
不声明的行为逐字节不变。
|
|
30
|
+
|
|
31
|
+
- **「今天打开了几次」永远答 1。** `open_count` 声明的是 `daily_total`(取 max),
|
|
32
|
+
而每次打开各贡献 1 —— 一列 1 取最大值还是 1。新增 `occurrence_count`(求和),
|
|
33
|
+
和 `cumulative` 并排放、把差别写在定义旁边。
|
|
34
|
+
|
|
35
|
+
- **每日照片数量按明细的保留期过期。** 明细和聚合是两个数,只写了一个,
|
|
36
|
+
于是聚合继承了 7 天 ——「8月1日新增 5 张」是发生过的事实,不是"现在还剩几张"。
|
|
37
|
+
|
|
38
|
+
- conformance 补第 ⑪ 条已在 0.2.1;本版把上面这些各配一条会红的回归测试。
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
## 0.2.2 — 2026-09-01
|
|
4
42
|
|
|
5
43
|
**代码没有变化。** 这一版只换了「怎么发出去」。
|
|
6
44
|
|
|
@@ -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 天 |
|
|
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 |
|
|
@@ -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
|
|
77
|
-
"""
|
|
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
|
-
|
|
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=(
|
|
@@ -1233,9 +1240,11 @@ APP_USAGE = SignalDefinition(
|
|
|
1233
1240
|
unit="count",
|
|
1234
1241
|
privacy_class="sensitive",
|
|
1235
1242
|
valid_range=(0, None),
|
|
1236
|
-
# 每条 open 贡献 1
|
|
1243
|
+
# 每条 open 贡献 1,当天**求和**。**只靠 open,所以可信** ——
|
|
1237
1244
|
# 这正是砍掉时长统计之后仍然答得准的那部分。
|
|
1238
|
-
|
|
1245
|
+
# ⚠️ 曾经写的是 daily_total(取 max),于是「今天打开了几次」
|
|
1246
|
+
# 永远等于 1 —— 这个信号唯一保证答得准的问题,答错了。
|
|
1247
|
+
aggregation_strategy="occurrence_count",
|
|
1239
1248
|
comparison_strategy="none",
|
|
1240
1249
|
query_visibility="on_demand",
|
|
1241
1250
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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,270 @@ 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"}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|