perceptkit 0.2.8__tar.gz → 0.4.0__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 (118) hide show
  1. {perceptkit-0.2.8 → perceptkit-0.4.0}/CHANGELOG.md +154 -0
  2. {perceptkit-0.2.8 → perceptkit-0.4.0}/PKG-INFO +6 -1
  3. {perceptkit-0.2.8 → perceptkit-0.4.0}/docs/reference-storage-mapping.md +52 -7
  4. {perceptkit-0.2.8 → perceptkit-0.4.0}/examples/ios_adapter.py +97 -8
  5. {perceptkit-0.2.8 → perceptkit-0.4.0}/pyproject.toml +16 -1
  6. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/algorithms/attribution.py +5 -0
  7. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/algorithms/history.py +5 -0
  8. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/algorithms/trend_models.py +9 -0
  9. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/catalog.py +32 -5
  10. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/conformance/memory.py +42 -5
  11. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/conformance/suite.py +108 -9
  12. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/contracts/records.py +21 -2
  13. perceptkit-0.4.0/src/perceptkit/contracts/retraction.py +77 -0
  14. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/kit.py +35 -1
  15. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/manifest/checks.py +6 -1
  16. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/manifest/minimal.py +124 -3
  17. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/ports/storage.py +58 -0
  18. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/processing/pipeline.py +4 -0
  19. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/processing/recompute.py +32 -0
  20. perceptkit-0.4.0/src/perceptkit/processing/retract.py +183 -0
  21. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/processing/source_sync.py +108 -8
  22. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/retention.py +79 -12
  23. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/fixtures/ios_snapshot_normal.json +19 -1
  24. perceptkit-0.4.0/tests/test_catalog.py +54 -0
  25. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_conformance.py +1 -1
  26. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_docs_match_code.py +28 -0
  27. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_export.py +2 -2
  28. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_ios_fixture.py +77 -3
  29. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_manifest.py +74 -0
  30. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_queries.py +4 -4
  31. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_recurrence.py +1 -1
  32. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_regressions.py +19 -10
  33. perceptkit-0.4.0/tests/test_retention.py +164 -0
  34. perceptkit-0.4.0/tests/test_retention_single_truth.py +80 -0
  35. perceptkit-0.4.0/tests/test_retraction.py +267 -0
  36. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_revision_recompute.py +2 -2
  37. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_scheduled.py +2 -2
  38. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_source_mirror.py +4 -4
  39. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_source_sync.py +194 -4
  40. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_trend_models.py +51 -11
  41. {perceptkit-0.2.8 → perceptkit-0.4.0}/uv.lock +1 -1
  42. perceptkit-0.2.8/tests/test_catalog.py +0 -21
  43. perceptkit-0.2.8/tests/test_retention.py +0 -97
  44. {perceptkit-0.2.8 → perceptkit-0.4.0}/.github/workflows/ci.yml +0 -0
  45. {perceptkit-0.2.8 → perceptkit-0.4.0}/.github/workflows/release.yml +0 -0
  46. {perceptkit-0.2.8 → perceptkit-0.4.0}/.gitignore +0 -0
  47. {perceptkit-0.2.8 → perceptkit-0.4.0}/LICENSE +0 -0
  48. {perceptkit-0.2.8 → perceptkit-0.4.0}/NOTES-packaging.md +0 -0
  49. {perceptkit-0.2.8 → perceptkit-0.4.0}/NOTES-quickstart.md +0 -0
  50. {perceptkit-0.2.8 → perceptkit-0.4.0}/README.md +0 -0
  51. {perceptkit-0.2.8 → perceptkit-0.4.0}/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
  52. {perceptkit-0.2.8 → perceptkit-0.4.0}/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
  53. {perceptkit-0.2.8 → perceptkit-0.4.0}/examples/end_to_end.py +0 -0
  54. {perceptkit-0.2.8 → perceptkit-0.4.0}/examples/quickstart.py +0 -0
  55. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/__init__.py +0 -0
  56. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/algorithms/__init__.py +0 -0
  57. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/algorithms/glance.py +0 -0
  58. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/algorithms/identity.py +0 -0
  59. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/algorithms/observation.py +0 -0
  60. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/algorithms/streaks.py +0 -0
  61. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/algorithms/wake.py +0 -0
  62. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/conformance/__init__.py +0 -0
  63. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/conformance/report.py +0 -0
  64. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/conformance/wake.py +0 -0
  65. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/contracts/__init__.py +0 -0
  66. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/contracts/_time.py +0 -0
  67. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/contracts/availability.py +0 -0
  68. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/contracts/context.py +0 -0
  69. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/contracts/delivery.py +0 -0
  70. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/contracts/errors.py +0 -0
  71. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/contracts/event.py +0 -0
  72. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/contracts/observation.py +0 -0
  73. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/contracts/receipt.py +0 -0
  74. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/contracts/report.py +0 -0
  75. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/contracts/versioning.py +0 -0
  76. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/fields.py +0 -0
  77. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/manifest/__init__.py +0 -0
  78. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/manifest/mapping.py +0 -0
  79. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/manifest/types.py +0 -0
  80. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/manifest/units.py +0 -0
  81. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/ports/__init__.py +0 -0
  82. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/ports/wake.py +0 -0
  83. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/processing/__init__.py +0 -0
  84. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/processing/aggregate.py +0 -0
  85. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/processing/dispatch.py +0 -0
  86. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/processing/normalize.py +0 -0
  87. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/processing/recurrence.py +0 -0
  88. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/processing/scheduled.py +0 -0
  89. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/prompts.py +0 -0
  90. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/queries/__init__.py +0 -0
  91. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/queries/api.py +0 -0
  92. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/rules/__init__.py +0 -0
  93. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/rules/engine.py +0 -0
  94. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/rules/evaluators.py +0 -0
  95. {perceptkit-0.2.8 → perceptkit-0.4.0}/src/perceptkit/rules/types.py +0 -0
  96. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/fixtures/README.md +0 -0
  97. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/fixtures/ios_snapshot_no_data.json +0 -0
  98. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/fixtures/ios_snapshot_unauthorized.json +0 -0
  99. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_attribution.py +0 -0
  100. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_conformance_wake_report.py +0 -0
  101. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_contracts.py +0 -0
  102. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_delivery_and_records.py +0 -0
  103. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_edge_cases.py +0 -0
  104. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_end_to_end.py +0 -0
  105. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_event_envelope.py +0 -0
  106. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_examples.py +0 -0
  107. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_identity.py +0 -0
  108. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_isolation.py +0 -0
  109. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_no_host_leakage.py +0 -0
  110. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_observation.py +0 -0
  111. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_pipeline.py +0 -0
  112. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_projection.py +0 -0
  113. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_purity.py +0 -0
  114. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_recompute.py +0 -0
  115. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_retention_entry.py +0 -0
  116. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_rules.py +0 -0
  117. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_streaks.py +0 -0
  118. {perceptkit-0.2.8 → perceptkit-0.4.0}/tests/test_wake.py +0 -0
@@ -1,5 +1,159 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.4.0 — 2026-09-06
4
+
5
+ **信号拆成单指标(破坏性)。** `health_body` / `health_vitals` /
6
+ `health_metabolic` 三个多指标信号拆成十二个,23 个信号 → 32 个。
7
+
8
+ health_body → health_weight / health_bmi / health_body_fat / health_height
9
+ health_vitals → health_resting_hr / health_hrv / health_respiratory
10
+ health_oxygen / health_vo2max / health_current_hr
11
+ health_metabolic → health_glucose / health_blood_pressure
12
+
13
+ **为什么必须拆**:保留期、身份策略、当前值有效期是**整个信号共用**的,
14
+ 而这些指标的生命周期本来就不一样。更要命的是逐条样本天然一次只带一个指标 ——
15
+ 实测:送一条新体重,同信号的 BMI 和体脂**从当前值里静默消失**。
16
+
17
+ **血压是唯一保留两个字段的**:来源侧它是一次读数(correlation),拆开就丢了
18
+ 「这是同一次量的」,撤回时两条各自被删、中间失败就留半条。
19
+
20
+ ### 拆分暴露的旧账(都已修)
21
+
22
+ - **四个指标存了明细但没人读**:身高、实时心率、呼吸率、血氧的字段都没有
23
+ 聚合策略,靠同信号的兄弟字段蒙混过了 manifest 校验。
24
+ - **呼吸率和血氧声明了趋势却读不到**:趋势是从日聚合读的,没聚合 →
25
+ 「最近血氧怎么样」永远是空的。现在给了 `numeric_dist`。
26
+ - **身高和实时心率本来就不该存历史**:改成 `current_only`。
27
+ - **manifest 校验器自己会崩**:`history_retention_days` 为 `None` 时拿去比
28
+ 大小抛 `TypeError` —— 校验器崩掉比漏报更糟,调用方拿到的是异常不是问题清单。
29
+
30
+
31
+ **来源撤回。** 用户在健康 app 里删掉一条记录之后,这边跟着不作数。
32
+
33
+ 之前:用户删掉那次难看的心率 → 我们不知道 → agent 继续说那个数
34
+ 之后:那条从当前值和趋势里消失,而"那天曾经有条记录、后来被删了"仍然答得出来
35
+
36
+ ### 修复(外部复核 2026-09-06 复现的两条,我也复现了)
37
+
38
+ - **tombstone 只按裸 id 删,会连坐同名的兄弟条目。** 同一个来源系统里两个
39
+ 账户各自的日历完全可能用同一个 event id —— 那是两件不同的事。用户删掉
40
+ 工作账户的一个会,私人日历里同 id 的安排**一起消失**,不可逆。
41
+ 改成结构化的 `DeletedItem`(account + collection + item),范围五段全比。
42
+ - **空 items 批次绕过 `collection_kind` 校验。** 校验写在
43
+ `if not items: return 0` 后面 = 永远走不到。一个空批次带着未知的种类能
44
+ 一路走完:什么都没写,但全量收尾照样执行、游标照样推进,下一轮增量
45
+ 以为上一轮成功了。**"什么都没发生"是最难发现的一种失败。**
46
+ - **一致性套件加第 ⑬ 条:删除只命中自己的范围。** 两种删除都不可逆,
47
+ 而且内存实现上很容易"看起来对" —— 真实存储要自己写 SQL,少一个 AND
48
+ 就是删过头,测试只放一条数据是发现不了的。所以每个宿主自己证明。
49
+
50
+ ### 新增
51
+
52
+ - `contracts.retraction.Retraction`、`StoragePort.record_retraction` /
53
+ `list_retractions`、`PerceptionKit.apply_retractions`。
54
+
55
+ - **刻意不做成 `availability` 的第四个状态。** 那个状态位回答的是
56
+ 「这次到底有没有拿到数」,撤回回答的是「之前那条还作不作数」——
57
+ 两个正交的问题。混在一起会同时坏两头:
58
+
59
+ 塞进去 `availability` 当初刻意避开的坑回来了(多一个非 observed
60
+ 状态要每个消费方记住,迟早有人漏)
61
+ 不塞 旧宿主把不认识的状态 normalize 成 unavailable,
62
+ 而 unavailable 的既有行为是**保留上一个可靠值当 last_known**
63
+ —— 被删掉的数值继续显示,正是撤回最不该发生的事
64
+
65
+ 走独立通道之后,没实现的宿主就是「从不撤回」(fail-closed)。
66
+
67
+ - **胜负规则是「观察即支配」,不用时间戳排序。** 来源的删除对象是临时的、
68
+ 客户端时钟可以被改、增量游标不透明不保证可比较 —— 拿这三样中任何一个
69
+ 排序,都会出现"先删后传的旧读数把当前值复活"。
70
+
71
+ ### 行为
72
+
73
+ - 撤回**不就地删除观测**,只是折聚合时不算它。抹掉的话「这天为什么少一块」
74
+ 再也答不出来。
75
+ - 当前值重选到剩下的观测里最新那条 `observed`;一条不剩写成没有有效样本,
76
+ **不是**留着被撤回的值。
77
+ - `purge_subject` 带上撤回记录 —— 漏一类就是删不干净。
78
+
79
+ ### 修复(外部复核第二轮,2026-09-06)
80
+
81
+ - 🔴 **上报键跟着信号一起拆了 —— 整包健康数据被判 unknown_signal 退回。**
82
+ 能力目录(`catalog.SIGNALS`)是按 **iOS 上报键**建索引的,客户端仍然按
83
+ HealthKit 的授权分组一次送 `health_vitals` / `health_body` /
84
+ `health_metabolic` 一整包。拆分时把这三个键也改成了拆完的名字,宿主
85
+ 查表查不到就整包退回,返回的还是 200 —— 客户端不重试,用户只会发现
86
+ 体征、体重、血压从某天起再也没更新过。**存储侧怎么拆和上报契约无关。**
87
+ 顺带:`step_count` 在这次误改里从目录中整个消失了。
88
+ - **主信号的字段被拆光时,仍然发一条 `observed` + 空值。** 只测了血压
89
+ 没测血糖的那趟上报,会替设备说一句「我看了血糖,结果是空」——
90
+ 下游当成一次真实测量:当前值被没有数值的记录顶掉、日聚合多算一次,
91
+ 而且不报错。现在这种情况不发主信号。
92
+ - 🔴 **四张声明表跟着拆了 —— 健康数据整类不再进历史,趋势和摘要一起变空。**
93
+ `history.SHAPE` / `attribution.ATTRIBUTION` / `trend_models.TREND_MODEL` /
94
+ `retention.RETENTION_DAYS` 按**上报键**建索引,唯一的消费方是宿主的
95
+ legacy 路径。拆完之后 `is_historized("health_vitals")` 返回 False,
96
+ 于是「我最近睡得比以前少吗」「体重趋势」全部读到空 —— **而且不报错**:
97
+ 调用方老老实实先问 `is_historized`,得到 False 就跳过,一切看起来正常。
98
+ 同一个根因(上报契约 ≠ 存储契约)的第三次发作,现在有测试专门盯着
99
+ 这四张表里有没有混进存储侧的信号名。
100
+
101
+ - **参考适配器(`examples/ios_adapter.py`)没有拆包。** 注释写着"见下",
102
+ 下面没有。照它接的宿主会把 BMI / 体脂 / 身高 / 血压跟着主信号一起送,
103
+ manifest 没声明这些字段 → 静默过滤掉。现在参考实现带完整的 `SPLIT_OFF`、
104
+ 单位换算(体脂 18.4% → 0.184)和血压合并,iOS 样本里也补了这三包数据,
105
+ 端到端跑进 kit 验收。
106
+ - **一致性第 ⑬ 条的撤回那半只放了一条数据。** 少写一个 `AND` 的实现照样
107
+ 全绿。现在每个范围维度都放一条"长得几乎一样"的兄弟数据:同 id 不同用户、
108
+ 同 id 不同信号、同 id 不同来源 —— 最后一个漏了就是连坐,撤 iOS 的一条,
109
+ Google 里同 id 的事实跟着没。
110
+
111
+ ## 0.3.0 — 2026-09-03
112
+
113
+ 外部审查(2026-09-03)里**不需要产品拍板的那批契约漏洞**。其中三条是审查者
114
+ 自己复现的,一条是我们没发现的双重真相。
115
+
116
+ ### ⚠️ 破坏性变更
117
+
118
+ - **``CalendarEventMirror`` / ``ReminderItemMirror`` 新增必填字段 ``source``。**
119
+ 它是唯一身份的一部分,不是标签 —— 少了它,一次 ``source="ios"`` 的全量同步
120
+ 会把概念上属于 Google 的日程一起删掉(快照收尾删的是"这轮没见到的",
121
+ 而另一个来源的条目当然没在这轮里)。用户会发现自己另一个日历账户的日程
122
+ 凭空消失,且不可逆。
123
+ 走 ``sync_source_mirror`` 的调用方不用改:kit 会用这一批声明的 ``source``
124
+ 盖上去,和 ``sync_id`` 同一个道理。
125
+ - **``StoragePort`` 新增 ``delete_source_items``**(见下)。
126
+
127
+ ### 修复
128
+
129
+ - **增量同步执行来源明确的删除(§8.1)。** 早先把增量定义成"一条都不许删",
130
+ 防住了"拿局部列表当全量",但同时堵死了另一条:来源的 change feed 明确
131
+ 传来一条删除时,那是**确定的事实**,不是推断。结果是用户在手机上删掉的
132
+ 日程,在 agent 眼里永远还在,还会一直出现在"接下来有什么安排"里。
133
+ ``SyncBatch.deleted_item_ids`` + ``StoragePort.delete_source_items``;
134
+ 和全量收尾的删除**分开计数**,否则分不清某次异常删除是范围判断出错
135
+ 还是来源真的删了。
136
+ - **来源进入镜像唯一身份(§8.2)。** 见上面的破坏性变更。
137
+ - **``collection_kind`` 与条目类型交叉校验(§8.3)。**
138
+ ``collection_kind="reminders"`` 配一批日历条目原本会被照单全收:日历表
139
+ 被写进去了,而**提醒的游标往前推进了** —— 数据和游标从此互相矛盾,
140
+ 下一轮增量提醒同步会以为上一轮成功了,那段提醒永远补不回来。
141
+ - **镜像条目的 subject 一律用可信上下文覆盖(§8.4)。** 条目是宿主从来源
142
+ 数据翻译出来的,它自带的 ``subject_id`` 最好的情况是冗余、最坏的情况是
143
+ 把 A 的日程写进 B 的花园。可信 subject 只有一个来源:``IngestContext``。
144
+ - **``retention_days()`` / ``stores_history()`` 改从 manifest 查(§9)。**
145
+ 它们原本读本模块顶上那张旧表,和 manifest **七条全对不上**:
146
+ ``focus_state`` 抛 KeyError(manifest 说 365)、``audio_route`` 返回 90
147
+ (manifest 说 7)。接入方和工程 AI 调公开 API 会拿到错的结论,而且没有
148
+ 任何地方报错 —— 两个真相各自自洽,只是不一样。
149
+ 旧名(``focus`` / ``playback`` / ``location_signal``)作为显式的、
150
+ 受测试的别名保留;那张旧表降级成历史记录,不再是任何查询的依据。
151
+
152
+ 🔴 ``retention_days()`` 对不进历史表的信号**仍然抛 ``KeyError``,
153
+ 不返回 ``None``** —— 这条早先的决策改用 manifest 之后理由更硬了:
154
+ 旧词表里 ``None`` 是「永久保存」,静默返回 ``None`` 会让照旧词表理解的
155
+ 调用方把「根本不存历史」读成「永久保留」,两个意思正好相反。
156
+
3
157
  ## 0.2.8 — 2026-09-02
4
158
 
5
159
  - **来源镜像的同步终于有了和 ``ingest()`` 对等的入口(P0-2)。**
@@ -1,7 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: perceptkit
3
- Version: 0.2.8
3
+ Version: 0.4.0
4
4
  Summary: 从设备信号判断:有没有发生值得留意的事、值不值得叫醒一次 agent、以及该怎么把此刻的状况讲给它听。不采集数据、不选数据库、不调模型 —— 存储由宿主实现 StoragePort,编排在包内。
5
+ Project-URL: Homepage, https://github.com/teleport-computer/perceptkit
6
+ Project-URL: Source, https://github.com/teleport-computer/perceptkit
7
+ Project-URL: Changelog, https://github.com/teleport-computer/perceptkit/blob/main/CHANGELOG.md
8
+ Project-URL: Issues, https://github.com/teleport-computer/perceptkit/issues
9
+ Author: Teleport Computer
5
10
  License:
6
11
  Apache License
7
12
  Version 2.0, January 2004
@@ -14,12 +14,21 @@
14
14
  | `broadcast` | CurrentProjection + StoredObservation | 300s | 7 天 | 同明细 | deterministic_digest | instant |
15
15
  | `focus_state` | CurrentProjection + StoredObservation + DailyAggregate | 900s | 365 天 | 永久 | deterministic_digest | instant |
16
16
  | `health_activity` | CurrentProjection + StoredObservation + DailyAggregate | 3600s | 永久 | 同明细 | source_event_id | source_local_date |
17
- | `health_body` | CurrentProjection + StoredObservation + DailyAggregate | 86400s | 永久 | 同明细 | source_event_id | instant |
17
+ | `health_blood_pressure` | CurrentProjection + StoredObservation + DailyAggregate | 86400s | 永久 | 同明细 | source_event_id | instant |
18
+ | `health_bmi` | CurrentProjection + StoredObservation + DailyAggregate | 86400s | 永久 | 同明细 | source_event_id | instant |
19
+ | `health_body_fat` | CurrentProjection + StoredObservation + DailyAggregate | 86400s | 永久 | 同明细 | source_event_id | instant |
20
+ | `health_current_hr` | CurrentProjection | 3600s | 不存 | 不适用 | source_event_id | instant |
18
21
  | `health_cycle` | CurrentProjection + StoredObservation + DailyAggregate | 86400s | 永久 | 同明细 | source_event_id | instant |
19
- | `health_metabolic` | CurrentProjection + StoredObservation + DailyAggregate | 86400s | 永久 | 同明细 | source_event_id | instant |
22
+ | `health_glucose` | CurrentProjection + StoredObservation + DailyAggregate | 86400s | 永久 | 同明细 | source_event_id | instant |
23
+ | `health_height` | CurrentProjection | 86400s | 不存 | 不适用 | source_event_id | instant |
24
+ | `health_hrv` | CurrentProjection + StoredObservation + DailyAggregate | 3600s | 永久 | 同明细 | source_event_id | instant |
20
25
  | `health_mood` | CurrentProjection + StoredObservation + DailyAggregate | 86400s | 永久 | 同明细 | source_event_id | instant |
26
+ | `health_oxygen` | CurrentProjection + StoredObservation + DailyAggregate | 3600s | 永久 | 同明细 | source_event_id | instant |
27
+ | `health_respiratory` | CurrentProjection + StoredObservation + DailyAggregate | 3600s | 永久 | 同明细 | source_event_id | instant |
28
+ | `health_resting_hr` | CurrentProjection + StoredObservation + DailyAggregate | 3600s | 永久 | 同明细 | source_event_id | instant |
21
29
  | `health_sleep` | CurrentProjection + StoredObservation + DailyAggregate | 86400s | 永久 | 同明细 | source_event_id | episode_end |
22
- | `health_vitals` | CurrentProjection + StoredObservation + DailyAggregate | 3600s | 永久 | 同明细 | source_event_id | instant |
30
+ | `health_vo2max` | CurrentProjection + StoredObservation + DailyAggregate | 3600s | 永久 | 同明细 | source_event_id | instant |
31
+ | `health_weight` | CurrentProjection + StoredObservation + DailyAggregate | 86400s | 永久 | 同明细 | source_event_id | instant |
23
32
  | `health_workout` | CurrentProjection + StoredObservation + DailyAggregate | 86400s | 永久 | 同明细 | source_event_id | episode_end |
24
33
  | `location_city` | CurrentProjection + StoredObservation + DailyAggregate | 900s | 永久 | 同明细 | deterministic_digest | instant |
25
34
  | `motion_state` | CurrentProjection + StoredObservation + DailyAggregate | 900s | 365 天 | 永久 | deterministic_digest | instant |
@@ -61,26 +70,62 @@ iOS 拿不到前台 app(`frontmost_app` 恒为 null),数据全靠用户在
61
70
 
62
71
  和 steps 同一种形态:日内单调累加,当天代表值取【最大值】不是求和。取最大值天然不怕跨天回退 —— 00:01 的新一天读数会归到新的一天,不会和昨天的数字相减产生负增量。
63
72
 
64
- ### `health_body`
73
+ ### `health_blood_pressure`
74
+
75
+ 🔴 收缩压和舒张压**留在同一个信号里**,因为来源侧它们是一次读数(HealthKit 建模成 correlation)。拆成两个信号就丢了「这是同一次量的」这个事实 —— 撤回时两条各自被删,中间任何一步失败就留下半条读数。
76
+
77
+ ### `health_bmi`
78
+
79
+ 通常由 app 从体重和身高算出来,不一定有独立的来源样本 —— 所以它可能拿不到稳定身份,撤回也就落不到它头上。
80
+
81
+ ### `health_body_fat`
65
82
 
66
83
  🔴 这一组是「用户改数据」最常发生的地方(体重录错、手动补录)——修订机制主要为它们服务。也是单位最容易标错的一组(kg / lb),所以 max_relative_jump 卡得比别的紧:70 kg 被标成 lb,换算完 31.8 kg 值域完全合法,只有「一次掉 55%」能看出不对。
67
84
 
85
+ ### `health_current_hr`
86
+
87
+ **这一个不走逐条样本。** 运动时每几秒一条,而它的语义就是「最近一次读数」——不是一条你会想删掉的测量记录。当日权威值那一档:同一天最新的查询结果赢。
88
+
68
89
  ### `health_cycle`
69
90
 
70
91
  周期型:看【间隔】不看数值高低 —— 「比平均晚了 4 天」才是信号。
71
92
 
72
- ### `health_metabolic`
93
+ ### `health_glucose`
94
+
95
+ 波动本来就大(餐前餐后能差一倍),所以不设跳变阈值 —— 设了会天天误报。
96
+
97
+ ### `health_height`
73
98
 
74
- 血糖本身波动就大(餐前餐后能差一倍),所以不设跳变阈值 —— 设了会天天误报。血压相对稳定,设一个宽的。
99
+ 几年才变一次,**不存历史**。挤在 health_body 里时它跟着存了明细,而它自己的字段没有聚合策略 —— 那些明细没有任何东西读得到,只是白占地方。拆开之后校验器直接把这条指出来了。
100
+
101
+ ### `health_hrv`
102
+
103
+ ⚠️ 建模方式和规范不同。规范用 metric + value + unit(一条观测一个指标),那需要「同一信号下多条并列当前值」的支持 —— 这个能力我们还没有(已记为已知缺口)。这里先按【每个指标一个字段】建模,和宿主现状一致,今天就能跑。等多维当前值做出来再切回规范的形态。
75
104
 
76
105
  ### `health_mood`
77
106
 
78
107
  用户自己记的,一天可能好几条 —— 所以是 event_list 不是取当天某一个值。
79
108
 
80
- ### `health_vitals`
109
+ ### `health_oxygen`
110
+
111
+ 同 health_respiratory:声明了趋势却没有聚合,趋势永远读到空。
112
+
113
+ ### `health_respiratory`
114
+
115
+ ⚠️ 拆分暴露的旧账:它在趋势表里声明了 fluctuating,但字段没有聚合策略,而趋势是从日聚合读的 —— 于是「最近呼吸率怎么样」永远读到空。挤在 health_vitals 里时靠兄弟字段蒙混过了 manifest 校验。
116
+
117
+ ### `health_resting_hr`
118
+
119
+ 一天测一次,是「一次测量」不是「当日代表值」—— 用户能指着某一次说「删掉它」。⚠️ 当前值有效期沿用了 health_vitals 的 1 小时,对一天一次的量偏短;改它是独立的产品决定,本次不动。
120
+
121
+ ### `health_vo2max`
81
122
 
82
123
  ⚠️ 建模方式和规范不同。规范用 metric + value + unit(一条观测一个指标),那需要「同一信号下多条并列当前值」的支持 —— 这个能力我们还没有(已记为已知缺口)。这里先按【每个指标一个字段】建模,和宿主现状一致,今天就能跑。等多维当前值做出来再切回规范的形态。
83
124
 
125
+ ### `health_weight`
126
+
127
+ 「用户改数据」最常发生的地方(录错、手动补录)——修订和撤回主要为它服务。也是单位最容易标错的:70 kg 被标成 lb,换算完 31.8 kg 值域完全合法,只有「一次掉 55%」能看出不对。
128
+
84
129
  ### `location_city`
85
130
 
86
131
  只有城市级。精细位置(home / work / 某个房间)是另一个信号(proximity_anchor),不能混进同一个字段 —— 两个时期都叫 home 就看不出搬过家。
@@ -33,12 +33,14 @@ KEY_TO_SIGNAL: dict[str, str] = {
33
33
  "audio_route": "audio_route",
34
34
  "weather": "weather",
35
35
  "playback": "music_playback",
36
- "health_vitals": "health_vitals",
36
+ # iOS 送的是一个打包的 health_vitals,kit 侧已经拆成单指标 ——
37
+ # 这里只认领**主**信号,其余字段由 SPLIT_OFF 分出去。
38
+ "health_vitals": "health_resting_hr",
37
39
  "health_sleep": "health_sleep",
38
40
  "health_workout": "health_workout",
39
41
  "health_activity": "health_activity",
40
- "health_body": "health_body",
41
- "health_metabolic": "health_metabolic",
42
+ "health_body": "health_weight",
43
+ "health_metabolic": "health_glucose",
42
44
  "health_cycle": "health_cycle",
43
45
  "health_mood": "health_mood",
44
46
  }
@@ -63,6 +65,17 @@ FIELD_ALIASES: dict[str, dict[str, str]] = {
63
65
  "apparent_temperature": "apparent_temperature_c",
64
66
  "humidity": "humidity_ratio",
65
67
  "precipitation_chance": "precipitation_probability"},
68
+ # 体脂:iOS 送百分比(18.4),manifest 声明的是 0~1 的比率。
69
+ # **只改名不换算**的话存进去是 18.4,区间检查会拒掉它,
70
+ # 然后体脂就再也没有到过 —— 一声不吭。换算见 _rename。
71
+ "health_weight": {"body_fat_pct": "body_fat_ratio"},
72
+ "health_glucose": {"blood_pressure_systolic": "blood_pressure_systolic_mmhg",
73
+ "blood_pressure_diastolic": "blood_pressure_diastolic_mmhg"},
74
+ }
75
+
76
+ #: 改名之外还要换算单位的字段(归一后的名字 -> 乘数)。
77
+ FIELD_SCALES: dict[str, dict[str, float]] = {
78
+ "health_weight": {"body_fat_ratio": 0.01},
66
79
  }
67
80
 
68
81
  #: manifest 里没有、但 iOS 会发的字段。**显式丢掉而不是让它悄悄被过滤** ——
@@ -75,6 +88,43 @@ DROPPED_FIELDS: dict[str, set[str]] = {
75
88
  }
76
89
 
77
90
 
91
+ #: 一个 iOS key 里的字段,分头去往**别的**信号。
92
+ #:
93
+ #: 2026-09-06 kit 把健康信号拆成了单指标,但 iOS 的上报契约没跟着拆 ——
94
+ #: 客户端仍然按 HealthKit 的授权分组一次报一整包。所以适配器要拆包。
95
+ #: 不拆的后果不是报错,是**静默丢失**:bmi / 体脂 / 身高会跟着
96
+ #: ``health_weight`` 一起送出去,manifest 里没声明这几个字段,
97
+ #: 管线把它们当未声明字段过滤掉,用户只会发现这些指标从来没有过数据。
98
+ #:
99
+ #: 键是 iOS key,值是 ``{归一后的字段名: (目标信号, 目标字段名)}``。
100
+ #: ⚠️ 写的是**归一之后**的名字(body_fat_ratio,不是 iOS 的 body_fat_pct)——
101
+ #: 拆分在改名之后做,顺序反了就永远匹配不上。
102
+ SPLIT_OFF: dict[str, dict[str, tuple[str, str]]] = {
103
+ "health_vitals": {
104
+ "step_count": ("steps", "step_count"),
105
+ "current_heart_rate": ("health_current_hr", "current_heart_rate"),
106
+ "hrv_sdnn_ms": ("health_hrv", "hrv_sdnn_ms"),
107
+ "respiratory_rate": ("health_respiratory", "respiratory_rate"),
108
+ "oxygen_saturation_pct": ("health_oxygen", "oxygen_saturation_pct"),
109
+ "vo2_max": ("health_vo2max", "vo2_max"),
110
+ },
111
+ "health_body": {
112
+ "bmi": ("health_bmi", "bmi"),
113
+ "body_fat_ratio": ("health_body_fat", "body_fat_ratio"),
114
+ "height_cm": ("health_height", "height_cm"),
115
+ },
116
+ "health_metabolic": {
117
+ # 收缩压和舒张压去**同一个**信号:来源侧它们是一次读数
118
+ # (HealthKit correlation)。拆成两条观测就丢了「这是同一次量的」,
119
+ # 而且后一条会把前一条的当前值顶掉、只剩半个读数。
120
+ "blood_pressure_systolic_mmhg":
121
+ ("health_blood_pressure", "blood_pressure_systolic_mmhg"),
122
+ "blood_pressure_diastolic_mmhg":
123
+ ("health_blood_pressure", "blood_pressure_diastolic_mmhg"),
124
+ },
125
+ }
126
+
127
+
78
128
  #: 有些信号**把授权状态放在 data 里的一个字段上**,而不是用 `data: null`。
79
129
  #: focus 就是这样:`{"authorization_status": "denied", "focused": null}`。
80
130
  #: 不认这一条的话,它会被当成 observed,然后因为必填字段是 null 被管线拒收 ——
@@ -109,11 +159,16 @@ def _rename(signal: str, value: Mapping[str, Any]) -> dict[str, Any]:
109
159
  """
110
160
  alias = FIELD_ALIASES.get(signal, {})
111
161
  dropped = DROPPED_FIELDS.get(signal, set())
162
+ scales = FIELD_SCALES.get(signal, {})
112
163
  out: dict[str, Any] = {}
113
164
  for k, v in value.items():
114
165
  if v is None or k in dropped:
115
166
  continue
116
- out[alias.get(k, k)] = v
167
+ name = alias.get(k, k)
168
+ factor = scales.get(name)
169
+ if factor is not None and isinstance(v, (int, float)) and not isinstance(v, bool):
170
+ v = v * factor
171
+ out[name] = v
117
172
  return out
118
173
 
119
174
 
@@ -155,9 +210,42 @@ def to_envelope(payload: Mapping[str, Any], *, occurred_at: str) -> dict[str, An
155
210
  "occurred_at": occurred_at,
156
211
  "availability": availability,
157
212
  }
213
+ # 先改名 / 换算,**再**按归一后的名字拆 —— 顺序反了的话
214
+ # SPLIT_OFF 里的 body_fat_ratio 永远匹配不上 iOS 的 body_fat_pct。
215
+ normalized = _rename(signal, data) if isinstance(data, Mapping) else {}
216
+ moved = set(SPLIT_OFF.get(key, {}))
217
+ emit_main = True
158
218
  if availability == "observed" and isinstance(data, Mapping):
159
- obs["value"] = _rename(signal, data)
160
- observations.append(obs)
219
+ value = {k2: v2 for k2, v2 in normalized.items() if k2 not in moved}
220
+ if moved and not value:
221
+ # 这趟上报里主信号的字段**一个都不剩**(只测了血压、
222
+ # 没测血糖)。照旧发一条 observed + 空 value,等于替设备
223
+ # 说了句「我看了血糖,结果是空」—— 下游会当成一次真实
224
+ # 测量:当前值被没有数值的记录顶掉、日聚合多算一次。
225
+ emit_main = False
226
+ obs["value"] = value
227
+ if emit_main:
228
+ observations.append(obs)
229
+
230
+ # 拆出去的字段:按**目标信号**分组再发,血压那两个字段必须
231
+ # 落进同一条观测。
232
+ grouped: dict[str, dict[str, Any]] = {}
233
+ for src, (target, field) in SPLIT_OFF.get(key, {}).items():
234
+ raw = normalized.get(src)
235
+ if raw is None:
236
+ # 没有就是没有。补一条 no_data 等于说「设备报告了它没走路」,
237
+ # 那是另一句话。
238
+ continue
239
+ grouped.setdefault(target, {})[field] = raw
240
+ for target, value in grouped.items():
241
+ split_obs: dict[str, Any] = {
242
+ "signal": target,
243
+ "signal_schema_version": 1,
244
+ "occurred_at": occurred_at,
245
+ "availability": "observed",
246
+ "value": value,
247
+ }
248
+ observations.append(split_obs)
161
249
 
162
250
  return {
163
251
  "schema_version": 1,
@@ -167,5 +255,6 @@ def to_envelope(payload: Mapping[str, Any], *, occurred_at: str) -> dict[str, An
167
255
  }
168
256
 
169
257
 
170
- __all__ = ["KEY_TO_SIGNAL", "IGNORED_KEYS", "FIELD_ALIASES", "DROPPED_FIELDS",
171
- "AUTH_STATUS_FIELDS", "AUTHORIZED_VALUES", "report_id_for", "to_envelope"]
258
+ __all__ = ["KEY_TO_SIGNAL", "IGNORED_KEYS", "FIELD_ALIASES", "FIELD_SCALES",
259
+ "DROPPED_FIELDS", "SPLIT_OFF", "AUTH_STATUS_FIELDS",
260
+ "AUTHORIZED_VALUES", "report_id_for", "to_envelope"]
@@ -4,16 +4,31 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "perceptkit"
7
- version = "0.2.8"
7
+ version = "0.4.0"
8
8
  description = "从设备信号判断:有没有发生值得留意的事、值不值得叫醒一次 agent、以及该怎么把此刻的状况讲给它听。不采集数据、不选数据库、不调模型 —— 存储由宿主实现 StoragePort,编排在包内。"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = { file = "LICENSE" }
12
12
  keywords = ["perception", "agent", "proactive", "wake", "context"]
13
+ authors = [{ name = "Teleport Computer" }]
13
14
 
14
15
  # 零依赖:全部标准库。tests/test_purity.py 用 AST 扫描盯着这条,别手改这里而不更新那条测试。
15
16
  dependencies = []
16
17
 
18
+ # 🔴 这几个链接不是装饰。没有它们,PyPI 页面上看不出这个包属于谁、仓库在哪 ——
19
+ # 2026-09-03 真的有人据此判断成"私有仓、还没发布",而它一直是公开的、
20
+ # 八个版本都在 PyPI 上。接入方(或工程 AI)从包页面得不到源头就只能猜,
21
+ # 而猜出来的结论会被当成事实往下传。
22
+ #
23
+ # ⚠️ TOML:表头之后的所有键值都归那个表。这一段必须放在 [project] 的
24
+ # 全部键值之后 —— 插在中间会把后面的 `dependencies` 吸进 urls 里,
25
+ # 零依赖声明就静默消失了(写这段时真踩到)。
26
+ [project.urls]
27
+ Homepage = "https://github.com/teleport-computer/perceptkit"
28
+ Source = "https://github.com/teleport-computer/perceptkit"
29
+ Changelog = "https://github.com/teleport-computer/perceptkit/blob/main/CHANGELOG.md"
30
+ Issues = "https://github.com/teleport-computer/perceptkit/issues"
31
+
17
32
  [project.optional-dependencies]
18
33
  dev = ["pytest>=8.0"]
19
34
 
@@ -22,6 +22,11 @@ EPISODE_END = "episode_end" # 区间:整体归结束(醒来
22
22
  SPLIT_AT_MIDNIGHT = "split_at_midnight" # 可加总时长:按本地午夜切分
23
23
  SOURCE_LOCAL_DATE = "source_local_date" # 周期事件:用来源记录的本地日期,不重解释
24
24
 
25
+ # ⚠️ 这张表按 **iOS 上报键**(宿主 legacy 路径问的那个名字)建索引,
26
+ # 不是 manifest 的信号名。2026-09-06 把健康信号拆成单指标时,这张表也跟着
27
+ # 拆过一次 —— 而它唯一的消费方就是 legacy 路径,拆完之后
28
+ # `is_historized("health_vitals")` 直接返回 False,健康数据整类不再进历史,
29
+ # 趋势和摘要一起变空,**不报错**。存储侧怎么拆看 manifest,跟这张表无关。
25
30
  ATTRIBUTION: dict[str, str] = {
26
31
  "health_sleep": EPISODE_END,
27
32
  "health_workout": EPISODE_END,
@@ -48,6 +48,11 @@ _TALLY_CAP = 30 # keep only the top-N artists/tracks per da
48
48
  # Signal (canonical catalog input key) -> shape. ONE line per signal; fields are
49
49
  # discovered from the observation. Signals absent here are NOT historized
50
50
  # (pure-instant / no daily pattern: time, battery, broadcast, now, app).
51
+ # ⚠️ 这张表按 **iOS 上报键**(宿主 legacy 路径问的那个名字)建索引,
52
+ # 不是 manifest 的信号名。2026-09-06 把健康信号拆成单指标时,这张表也跟着
53
+ # 拆过一次 —— 而它唯一的消费方就是 legacy 路径,拆完之后
54
+ # `is_historized("health_vitals")` 直接返回 False,健康数据整类不再进历史,
55
+ # 趋势和摘要一起变空,**不报错**。存储侧怎么拆看 manifest,跟这张表无关。
51
56
  SHAPE: dict[str, str] = {
52
57
  "health_vitals": NUMERIC_DIST,
53
58
  "health_metabolic": NUMERIC_DIST,
@@ -17,6 +17,11 @@ DRIFTING = "drifting" # 没有「平时水平」,方向与速率才是
17
17
  CYCLICAL = "cyclical" # 看间隔,不看数值高低
18
18
 
19
19
  # signal -> 模型。未列出的信号沿用波动型(现有 read_trend 的行为)。
20
+ # ⚠️ 这张表按 **iOS 上报键**(宿主 legacy 路径问的那个名字)建索引,
21
+ # 不是 manifest 的信号名。2026-09-06 把健康信号拆成单指标时,这张表也跟着
22
+ # 拆过一次 —— 而它唯一的消费方就是 legacy 路径,拆完之后
23
+ # `is_historized("health_vitals")` 直接返回 False,健康数据整类不再进历史,
24
+ # 趋势和摘要一起变空,**不报错**。存储侧怎么拆看 manifest,跟这张表无关。
20
25
  TREND_MODEL: dict[str, str] = {
21
26
  "health_sleep": FLUCTUATING,
22
27
  "health_vitals": FLUCTUATING,
@@ -40,6 +45,10 @@ QUERY_ONLY: frozenset[str] = frozenset({
40
45
  # 一个字段;而 health_vitals 整体是 FLUCTUATING 且不在 QUERY_ONLY 里,
41
46
  # 单看"有模型 + 不在 QUERY_ONLY"会让当前心率被判定成可以叫醒——但它每次
42
47
  # 心跳都在变,跟血糖血压一样缺采样协议,不该拿来触发主动打扰。
48
+ #
49
+ # (2026-09-06 一度以为拆分让这条例外消失、把表清空了。清空是错的:这张
50
+ # 表说的是**上报键**里的字段,而上报契约没拆 —— health_vitals 仍然一次
51
+ # 带着当前心率一起上来。)
43
52
  # 存 (signal, field) 二元组,不是单独一张 field 名单:同名字段换了信号
44
53
  # 语境可能就该叫醒,必须连着信号一起认。
45
54
  QUERY_ONLY_FIELDS: frozenset[tuple[str, str]] = frozenset({
@@ -81,10 +81,30 @@ CAPABILITIES: dict[str, Capability] = {c.key: c for c in [
81
81
  Capability("reminders", "提醒事项", 2, query_tool=True),
82
82
  Capability("health_sleep", "睡眠", 2, query_tool=True),
83
83
  Capability("health_workout", "运动", 2, query_tool=True),
84
- Capability("health_vitals", "身体趋势", 2, query_tool=True),
84
+ # 2026-09-06 拆成单指标。**能力表分两种角色,别混**:
85
+ # 报告闸 客户端按 HealthKit 的授权分组一次报一整包(体征、身体测量、
86
+ # 代谢),闸就得按包来 —— 下面三个 health_vitals / health_body /
87
+ # health_metabolic 是这个用途,SIGNALS 里三条上报键指向它们。
88
+ # 查询档 agent 按**单个指标**问("我最近血氧怎么样"),所以查询工具
89
+ # 按拆完的指标各占一行。
90
+ # 拆分只发生在第二种上。把第一种一并拆掉的后果是上报键在 SIGNALS 里
91
+ # 查不到 → 整包体征被判 unknown_signal 退回,而客户端不会报错。
92
+ Capability("health_vitals", "身体趋势", 2),
93
+ Capability("health_body", "身体测量(体重/BMI/体脂/身高)", 2),
94
+ Capability("health_metabolic", "代谢点值(血糖/血压)", 2),
95
+ Capability("health_resting_hr", "静息心率", 2, query_tool=True),
96
+ Capability("health_current_hr", "实时心率", 2, query_tool=True),
97
+ Capability("health_hrv", "心率变异性", 2, query_tool=True),
98
+ Capability("health_respiratory", "呼吸率", 2, query_tool=True),
99
+ Capability("health_oxygen", "血氧", 2, query_tool=True),
100
+ Capability("health_vo2max", "最大摄氧量", 2, query_tool=True),
85
101
  Capability("health_activity", "活动量(能量/锻炼/站立/正念)", 2, query_tool=True),
86
- Capability("health_body", "身体测量(体重/BMI/体脂/身高)", 2, query_tool=True),
87
- Capability("health_metabolic", "代谢点值(血糖/血压)", 2, query_tool=True),
102
+ Capability("health_weight", "体重", 2, query_tool=True),
103
+ Capability("health_bmi", "BMI", 2, query_tool=True),
104
+ Capability("health_body_fat", "体脂率", 2, query_tool=True),
105
+ Capability("health_height", "身高", 2, query_tool=True),
106
+ Capability("health_glucose", "血糖", 2, query_tool=True),
107
+ Capability("health_blood_pressure", "血压", 2, query_tool=True),
88
108
  Capability("health_cycle", "经期", 2, query_tool=True),
89
109
  Capability("health_mood", "心情 / State of Mind", 2, query_tool=True),
90
110
  ]}
@@ -130,6 +150,12 @@ SIGNALS: dict[str, Signal] = {s.input: s for s in [
130
150
  resolver="health_sleep", ttl_sec=86400.0, significant=False),
131
151
  Signal("health_workout", "health_workout", ("workout_type", "duration_min", "count_today"),
132
152
  resolver="health_workout", ttl_sec=86400.0, significant=False),
153
+ # ⚠️ 这张表是按 **iOS 上报键**(context_snapshot 的 `key`)建索引的 ——
154
+ # 客户端一次报一整包,键名是 health_vitals / health_body / health_metabolic。
155
+ # 2026-09-06 把**存储侧**的信号拆成了单指标(见 manifest.minimal),
156
+ # 但**上报契约没变**,所以这里保持原样。曾经把这三条也改成拆完的名字,
157
+ # 结果是这三个上报键在表里查不到 → service 判 unknown_signal 整包退回,
158
+ # 客户端拿到 200、用户什么都不知道。存储侧怎么拆,看宿主的 SPLIT_OFF。
133
159
  Signal("health_vitals", "health_vitals",
134
160
  ("resting_heart_rate", "step_count", "current_heart_rate", "hrv_sdnn_ms",
135
161
  "respiratory_rate", "oxygen_saturation_pct", "vo2_max"),
@@ -141,7 +167,8 @@ SIGNALS: dict[str, Signal] = {s.input: s for s in [
141
167
  ("weight_kg", "bmi", "body_fat_pct", "height_cm"),
142
168
  ttl_sec=86400.0, significant=False),
143
169
  Signal("health_metabolic", "health_metabolic",
144
- ("blood_glucose_mmol_l", "blood_pressure_systolic", "blood_pressure_diastolic"),
170
+ ("blood_glucose_mmol_l", "blood_pressure_systolic",
171
+ "blood_pressure_diastolic"),
145
172
  ttl_sec=86400.0, significant=False),
146
173
  Signal("health_cycle", "health_cycle",
147
174
  ("flow_level", "is_active_period"),
@@ -196,7 +223,7 @@ COMPOSITE_KEYS: dict[str, list[str]] = {}
196
223
  KIND_CAPABILITY = {
197
224
  "workout": "health_workout",
198
225
  "sleep": "health_sleep",
199
- "vitals": "health_vitals",
226
+ "vitals": "health_resting_hr",
200
227
  }
201
228
 
202
229
  # Burst de-dup backstop. Clustering is primarily done ON DEVICE (iOS collapses a