perceptkit 0.2.4__tar.gz → 0.2.6__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.6/.github/workflows/ci.yml +56 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/CHANGELOG.md +87 -1
- {perceptkit-0.2.4 → perceptkit-0.2.6}/PKG-INFO +6 -5
- {perceptkit-0.2.4 → perceptkit-0.2.6}/README.md +4 -3
- {perceptkit-0.2.4 → perceptkit-0.2.6}/docs/reference-storage-mapping.md +1 -1
- {perceptkit-0.2.4 → perceptkit-0.2.6}/pyproject.toml +2 -2
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/algorithms/attribution.py +12 -7
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/conformance/memory.py +30 -4
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/conformance/suite.py +94 -2
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/kit.py +54 -2
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/manifest/checks.py +40 -1
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/manifest/minimal.py +19 -9
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/ports/storage.py +49 -4
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/processing/recompute.py +57 -2
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/queries/api.py +109 -56
- perceptkit-0.2.6/src/perceptkit/retention.py +168 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/rules/engine.py +6 -2
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_conformance.py +1 -1
- perceptkit-0.2.6/tests/test_docs_match_code.py +47 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_regressions.py +217 -0
- perceptkit-0.2.6/tests/test_retention_entry.py +160 -0
- perceptkit-0.2.6/tests/test_revision_recompute.py +103 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/uv.lock +1 -1
- perceptkit-0.2.4/src/perceptkit/retention.py +0 -84
- {perceptkit-0.2.4 → perceptkit-0.2.6}/.github/workflows/release.yml +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/.gitignore +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/LICENSE +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/NOTES-packaging.md +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/NOTES-quickstart.md +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/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.4 → perceptkit-0.2.6}/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.4 → perceptkit-0.2.6}/examples/end_to_end.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/examples/ios_adapter.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/examples/quickstart.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/__init__.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/algorithms/__init__.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/algorithms/glance.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/algorithms/history.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/algorithms/identity.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/algorithms/observation.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/algorithms/streaks.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/algorithms/trend_models.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/algorithms/wake.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/catalog.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/conformance/__init__.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/conformance/report.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/conformance/wake.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/contracts/__init__.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/contracts/_time.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/contracts/availability.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/contracts/context.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/contracts/delivery.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/contracts/errors.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/contracts/event.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/contracts/observation.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/contracts/receipt.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/contracts/records.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/contracts/report.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/contracts/versioning.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/fields.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/manifest/__init__.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/manifest/mapping.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/manifest/types.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/manifest/units.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/ports/__init__.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/ports/wake.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/processing/__init__.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/processing/aggregate.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/processing/dispatch.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/processing/normalize.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/processing/pipeline.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/processing/recurrence.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/processing/scheduled.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/prompts.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/queries/__init__.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/rules/__init__.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/rules/evaluators.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/src/perceptkit/rules/types.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/fixtures/README.md +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/fixtures/ios_snapshot_no_data.json +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/fixtures/ios_snapshot_normal.json +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/fixtures/ios_snapshot_unauthorized.json +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_attribution.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_catalog.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_conformance_wake_report.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_contracts.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_delivery_and_records.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_edge_cases.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_end_to_end.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_event_envelope.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_examples.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_export.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_identity.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_ios_fixture.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_isolation.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_manifest.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_no_host_leakage.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_observation.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_pipeline.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_projection.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_purity.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_queries.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_recompute.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_recurrence.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_retention.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_rules.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_scheduled.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_source_mirror.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_streaks.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_trend_models.py +0 -0
- {perceptkit-0.2.4 → perceptkit-0.2.6}/tests/test_wake.py +0 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# 每次推送都跑,跨所有**声明支持**的 Python 版本。
|
|
2
|
+
#
|
|
3
|
+
# 为什么要矩阵:这个包声明 requires-python >=3.10,而在此之前只有发版那条
|
|
4
|
+
# workflow 会跑测试,且只跑 3.12。结果是 3.10 上「两位小数秒的时间戳整条被拒」
|
|
5
|
+
# 这个 bug 活到了外部审查才被发现 —— 声明支持一个版本却从不在上面跑,
|
|
6
|
+
# 等于把"支持"两个字写在一个没人验的地方。
|
|
7
|
+
#
|
|
8
|
+
# 矩阵的下边界必须和 pyproject 的 requires-python 一致。要提最低版本,
|
|
9
|
+
# 就同时改这两处,不许只改一处。
|
|
10
|
+
name: CI
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
push:
|
|
14
|
+
branches: ["**"]
|
|
15
|
+
pull_request:
|
|
16
|
+
|
|
17
|
+
concurrency:
|
|
18
|
+
group: ci-${{ github.ref }}
|
|
19
|
+
cancel-in-progress: true
|
|
20
|
+
|
|
21
|
+
jobs:
|
|
22
|
+
test:
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
strategy:
|
|
25
|
+
fail-fast: false
|
|
26
|
+
matrix:
|
|
27
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/checkout@v4
|
|
30
|
+
|
|
31
|
+
- uses: actions/setup-python@v5
|
|
32
|
+
with:
|
|
33
|
+
python-version: ${{ matrix.python-version }}
|
|
34
|
+
|
|
35
|
+
- name: Install uv
|
|
36
|
+
run: pipx install uv
|
|
37
|
+
|
|
38
|
+
- name: requires-python 的下界必须就是矩阵的下界
|
|
39
|
+
# 光有矩阵不够 —— 有人把 requires-python 降到 3.9 而矩阵没跟,
|
|
40
|
+
# 就又回到"声明支持但从不验"那个状态,而且这次是无声的。
|
|
41
|
+
run: |
|
|
42
|
+
FLOOR=$(grep -m1 '^requires-python' pyproject.toml \
|
|
43
|
+
| sed 's/.*>=\([0-9.]*\).*/\1/')
|
|
44
|
+
echo "requires-python 下界=$FLOOR 矩阵下界=3.10"
|
|
45
|
+
test "$FLOOR" = "3.10" || {
|
|
46
|
+
echo "::error::requires-python 是 >=$FLOOR,但 CI 矩阵从 3.10 起。"
|
|
47
|
+
echo "::error::改最低版本时 pyproject 和这个矩阵必须一起改。"
|
|
48
|
+
exit 1; }
|
|
49
|
+
|
|
50
|
+
- name: 跑测试
|
|
51
|
+
run: uv run --python ${{ matrix.python-version }} --extra dev pytest tests/ -q
|
|
52
|
+
|
|
53
|
+
- name: 示例必须能脱离宿主跑通
|
|
54
|
+
run: |
|
|
55
|
+
uv run --python ${{ matrix.python-version }} python3 examples/quickstart.py
|
|
56
|
+
uv run --python ${{ matrix.python-version }} python3 examples/end_to_end.py
|
|
@@ -1,6 +1,92 @@
|
|
|
1
1
|
# 变更记录
|
|
2
2
|
|
|
3
|
-
## 0.2.
|
|
3
|
+
## 0.2.6 — 2026-09-02
|
|
4
|
+
|
|
5
|
+
外部审查那份清单里剩下的两条(P0-1 / P0-6 的可做部分)。
|
|
6
|
+
|
|
7
|
+
### 新增
|
|
8
|
+
|
|
9
|
+
- **`PerceptionKit.run_retention()` —— 清理终于有了统一入口(P0-1)。**
|
|
10
|
+
以前包里只有保留期表和查询函数,没有任何东西真的执行,于是每个宿主自己
|
|
11
|
+
照 manifest 推导一遍。**什么时候跑仍然是宿主的事**(这里没有调度),
|
|
12
|
+
但规则只该有一份 —— 这条路上每个坑错了都不报错:明细和聚合是两个保留期、
|
|
13
|
+
PERMANENT 要跳过、没声明的不许猜一个、去重身份不能跟着明细删。
|
|
14
|
+
默认 `dry_run=True`:这是包里唯一会永久删用户数据的动作。
|
|
15
|
+
- **`StoragePort.delete_aggregates()`。** 端口原来只有明细的删除口,
|
|
16
|
+
日聚合根本删不掉 —— 宿主要么自己写 SQL,要么干脆不清,而
|
|
17
|
+
「有限保留期的聚合永远不删」不会报错,只会让库一直长。
|
|
18
|
+
|
|
19
|
+
### 修复
|
|
20
|
+
|
|
21
|
+
- **修订过的那天,重算会把错值和改正值一起折进去(P0-6 的一半)。**
|
|
22
|
+
`source_revision` 原来**只**用在当前值上,历史和聚合完全没读它:
|
|
23
|
+
心率 90(revision 1)被改成 60(revision 2),重算那天折出来是 75 ——
|
|
24
|
+
一个从来没发生过的数字。而这个错只有重算时才现形,当前值那条路是对的,
|
|
25
|
+
所以"改完之后当前显示对了"会让人以为整条链路都对了。
|
|
26
|
+
修订号按数字比不按字符串比(否则 `"10" < "9"`,第 10 版被第 9 版盖掉)。
|
|
27
|
+
**一组观测全都没有修订号时一条都不合并** —— 那等于替它们编一个
|
|
28
|
+
"后来的覆盖先来的"顺序,而那恰好不是 `cumulative` 现在的规则(它取 max)。
|
|
29
|
+
|
|
30
|
+
### 已知未做
|
|
31
|
+
|
|
32
|
+
- **来源侧删除没有 tombstone 契约。** 修订带着 `availability` 一起来时,
|
|
33
|
+
上面那条已经能把事实从当天移掉;但 HealthKit 真正的删除走的是
|
|
34
|
+
`deletedObjects`,那是一条完全没接的路。
|
|
35
|
+
- **`cumulative` 取 max,分不清「迟到的旧数据」和「向下的修正」。**
|
|
36
|
+
用户删掉一次运动、当日活动能量从 500 降到 300 —— 当前值会跟着降,
|
|
37
|
+
日聚合停在 500,同一天同一个指标两个数。改成"最新的赢"能修这个,
|
|
38
|
+
但会让来源侧的一次归零(比如重装后当日步数从头算)把那天清掉。
|
|
39
|
+
两种规则各有一个失败模式,要产品拍板,不从代码里溜进去。
|
|
40
|
+
|
|
41
|
+
## 0.2.5 — 2026-09-02
|
|
42
|
+
|
|
43
|
+
**外部审查(2026-09-02)逐条核出来的。** 四条里三条是"查询静默给错答案",
|
|
44
|
+
不是崩溃 —— 这类最贵,因为看上去一切正常。
|
|
45
|
+
|
|
46
|
+
### 修复
|
|
47
|
+
|
|
48
|
+
- **排查查不到终态事件。** `list_events` 的文档自己写着「答案往往不是
|
|
49
|
+
pending,而是 suppressed 或 rejected」,下一行却调了只看待投递的端口,
|
|
50
|
+
而那两个恰好都是终态。于是「为什么没提醒我」这个问题,代码保证答不出来 ——
|
|
51
|
+
排查看到的是"压根没产生过这个事件",方向直接错。新增 `StoragePort.list_events`
|
|
52
|
+
(任何投递状态,状态/类型/时间窗全部下推),旧的 `list_pending_events`
|
|
53
|
+
仍然只给 worker 用。
|
|
54
|
+
- **日历和提醒最多只能看到 500 条。** 从存储取一个固定上限、再在内存里切页:
|
|
55
|
+
游标只在那一批里打转,第 501 条永远取不到,而且不报错 —— 用户看到的是
|
|
56
|
+
"我八月没有日程"。`offset` 现在真的下推给存储;日历因为要展开重复规则,
|
|
57
|
+
用 `(读到第几条:那一条消费到第几个)` 的复合游标,翻页不会把同一条日程重给一遍。
|
|
58
|
+
- **Python 3.10 上两位小数秒的时间戳整条被拒。** 归属那一层自己调了
|
|
59
|
+
`fromisoformat`,绕开了包里那个宽解析器;3.10 的 `fromisoformat` 只认三位或
|
|
60
|
+
六位小数秒,而真机 producer 发的是 `23:59:59.96+08:00`。冷却判定也走了同一条
|
|
61
|
+
窄路 —— 那里解析失败会**静默当作没冷却过**,同一件事反复叫醒 agent。
|
|
62
|
+
两处都改走 `contracts._time`。
|
|
63
|
+
|
|
64
|
+
- **「今天新增了几张照片」永远答 1。** `photo_library_added.count` 的声明是
|
|
65
|
+
`valid_range=(1,1)`(一张照片一条 count=1)配 `daily_total`(当天取 max)——
|
|
66
|
+
max 恒等于 1。用户拍了二十张,聚合里写着 1,而这条聚合是**永久**保存的。
|
|
67
|
+
这是第二次:`app_usage.open_count` 先踩过同一个坑,教训写进了注释、
|
|
68
|
+
例子都写着「用户开了二十次,答案还是 1 次」,但隔壁的照片没人回头看。
|
|
69
|
+
**一条只写在注释里的纪律,保护不了下一个信号** —— 所以同时加了
|
|
70
|
+
`check_counting_strategies_can_actually_count`:取 max 的策略配上「取值范围
|
|
71
|
+
只有一个数」的字段,是可以机械证明的矛盾,直接在 `validate_manifest` 里拦掉。
|
|
72
|
+
写完这个检查的当下它就把照片这条抓出来了。
|
|
73
|
+
⚠️ 已经落库的历史数字修不回来:明细只留 7 天,更早的日子连重算的原料都没有。
|
|
74
|
+
|
|
75
|
+
### 新增
|
|
76
|
+
|
|
77
|
+
- **一致性套件第 ⑫ 条:终态可查与翻页下推。** 前两条修的是包内实现,但同样的
|
|
78
|
+
错宿主也会犯:把 `offset` 加进签名、body 里照旧忽略 —— 签名对了、行为没变,
|
|
79
|
+
而 Protocol 不会告诉你。所以这条验的是行为。
|
|
80
|
+
- `tests/test_docs_match_code.py`:文档里声称的信号数量、CHANGELOG 顶上的版本,
|
|
81
|
+
和代码对不上就红。
|
|
82
|
+
|
|
83
|
+
### 文档
|
|
84
|
+
|
|
85
|
+
- manifest 开头不再说"五个信号"(实际 23 个),`PerceptionKit` 的默认值注释同步。
|
|
86
|
+
- README 去掉写死的测试条数 —— 每次提交都在变,写死了就是保证会漂。
|
|
87
|
+
- `pyproject` 的描述不再说"不碰存储":不选数据库,但存储编排在包内。
|
|
88
|
+
|
|
89
|
+
## 0.2.4 — 2026-09-01
|
|
4
90
|
|
|
5
91
|
**第一批真机数据跑出来的。** 4052 次比对里三处不一致,这是其中属于内核的那一处。
|
|
6
92
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: perceptkit
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary: 从设备信号判断:有没有发生值得留意的事、值不值得叫醒一次 agent
|
|
3
|
+
Version: 0.2.6
|
|
4
|
+
Summary: 从设备信号判断:有没有发生值得留意的事、值不值得叫醒一次 agent、以及该怎么把此刻的状况讲给它听。不采集数据、不选数据库、不调模型 —— 存储由宿主实现 StoragePort,编排在包内。
|
|
5
5
|
License:
|
|
6
6
|
Apache License
|
|
7
7
|
Version 2.0, January 2004
|
|
@@ -247,7 +247,7 @@ kit.dispatch_pending(worker_id="w1", now=now)
|
|
|
247
247
|
```bash
|
|
248
248
|
git clone git@github.com:teleport-computer/perceptkit.git
|
|
249
249
|
cd perceptkit
|
|
250
|
-
uv run pytest #
|
|
250
|
+
uv run pytest # 全部测试
|
|
251
251
|
uv run python3 examples/quickstart.py # 一分钟看它怎么判断
|
|
252
252
|
```
|
|
253
253
|
|
|
@@ -429,8 +429,9 @@ src/perceptkit/
|
|
|
429
429
|
|
|
430
430
|
## 六、现状
|
|
431
431
|
|
|
432
|
-
|
|
433
|
-
以及 `test_no_host_leakage.py
|
|
432
|
+
测试全绿(含 `test_purity.py` 的 AST 扫描:本包只依赖标准库,
|
|
433
|
+
以及 `test_no_host_leakage.py`:不带宿主内部痕迹)。条数不写在这里 ——
|
|
434
|
+
每次提交都在变,写死了就是保证会漂,而"多少条"本身帮不了任何判断。`examples/quickstart.py`
|
|
434
435
|
本身也有一条测试盯着(`tests/test_examples.py`)——它必须一直能跑,
|
|
435
436
|
不能悄悄烂掉。
|
|
436
437
|
|
|
@@ -34,7 +34,7 @@ kit.dispatch_pending(worker_id="w1", now=now)
|
|
|
34
34
|
```bash
|
|
35
35
|
git clone git@github.com:teleport-computer/perceptkit.git
|
|
36
36
|
cd perceptkit
|
|
37
|
-
uv run pytest #
|
|
37
|
+
uv run pytest # 全部测试
|
|
38
38
|
uv run python3 examples/quickstart.py # 一分钟看它怎么判断
|
|
39
39
|
```
|
|
40
40
|
|
|
@@ -216,8 +216,9 @@ src/perceptkit/
|
|
|
216
216
|
|
|
217
217
|
## 六、现状
|
|
218
218
|
|
|
219
|
-
|
|
220
|
-
以及 `test_no_host_leakage.py
|
|
219
|
+
测试全绿(含 `test_purity.py` 的 AST 扫描:本包只依赖标准库,
|
|
220
|
+
以及 `test_no_host_leakage.py`:不带宿主内部痕迹)。条数不写在这里 ——
|
|
221
|
+
每次提交都在变,写死了就是保证会漂,而"多少条"本身帮不了任何判断。`examples/quickstart.py`
|
|
221
222
|
本身也有一条测试盯着(`tests/test_examples.py`)——它必须一直能跑,
|
|
222
223
|
不能悄悄烂掉。
|
|
223
224
|
|
|
@@ -99,7 +99,7 @@ iOS 拿不到前台 app(`frontmost_app` 恒为 null),数据全靠用户在
|
|
|
99
99
|
|
|
100
100
|
一张照片一条 count=1,不是「今天 5 张」报一次 —— 拆成一条条才能让照片走通用管线:跨午夜天然各归各的日、某条字段有问题只拒那一条。传输上仍然可以一个信封装多条,不多发请求。
|
|
101
101
|
🔴 删照片【不回减】过去某日的数量:它记的是「那天发生过什么」,不是「现在还剩几张」。
|
|
102
|
-
|
|
102
|
+
身份必须由设备给一个稳定值:内容信封 id 是每次上传新生成的,用它的话同一张照片重传就是两张,去重表再完美也挡不住。iOS 送的是SHA256(固定 namespace + PHAsset.localIdentifier)——固定 namespace 而不是wifi_anchor_id 那种设备本地随机密钥:照片 id 是本机相册的高熵 UUID,别的设备上不存在,固定 namespace 就够不可逆;而且重装后仍然稳定,正好让重扫相册时认出「这些都数过了」。
|
|
103
103
|
去重指纹的保留期:规范建议永久;我们查下来当前实现【找不到超过 7 天的重放路径】,所以按「覆盖明细保留期 + 富余」取 30 天更实在。规范自己也是条件句:「若 producer 可以在超过 7 天后重放,才必须永久保留」。
|
|
104
104
|
|
|
105
105
|
### `presence_recovery`
|
|
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "perceptkit"
|
|
7
|
-
version = "0.2.
|
|
8
|
-
description = "从设备信号判断:有没有发生值得留意的事、值不值得叫醒一次 agent
|
|
7
|
+
version = "0.2.6"
|
|
8
|
+
description = "从设备信号判断:有没有发生值得留意的事、值不值得叫醒一次 agent、以及该怎么把此刻的状况讲给它听。不采集数据、不选数据库、不调模型 —— 存储由宿主实现 StoragePort,编排在包内。"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = { file = "LICENSE" }
|
|
@@ -15,6 +15,8 @@ from __future__ import annotations
|
|
|
15
15
|
import datetime as _dt
|
|
16
16
|
import zoneinfo as _zoneinfo
|
|
17
17
|
|
|
18
|
+
from ..contracts import _time
|
|
19
|
+
|
|
18
20
|
INSTANT = "instant" # 单点:按其自带 offset 的本地日期
|
|
19
21
|
EPISODE_END = "episode_end" # 区间:整体归结束(醒来)那天
|
|
20
22
|
SPLIT_AT_MIDNIGHT = "split_at_midnight" # 可加总时长:按本地午夜切分
|
|
@@ -52,14 +54,17 @@ ATTRIBUTION: dict[str, str] = {
|
|
|
52
54
|
|
|
53
55
|
|
|
54
56
|
def _aware(raw: str) -> _dt.datetime:
|
|
55
|
-
"""解析成带 offset 的时刻。没有 offset 就报错 —— 不猜。
|
|
57
|
+
"""解析成带 offset 的时刻。没有 offset 就报错 —— 不猜。
|
|
58
|
+
|
|
59
|
+
🔴 **走 ``contracts._time`` 那一个解析器,不许自己调 ``fromisoformat``。**
|
|
60
|
+
3.10 的 ``fromisoformat`` 只认三位或六位小数秒,真实 producer 发的是
|
|
61
|
+
``23:59:59.96+08:00`` 这种两位的;自己调就是"3.12 上全绿、3.10 上
|
|
62
|
+
整条上报被拒"。这个包声明 ``requires-python>=3.10``,那就得真的能跑。
|
|
63
|
+
"""
|
|
56
64
|
try:
|
|
57
|
-
|
|
58
|
-
except
|
|
59
|
-
raise ValueError(
|
|
60
|
-
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
61
|
-
raise ValueError(f"时间缺少时区 offset,拒绝按 UTC 或本机时区猜测:{raw!r}")
|
|
62
|
-
return parsed
|
|
65
|
+
return _time.parse_timestamp(raw, field="时间")
|
|
66
|
+
except _time.TimestampError as exc:
|
|
67
|
+
raise ValueError(str(exc)) from exc
|
|
63
68
|
|
|
64
69
|
|
|
65
70
|
def _utc_naive(dtobj: _dt.datetime) -> _dt.datetime:
|
|
@@ -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 [
|
|
@@ -197,7 +205,8 @@ class InMemoryStorage:
|
|
|
197
205
|
self.reminders[(subject_id, r.source_account_id, r.source_list_id,
|
|
198
206
|
r.source_reminder_id)] = r
|
|
199
207
|
|
|
200
|
-
def list_calendar_events(self, *, subject_id, start=None, end=None,
|
|
208
|
+
def list_calendar_events(self, *, subject_id, start=None, end=None,
|
|
209
|
+
limit=50, offset=0):
|
|
201
210
|
rows = [v for k, v in self.calendar.items() if k[0] == subject_id]
|
|
202
211
|
keep = []
|
|
203
212
|
for item in rows:
|
|
@@ -212,9 +221,10 @@ class InMemoryStorage:
|
|
|
212
221
|
keep.sort(key=lambda i: (i.event_fields.get("start_at") is None,
|
|
213
222
|
i.event_fields.get("start_at") or _EPOCH,
|
|
214
223
|
i.source_event_id))
|
|
215
|
-
return keep[:limit]
|
|
224
|
+
return keep[offset:offset + limit]
|
|
216
225
|
|
|
217
|
-
def list_reminders(self, *, subject_id, include_completed=False,
|
|
226
|
+
def list_reminders(self, *, subject_id, include_completed=False,
|
|
227
|
+
limit=50, offset=0):
|
|
218
228
|
keep = [
|
|
219
229
|
v for k, v in self.reminders.items()
|
|
220
230
|
if k[0] == subject_id
|
|
@@ -223,7 +233,7 @@ class InMemoryStorage:
|
|
|
223
233
|
keep.sort(key=lambda i: (i.reminder_fields.get("due_at") is None,
|
|
224
234
|
i.reminder_fields.get("due_at") or _EPOCH,
|
|
225
235
|
i.source_reminder_id))
|
|
226
|
-
return keep[:limit]
|
|
236
|
+
return keep[offset:offset + limit]
|
|
227
237
|
|
|
228
238
|
def apply_source_snapshot(self, *, subject_id, source, collection_kind, sync_id,
|
|
229
239
|
coverage_start, coverage_end, snapshot_kind) -> int:
|
|
@@ -326,6 +336,22 @@ class InMemoryStorage:
|
|
|
326
336
|
if not e.is_terminal and (subject_id is None or e.subject_id == subject_id)
|
|
327
337
|
][:limit]
|
|
328
338
|
|
|
339
|
+
def list_events(self, *, subject_id, delivery_states=None, event_type=None,
|
|
340
|
+
start=None, end=None, limit=50, offset=0):
|
|
341
|
+
# 注意这里**没有** is_terminal 过滤 —— 排查要的正是终态。
|
|
342
|
+
rows = [e for e in self.outbox.values() if e.subject_id == subject_id]
|
|
343
|
+
if delivery_states is not None:
|
|
344
|
+
wanted = set(delivery_states)
|
|
345
|
+
rows = [e for e in rows if e.delivery_state in wanted]
|
|
346
|
+
if event_type is not None:
|
|
347
|
+
rows = [e for e in rows if e.event_type == event_type]
|
|
348
|
+
if start is not None:
|
|
349
|
+
rows = [e for e in rows if e.occurred_at >= start]
|
|
350
|
+
if end is not None:
|
|
351
|
+
rows = [e for e in rows if e.occurred_at <= end]
|
|
352
|
+
rows.sort(key=lambda e: (e.occurred_at, e.event_id), reverse=True)
|
|
353
|
+
return rows[offset:offset + limit]
|
|
354
|
+
|
|
329
355
|
# -- 用户数据 --------------------------------------------------------
|
|
330
356
|
|
|
331
357
|
def purge_subject(self, *, subject_id) -> dict[str, int]:
|
|
@@ -86,7 +86,7 @@ def _entry(**over: Any) -> EventOutboxEntry:
|
|
|
86
86
|
|
|
87
87
|
|
|
88
88
|
# ---------------------------------------------------------------------------
|
|
89
|
-
#
|
|
89
|
+
# 十二条保证
|
|
90
90
|
# ---------------------------------------------------------------------------
|
|
91
91
|
|
|
92
92
|
def _g1_report_and_observation_idempotency(new: StorageFactory) -> list[str]:
|
|
@@ -383,6 +383,97 @@ def _g11_both_source_mirrors_round_trip(new: StorageFactory) -> list[str]:
|
|
|
383
383
|
return problems
|
|
384
384
|
|
|
385
385
|
|
|
386
|
+
def _g12_terminal_events_and_offsets_are_queryable(new: StorageFactory) -> list[str]:
|
|
387
|
+
"""⑫ 终态事件查得到,翻页翻得过读取批次。
|
|
388
|
+
|
|
389
|
+
两件事都是"静默给错答案",不是"报错":
|
|
390
|
+
|
|
391
|
+
终态查不到 「为什么没提醒我」的答案通常是 suppressed(撞了安静时段)
|
|
392
|
+
或 rejected(宿主拒了),而这两个都是终态。宿主只实现了
|
|
393
|
+
「待投递」那个查询的话,排查看到的是"压根没产生过这个
|
|
394
|
+
事件",方向直接错了。
|
|
395
|
+
翻页翻不动 offset 不真的下推到存储,游标就只在第一批里打转。
|
|
396
|
+
用户看到的是"我八月没有日程",不是"结果被截断了"。
|
|
397
|
+
|
|
398
|
+
宿主容易只把 ``offset`` 加进签名、body 里照旧忽略 —— 签名对了、行为没变,
|
|
399
|
+
而 Protocol 不会告诉你。所以这里验的是**行为**,不是方法在不在。
|
|
400
|
+
"""
|
|
401
|
+
problems: list[str] = []
|
|
402
|
+
s = new()
|
|
403
|
+
base = T0
|
|
404
|
+
# 回执状态和投递状态是**两套词表**:runtime 回 conversation_suppressed,
|
|
405
|
+
# 落到发件箱上叫 suppressed。这里照真实那条路走,不直接改状态字段。
|
|
406
|
+
wanted = {
|
|
407
|
+
"ev0": (_receipt.WAKE_SUPPRESSED, _delivery.SUPPRESSED), # 撞了安静时段
|
|
408
|
+
"ev1": (_receipt.WAKE_REJECTED, _delivery.REJECTED), # 宿主拒了
|
|
409
|
+
"ev2": (_receipt.WAKE_ACCEPTED, _delivery.DELIVERED),
|
|
410
|
+
"ev3": (None, _delivery.PENDING),
|
|
411
|
+
}
|
|
412
|
+
for i, (eid, (status, state)) in enumerate(wanted.items()):
|
|
413
|
+
s.enqueue_event(_entry(event_id=eid, event_type="t",
|
|
414
|
+
occurred_at=base + timedelta(minutes=i)))
|
|
415
|
+
if status is None:
|
|
416
|
+
continue
|
|
417
|
+
claimed = s.claim_pending_event(worker_id="w", now=base,
|
|
418
|
+
lease_seconds=60)
|
|
419
|
+
if claimed is None:
|
|
420
|
+
problems.append("⑫: 领不到刚入队的事件,后面测不了")
|
|
421
|
+
return problems
|
|
422
|
+
s.record_wake_receipt(
|
|
423
|
+
receipt=WakeReceipt(event_id=claimed.event_id,
|
|
424
|
+
attempt_id=f"a{i}", status=status,
|
|
425
|
+
received_at=base),
|
|
426
|
+
next_state=state, claim_token=claimed.claim_token,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
seen = {e.event_id: e.delivery_state
|
|
430
|
+
for e in s.list_events(subject_id="u1", limit=50)}
|
|
431
|
+
for eid, (_status, state) in wanted.items():
|
|
432
|
+
if eid not in seen:
|
|
433
|
+
problems.append(
|
|
434
|
+
f"⑫: list_events 看不到 {state} 的事件 —— "
|
|
435
|
+
f"「为什么没提醒我」这个问题就答不出来"
|
|
436
|
+
)
|
|
437
|
+
only = [e.event_id for e in s.list_events(
|
|
438
|
+
subject_id="u1", delivery_states=[_delivery.SUPPRESSED], limit=50)]
|
|
439
|
+
if only != ["ev0"]:
|
|
440
|
+
problems.append("⑫: 按投递状态筛没生效")
|
|
441
|
+
|
|
442
|
+
# offset 必须真的下推:连着两页不能给出同一条。
|
|
443
|
+
first = [e.event_id for e in s.list_events(subject_id="u1", limit=2, offset=0)]
|
|
444
|
+
second = [e.event_id for e in s.list_events(subject_id="u1", limit=2, offset=2)]
|
|
445
|
+
if set(first) & set(second) or len(first) + len(second) != 4:
|
|
446
|
+
problems.append("⑫: list_events 的 offset 没有下推到存储,翻页会重复或漏")
|
|
447
|
+
|
|
448
|
+
s2 = new()
|
|
449
|
+
s2.upsert_calendar_events(subject_id="u1", events=[CalendarEventMirror(
|
|
450
|
+
subject_id="u1", source_account_id="a", source_calendar_id="c",
|
|
451
|
+
source_event_id=f"e{i}",
|
|
452
|
+
event_fields={"title": f"e{i}", "start_at": base + timedelta(minutes=i)},
|
|
453
|
+
) for i in range(4)])
|
|
454
|
+
p1 = [e.source_event_id for e in s2.list_calendar_events(
|
|
455
|
+
subject_id="u1", limit=2, offset=0)]
|
|
456
|
+
p2 = [e.source_event_id for e in s2.list_calendar_events(
|
|
457
|
+
subject_id="u1", limit=2, offset=2)]
|
|
458
|
+
if set(p1) & set(p2) or len(set(p1) | set(p2)) != 4:
|
|
459
|
+
problems.append("⑫: list_calendar_events 的 offset 没有下推到存储")
|
|
460
|
+
|
|
461
|
+
s3 = new()
|
|
462
|
+
s3.upsert_reminders(subject_id="u1", items=[ReminderItemMirror(
|
|
463
|
+
subject_id="u1", source_account_id="a", source_list_id="l",
|
|
464
|
+
source_reminder_id=f"r{i}",
|
|
465
|
+
reminder_fields={"title": f"r{i}", "is_completed": False,
|
|
466
|
+
"due_at": base + timedelta(minutes=i)},
|
|
467
|
+
) for i in range(4)])
|
|
468
|
+
q1 = [r.source_reminder_id for r in s3.list_reminders(
|
|
469
|
+
subject_id="u1", limit=2, offset=0)]
|
|
470
|
+
q2 = [r.source_reminder_id for r in s3.list_reminders(
|
|
471
|
+
subject_id="u1", limit=2, offset=2)]
|
|
472
|
+
if set(q1) & set(q2) or len(set(q1) | set(q2)) != 4:
|
|
473
|
+
problems.append("⑫: list_reminders 的 offset 没有下推到存储")
|
|
474
|
+
return problems
|
|
475
|
+
|
|
476
|
+
|
|
386
477
|
GUARANTEES: dict[str, Callable[[StorageFactory], list[str]]] = {
|
|
387
478
|
"①上报与观测幂等": _g1_report_and_observation_idempotency,
|
|
388
479
|
"②旧数据不覆盖新当前值": _g2_old_does_not_overwrite_new,
|
|
@@ -395,6 +486,7 @@ GUARANTEES: dict[str, Callable[[StorageFactory], list[str]]] = {
|
|
|
395
486
|
"⑨清理不破坏永久聚合": _g9_retention_cleanup_spares_what_permanent_aggregates_need,
|
|
396
487
|
"⑩用户隔离与删除": _g10_subject_isolation_and_purge,
|
|
397
488
|
"⑪两个来源镜像都能往返": _g11_both_source_mirrors_round_trip,
|
|
489
|
+
"⑫终态可查与翻页下推": _g12_terminal_events_and_offsets_are_queryable,
|
|
398
490
|
}
|
|
399
491
|
|
|
400
492
|
#: 这几条在内存实现上**永远是绿的**,因为内存天然原子、天然无并发。
|
|
@@ -403,7 +495,7 @@ NOT_PROVABLE_IN_MEMORY: frozenset[str] = frozenset({"⑤提供原子边界"})
|
|
|
403
495
|
|
|
404
496
|
|
|
405
497
|
def run_storage_conformance(factory: StorageFactory) -> list[str]:
|
|
406
|
-
"""
|
|
498
|
+
"""跑全部十二条,返回问题清单(空 = 通过)。
|
|
407
499
|
|
|
408
500
|
返回列表而不是抛异常:一次看到全部缺口,比逐个修再重跑快得多。
|
|
409
501
|
"""
|
|
@@ -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
|
|
@@ -37,7 +38,7 @@ class PerceptionKit:
|
|
|
37
38
|
|
|
38
39
|
storage: StoragePort
|
|
39
40
|
wake: WakePort | None = None
|
|
40
|
-
#:
|
|
41
|
+
#: 信号声明。默认是 ``MINIMAL_SIGNALS``(23 个,覆盖四种存储形态);
|
|
41
42
|
#: 宿主应当传自己的完整 manifest。
|
|
42
43
|
signals: Mapping[str, SignalDefinition] = field(
|
|
43
44
|
default_factory=lambda: dict(MINIMAL_SIGNALS)
|
|
@@ -142,6 +143,57 @@ 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": k, "why": why} for k, why in plan.skipped],
|
|
194
|
+
"removed": removed,
|
|
195
|
+
}
|
|
196
|
+
|
|
145
197
|
def evaluate_absence(
|
|
146
198
|
self, *, subject_id: str, now: datetime,
|
|
147
199
|
) -> ScheduledOutcome:
|
|
@@ -286,12 +286,49 @@ def check_projections_do_not_drift(
|
|
|
286
286
|
)
|
|
287
287
|
return problems
|
|
288
288
|
|
|
289
|
+
def check_counting_strategies_can_actually_count(
|
|
290
|
+
signals: Mapping[str, SignalDefinition],
|
|
291
|
+
) -> list[str]:
|
|
292
|
+
"""取 max 的策略配上「取值范围只有一个数」的字段 —— 结果恒等于那个数。
|
|
293
|
+
|
|
294
|
+
这不是风格问题,是一个可以机械证明的矛盾,而它的表现是**一个安静的错数**:
|
|
295
|
+
|
|
296
|
+
photo_library_added.count valid_range=(1, 1) 一张照片一条 count=1
|
|
297
|
+
aggregation_strategy="daily_total"(取 max)
|
|
298
|
+
→ max(1, 1, 1, ...) = 1
|
|
299
|
+
→ "今天新增了几张照片" 永远答 1
|
|
300
|
+
|
|
301
|
+
照片那条聚合还是永久保存的,所以错的是一个永远不会自己修好的历史数字。
|
|
302
|
+
真实发生过:``app_usage.open_count`` 先踩了一次,教训写进了
|
|
303
|
+
``aggregate._STRATEGY_TO_SHAPE`` 的注释里,但隔壁的 ``photo_library_added``
|
|
304
|
+
没人回头看 —— 一条只写在注释里的纪律,保护不了下一个信号。
|
|
305
|
+
|
|
306
|
+
「每次事件贡献一份」用 ``occurrence_count``(求和)。
|
|
307
|
+
``daily_total`` / ``cumulative`` 是给**来源自己在数**的量用的
|
|
308
|
+
(今日步数 8000 → 8300,取 max 才对)。
|
|
309
|
+
"""
|
|
310
|
+
problems: list[str] = []
|
|
311
|
+
for key, sig in signals.items():
|
|
312
|
+
for f in sig.fields:
|
|
313
|
+
if f.aggregation_strategy not in ("daily_total", "cumulative"):
|
|
314
|
+
continue
|
|
315
|
+
rng = f.valid_range
|
|
316
|
+
if rng is None or rng[0] != rng[1]:
|
|
317
|
+
continue
|
|
318
|
+
problems.append(
|
|
319
|
+
f"{key}.{f.key}: valid_range={rng} 只允许一个值,"
|
|
320
|
+
f"而 {f.aggregation_strategy!r} 取当天最大值 —— 结果恒等于 {rng[0]}。"
|
|
321
|
+
f"每次事件各贡献一份的量要用 'occurrence_count'(求和)"
|
|
322
|
+
)
|
|
323
|
+
return problems
|
|
324
|
+
|
|
325
|
+
|
|
289
326
|
def validate_manifest(
|
|
290
327
|
signals: Mapping[str, SignalDefinition],
|
|
291
328
|
*,
|
|
292
329
|
available_normalizers: Iterable[str] = (),
|
|
293
330
|
) -> list[str]:
|
|
294
|
-
"""
|
|
331
|
+
"""跑全部六条检查(产品规范 §8 列的五条 + 一条计数策略自洽),返回问题清单(空 = 通过)。
|
|
295
332
|
|
|
296
333
|
返回列表而不是抛异常:一次看到全部缺口,比逐个修再重跑快得多。
|
|
297
334
|
"""
|
|
@@ -311,6 +348,7 @@ def validate_manifest(
|
|
|
311
348
|
)
|
|
312
349
|
problems += check_wake_eligible_fields_have_comparators(signals)
|
|
313
350
|
problems += check_projections_do_not_drift(signals)
|
|
351
|
+
problems += check_counting_strategies_can_actually_count(signals)
|
|
314
352
|
return problems
|
|
315
353
|
|
|
316
354
|
|
|
@@ -319,5 +357,6 @@ __all__ = [
|
|
|
319
357
|
"check_history_has_retention",
|
|
320
358
|
"check_named_implementations_exist",
|
|
321
359
|
"check_wake_eligible_fields_have_comparators",
|
|
360
|
+
"check_counting_strategies_can_actually_count",
|
|
322
361
|
"validate_manifest",
|
|
323
362
|
]
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""默认 manifest —— 23 个信号。
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
**它是怎么长到 23 个的。** 一开始只有五个:管线的正确性(幂等、乱序、TTL、
|
|
4
|
+
聚合重算、规则求值、投递可靠性)和信号数量无关,所以先用五个把管线跑通,
|
|
5
|
+
剩下的只是往表里填格子。管线验完之后按真机上真实出现的来源逐个补齐,
|
|
6
|
+
现在 23 个。改这个数字时**这一行也要跟着改** —— 一份说"五个"的文件
|
|
7
|
+
会让下一个接入的人(和下一个工程 AI)按五个去设计。
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
最早那五个之所以是那五个,是因为它们**恰好覆盖四种存储形态和三种身份策略**,
|
|
10
|
+
这个骨架现在仍然成立:
|
|
8
11
|
|
|
9
12
|
battery current_only · singleton
|
|
10
13
|
presence_recovery current_only · source_event_id · occurrence 事件
|
|
@@ -611,9 +614,12 @@ PHOTO_LIBRARY_ADDED = SignalDefinition(
|
|
|
611
614
|
"传输上仍然可以一个信封装多条,不多发请求。\n"
|
|
612
615
|
"🔴 删照片【不回减】过去某日的数量:它记的是「那天发生过什么」,"
|
|
613
616
|
"不是「现在还剩几张」。\n"
|
|
614
|
-
"
|
|
615
|
-
"
|
|
616
|
-
"
|
|
617
|
+
"身份必须由设备给一个稳定值:内容信封 id 是每次上传新生成的,用它的话"
|
|
618
|
+
"同一张照片重传就是两张,去重表再完美也挡不住。iOS 送的是"
|
|
619
|
+
"SHA256(固定 namespace + PHAsset.localIdentifier)——固定 namespace 而不是"
|
|
620
|
+
"wifi_anchor_id 那种设备本地随机密钥:照片 id 是本机相册的高熵 UUID,"
|
|
621
|
+
"别的设备上不存在,固定 namespace 就够不可逆;而且重装后仍然稳定,"
|
|
622
|
+
"正好让重扫相册时认出「这些都数过了」。\n"
|
|
617
623
|
"去重指纹的保留期:规范建议永久;我们查下来当前实现【找不到超过 7 天的"
|
|
618
624
|
"重放路径】,所以按「覆盖明细保留期 + 富余」取 30 天更实在。"
|
|
619
625
|
"规范自己也是条件句:「若 producer 可以在超过 7 天后重放,才必须永久保留」。"
|
|
@@ -626,7 +632,11 @@ PHOTO_LIBRARY_ADDED = SignalDefinition(
|
|
|
626
632
|
privacy_class="personal",
|
|
627
633
|
nullable=False,
|
|
628
634
|
valid_range=(1, 1), # 永远是 1 —— 一张照片一条
|
|
629
|
-
|
|
635
|
+
# 🔴 求和,不是取 max。每张照片各贡献一份 1,取 max 的话
|
|
636
|
+
# 「今天新增了几张」永远答 1,而这条聚合是永久保存的。
|
|
637
|
+
# app_usage.open_count 先踩过同一个坑;现在
|
|
638
|
+
# check_counting_strategies_can_actually_count 会拦住第三次。
|
|
639
|
+
aggregation_strategy="occurrence_count",
|
|
630
640
|
comparison_strategy="occurrence",
|
|
631
641
|
trend_model="fluctuating",
|
|
632
642
|
wake_eligible=True,
|