perceptkit 0.2.8__tar.gz → 0.3.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 (114) hide show
  1. {perceptkit-0.2.8 → perceptkit-0.3.0}/CHANGELOG.md +46 -0
  2. {perceptkit-0.2.8 → perceptkit-0.3.0}/PKG-INFO +1 -1
  3. {perceptkit-0.2.8 → perceptkit-0.3.0}/pyproject.toml +1 -1
  4. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/conformance/memory.py +22 -5
  5. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/conformance/suite.py +7 -7
  6. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/records.py +11 -2
  7. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/ports/storage.py +21 -0
  8. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/processing/source_sync.py +79 -7
  9. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/retention.py +62 -9
  10. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_export.py +2 -2
  11. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_queries.py +2 -2
  12. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_recurrence.py +1 -1
  13. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_regressions.py +2 -2
  14. perceptkit-0.3.0/tests/test_retention.py +132 -0
  15. perceptkit-0.3.0/tests/test_retention_single_truth.py +80 -0
  16. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_source_mirror.py +4 -4
  17. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_source_sync.py +137 -3
  18. {perceptkit-0.2.8 → perceptkit-0.3.0}/uv.lock +2 -2
  19. perceptkit-0.2.8/tests/test_retention.py +0 -97
  20. {perceptkit-0.2.8 → perceptkit-0.3.0}/.github/workflows/ci.yml +0 -0
  21. {perceptkit-0.2.8 → perceptkit-0.3.0}/.github/workflows/release.yml +0 -0
  22. {perceptkit-0.2.8 → perceptkit-0.3.0}/.gitignore +0 -0
  23. {perceptkit-0.2.8 → perceptkit-0.3.0}/LICENSE +0 -0
  24. {perceptkit-0.2.8 → perceptkit-0.3.0}/NOTES-packaging.md +0 -0
  25. {perceptkit-0.2.8 → perceptkit-0.3.0}/NOTES-quickstart.md +0 -0
  26. {perceptkit-0.2.8 → perceptkit-0.3.0}/README.md +0 -0
  27. {perceptkit-0.2.8 → perceptkit-0.3.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
  28. {perceptkit-0.2.8 → perceptkit-0.3.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
  29. {perceptkit-0.2.8 → perceptkit-0.3.0}/docs/reference-storage-mapping.md +0 -0
  30. {perceptkit-0.2.8 → perceptkit-0.3.0}/examples/end_to_end.py +0 -0
  31. {perceptkit-0.2.8 → perceptkit-0.3.0}/examples/ios_adapter.py +0 -0
  32. {perceptkit-0.2.8 → perceptkit-0.3.0}/examples/quickstart.py +0 -0
  33. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/__init__.py +0 -0
  34. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/algorithms/__init__.py +0 -0
  35. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/algorithms/attribution.py +0 -0
  36. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/algorithms/glance.py +0 -0
  37. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/algorithms/history.py +0 -0
  38. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/algorithms/identity.py +0 -0
  39. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/algorithms/observation.py +0 -0
  40. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/algorithms/streaks.py +0 -0
  41. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/algorithms/trend_models.py +0 -0
  42. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/algorithms/wake.py +0 -0
  43. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/catalog.py +0 -0
  44. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/conformance/__init__.py +0 -0
  45. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/conformance/report.py +0 -0
  46. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/conformance/wake.py +0 -0
  47. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/__init__.py +0 -0
  48. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/_time.py +0 -0
  49. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/availability.py +0 -0
  50. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/context.py +0 -0
  51. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/delivery.py +0 -0
  52. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/errors.py +0 -0
  53. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/event.py +0 -0
  54. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/observation.py +0 -0
  55. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/receipt.py +0 -0
  56. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/report.py +0 -0
  57. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/contracts/versioning.py +0 -0
  58. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/fields.py +0 -0
  59. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/kit.py +0 -0
  60. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/manifest/__init__.py +0 -0
  61. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/manifest/checks.py +0 -0
  62. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/manifest/mapping.py +0 -0
  63. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/manifest/minimal.py +0 -0
  64. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/manifest/types.py +0 -0
  65. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/manifest/units.py +0 -0
  66. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/ports/__init__.py +0 -0
  67. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/ports/wake.py +0 -0
  68. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/processing/__init__.py +0 -0
  69. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/processing/aggregate.py +0 -0
  70. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/processing/dispatch.py +0 -0
  71. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/processing/normalize.py +0 -0
  72. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/processing/pipeline.py +0 -0
  73. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/processing/recompute.py +0 -0
  74. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/processing/recurrence.py +0 -0
  75. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/processing/scheduled.py +0 -0
  76. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/prompts.py +0 -0
  77. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/queries/__init__.py +0 -0
  78. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/queries/api.py +0 -0
  79. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/rules/__init__.py +0 -0
  80. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/rules/engine.py +0 -0
  81. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/rules/evaluators.py +0 -0
  82. {perceptkit-0.2.8 → perceptkit-0.3.0}/src/perceptkit/rules/types.py +0 -0
  83. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/fixtures/README.md +0 -0
  84. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/fixtures/ios_snapshot_no_data.json +0 -0
  85. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/fixtures/ios_snapshot_normal.json +0 -0
  86. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/fixtures/ios_snapshot_unauthorized.json +0 -0
  87. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_attribution.py +0 -0
  88. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_catalog.py +0 -0
  89. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_conformance.py +0 -0
  90. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_conformance_wake_report.py +0 -0
  91. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_contracts.py +0 -0
  92. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_delivery_and_records.py +0 -0
  93. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_docs_match_code.py +0 -0
  94. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_edge_cases.py +0 -0
  95. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_end_to_end.py +0 -0
  96. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_event_envelope.py +0 -0
  97. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_examples.py +0 -0
  98. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_identity.py +0 -0
  99. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_ios_fixture.py +0 -0
  100. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_isolation.py +0 -0
  101. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_manifest.py +0 -0
  102. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_no_host_leakage.py +0 -0
  103. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_observation.py +0 -0
  104. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_pipeline.py +0 -0
  105. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_projection.py +0 -0
  106. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_purity.py +0 -0
  107. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_recompute.py +0 -0
  108. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_retention_entry.py +0 -0
  109. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_revision_recompute.py +0 -0
  110. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_rules.py +0 -0
  111. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_scheduled.py +0 -0
  112. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_streaks.py +0 -0
  113. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_trend_models.py +0 -0
  114. {perceptkit-0.2.8 → perceptkit-0.3.0}/tests/test_wake.py +0 -0
@@ -1,5 +1,51 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.3.0 — 2026-09-03
4
+
5
+ 外部审查(2026-09-03)里**不需要产品拍板的那批契约漏洞**。其中三条是审查者
6
+ 自己复现的,一条是我们没发现的双重真相。
7
+
8
+ ### ⚠️ 破坏性变更
9
+
10
+ - **``CalendarEventMirror`` / ``ReminderItemMirror`` 新增必填字段 ``source``。**
11
+ 它是唯一身份的一部分,不是标签 —— 少了它,一次 ``source="ios"`` 的全量同步
12
+ 会把概念上属于 Google 的日程一起删掉(快照收尾删的是"这轮没见到的",
13
+ 而另一个来源的条目当然没在这轮里)。用户会发现自己另一个日历账户的日程
14
+ 凭空消失,且不可逆。
15
+ 走 ``sync_source_mirror`` 的调用方不用改:kit 会用这一批声明的 ``source``
16
+ 盖上去,和 ``sync_id`` 同一个道理。
17
+ - **``StoragePort`` 新增 ``delete_source_items``**(见下)。
18
+
19
+ ### 修复
20
+
21
+ - **增量同步执行来源明确的删除(§8.1)。** 早先把增量定义成"一条都不许删",
22
+ 防住了"拿局部列表当全量",但同时堵死了另一条:来源的 change feed 明确
23
+ 传来一条删除时,那是**确定的事实**,不是推断。结果是用户在手机上删掉的
24
+ 日程,在 agent 眼里永远还在,还会一直出现在"接下来有什么安排"里。
25
+ ``SyncBatch.deleted_item_ids`` + ``StoragePort.delete_source_items``;
26
+ 和全量收尾的删除**分开计数**,否则分不清某次异常删除是范围判断出错
27
+ 还是来源真的删了。
28
+ - **来源进入镜像唯一身份(§8.2)。** 见上面的破坏性变更。
29
+ - **``collection_kind`` 与条目类型交叉校验(§8.3)。**
30
+ ``collection_kind="reminders"`` 配一批日历条目原本会被照单全收:日历表
31
+ 被写进去了,而**提醒的游标往前推进了** —— 数据和游标从此互相矛盾,
32
+ 下一轮增量提醒同步会以为上一轮成功了,那段提醒永远补不回来。
33
+ - **镜像条目的 subject 一律用可信上下文覆盖(§8.4)。** 条目是宿主从来源
34
+ 数据翻译出来的,它自带的 ``subject_id`` 最好的情况是冗余、最坏的情况是
35
+ 把 A 的日程写进 B 的花园。可信 subject 只有一个来源:``IngestContext``。
36
+ - **``retention_days()`` / ``stores_history()`` 改从 manifest 查(§9)。**
37
+ 它们原本读本模块顶上那张旧表,和 manifest **七条全对不上**:
38
+ ``focus_state`` 抛 KeyError(manifest 说 365)、``audio_route`` 返回 90
39
+ (manifest 说 7)。接入方和工程 AI 调公开 API 会拿到错的结论,而且没有
40
+ 任何地方报错 —— 两个真相各自自洽,只是不一样。
41
+ 旧名(``focus`` / ``playback`` / ``location_signal``)作为显式的、
42
+ 受测试的别名保留;那张旧表降级成历史记录,不再是任何查询的依据。
43
+
44
+ 🔴 ``retention_days()`` 对不进历史表的信号**仍然抛 ``KeyError``,
45
+ 不返回 ``None``** —— 这条早先的决策改用 manifest 之后理由更硬了:
46
+ 旧词表里 ``None`` 是「永久保存」,静默返回 ``None`` 会让照旧词表理解的
47
+ 调用方把「根本不存历史」读成「永久保留」,两个意思正好相反。
48
+
3
49
  ## 0.2.8 — 2026-09-02
4
50
 
5
51
  - **来源镜像的同步终于有了和 ``ingest()`` 对等的入口(P0-2)。**
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: perceptkit
3
- Version: 0.2.8
3
+ Version: 0.3.0
4
4
  Summary: 从设备信号判断:有没有发生值得留意的事、值不值得叫醒一次 agent、以及该怎么把此刻的状况讲给它听。不采集数据、不选数据库、不调模型 —— 存储由宿主实现 StoragePort,编排在包内。
5
5
  License:
6
6
  Apache License
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "perceptkit"
7
- version = "0.2.8"
7
+ version = "0.3.0"
8
8
  description = "从设备信号判断:有没有发生值得留意的事、值不值得叫醒一次 agent、以及该怎么把此刻的状况讲给它听。不采集数据、不选数据库、不调模型 —— 存储由宿主实现 StoragePort,编排在包内。"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -196,14 +196,16 @@ class InMemoryStorage:
196
196
  self.sync_state[(state.subject_id, state.source, state.collection_kind)] = state
197
197
 
198
198
  def upsert_calendar_events(self, *, subject_id, events) -> None:
199
+ # 键里带 source —— 少了它,两个来源系统里碰巧同 id 的日程会互相覆盖,
200
+ # 而全量同步还会把另一个来源的条目当成"这轮没见到"删掉。
199
201
  for e in events:
200
- self.calendar[(subject_id, e.source_account_id, e.source_calendar_id,
201
- e.source_event_id)] = e
202
+ self.calendar[(subject_id, e.source, e.source_account_id,
203
+ e.source_calendar_id, e.source_event_id)] = e
202
204
 
203
205
  def upsert_reminders(self, *, subject_id, items) -> None:
204
206
  for r in items:
205
- self.reminders[(subject_id, r.source_account_id, r.source_list_id,
206
- r.source_reminder_id)] = r
207
+ self.reminders[(subject_id, r.source, r.source_account_id,
208
+ r.source_list_id, r.source_reminder_id)] = r
207
209
 
208
210
  def list_calendar_events(self, *, subject_id, start=None, end=None,
209
211
  limit=50, offset=0):
@@ -235,6 +237,17 @@ class InMemoryStorage:
235
237
  i.source_reminder_id))
236
238
  return keep[offset:offset + limit]
237
239
 
240
+ def delete_source_items(self, *, subject_id, source, collection_kind,
241
+ source_item_ids) -> int:
242
+ store = self.calendar if collection_kind == "calendar" else self.reminders
243
+ wanted = set(source_item_ids)
244
+ # key = (subject, source, account, collection, item_id) —— 最后一位是 id。
245
+ doomed = [k for k in store
246
+ if k[0] == subject_id and k[1] == source and k[-1] in wanted]
247
+ for k in doomed:
248
+ del store[k]
249
+ return len(doomed)
250
+
238
251
  def apply_source_snapshot(self, *, subject_id, source, collection_kind, sync_id,
239
252
  coverage_start, coverage_end, snapshot_kind) -> int:
240
253
  # 增量同步没有资格删任何东西 —— 它只知道"变了什么",不知道"还剩什么"。
@@ -243,7 +256,11 @@ class InMemoryStorage:
243
256
  store = self.calendar if collection_kind == "calendar" else self.reminders
244
257
  doomed = []
245
258
  for key, item in store.items():
246
- if key[0] != subject_id or item.last_seen_sync_id == sync_id:
259
+ # 🔴 必须同时限定 subject **和 source**。只按 subject 删的话,
260
+ # 一次 source="ios" 的全量同步会把 Google 日历的条目一起删掉 ——
261
+ # 它们当然没出现在这一轮 ios 的批次里。
262
+ if (key[0] != subject_id or item.source != source
263
+ or item.last_seen_sync_id == sync_id):
247
264
  continue
248
265
  # 🔴 只删【能证明落在覆盖范围内】的。拿局部窗口去删窗口外的数据,
249
266
  # 会让用户发现自己去年的日程凭空消失,而且不可逆。
@@ -228,12 +228,12 @@ def _g8_partial_sync_does_not_delete_outside_its_window(new: StorageFactory) ->
228
228
  problems: list[str] = []
229
229
  s = new()
230
230
  inside = CalendarEventMirror(
231
- subject_id="u1", source_account_id="a", source_calendar_id="c",
231
+ subject_id="u1", source="ios", source_account_id="a", source_calendar_id="c",
232
232
  source_event_id="e_in", event_fields={"start_at": T0},
233
233
  last_seen_sync_id="old",
234
234
  )
235
235
  outside = CalendarEventMirror(
236
- subject_id="u1", source_account_id="a", source_calendar_id="c",
236
+ subject_id="u1", source="ios", source_account_id="a", source_calendar_id="c",
237
237
  source_event_id="e_out", event_fields={"start_at": T0 - timedelta(days=400)},
238
238
  last_seen_sync_id="old",
239
239
  )
@@ -348,7 +348,7 @@ def _g11_both_source_mirrors_round_trip(new: StorageFactory) -> list[str]:
348
348
  problems: list[str] = []
349
349
  s = new()
350
350
  s.upsert_calendar_events(subject_id="u1", events=[CalendarEventMirror(
351
- subject_id="u1", source_account_id="a", source_calendar_id="c",
351
+ subject_id="u1", source="ios", source_account_id="a", source_calendar_id="c",
352
352
  source_event_id="e1", event_fields={"title": "站会", "start_at": T0},
353
353
  )])
354
354
  got = list(s.list_calendar_events(subject_id="u1", limit=10))
@@ -357,7 +357,7 @@ def _g11_both_source_mirrors_round_trip(new: StorageFactory) -> list[str]:
357
357
 
358
358
  s2 = new()
359
359
  s2.upsert_reminders(subject_id="u1", items=[ReminderItemMirror(
360
- subject_id="u1", source_account_id="a", source_list_id="l",
360
+ subject_id="u1", source="ios", source_account_id="a", source_list_id="l",
361
361
  source_reminder_id="r1", reminder_fields={"title": "买牛奶",
362
362
  "is_completed": False},
363
363
  )])
@@ -368,7 +368,7 @@ def _g11_both_source_mirrors_round_trip(new: StorageFactory) -> list[str]:
368
368
  )
369
369
  # 已完成的默认不出现,除非明说要。
370
370
  s2.upsert_reminders(subject_id="u1", items=[ReminderItemMirror(
371
- subject_id="u1", source_account_id="a", source_list_id="l",
371
+ subject_id="u1", source="ios", source_account_id="a", source_list_id="l",
372
372
  source_reminder_id="r2", reminder_fields={"title": "交房租",
373
373
  "is_completed": True},
374
374
  )])
@@ -447,7 +447,7 @@ def _g12_terminal_events_and_offsets_are_queryable(new: StorageFactory) -> list[
447
447
 
448
448
  s2 = new()
449
449
  s2.upsert_calendar_events(subject_id="u1", events=[CalendarEventMirror(
450
- subject_id="u1", source_account_id="a", source_calendar_id="c",
450
+ subject_id="u1", source="ios", source_account_id="a", source_calendar_id="c",
451
451
  source_event_id=f"e{i}",
452
452
  event_fields={"title": f"e{i}", "start_at": base + timedelta(minutes=i)},
453
453
  ) for i in range(4)])
@@ -460,7 +460,7 @@ def _g12_terminal_events_and_offsets_are_queryable(new: StorageFactory) -> list[
460
460
 
461
461
  s3 = new()
462
462
  s3.upsert_reminders(subject_id="u1", items=[ReminderItemMirror(
463
- subject_id="u1", source_account_id="a", source_list_id="l",
463
+ subject_id="u1", source="ios", source_account_id="a", source_list_id="l",
464
464
  source_reminder_id=f"r{i}",
465
465
  reminder_fields={"title": f"r{i}", "is_completed": False,
466
466
  "due_at": base + timedelta(minutes=i)},
@@ -218,11 +218,18 @@ class CalendarEventMirror:
218
218
  **镜像不是快照历史。** 存的是"来源现在有哪些条目",条目自己带着
219
219
  过去或未来的时间;不存"我们每次同步时看到了什么"。来源删除 → 本地删除。
220
220
 
221
- 唯一身份必须**包含来源账户和日历**:不同账户碰巧用同一个 event id
222
- 是完全可能的。
221
+ 唯一身份必须**包含来源系统、来源账户和日历**:不同账户碰巧用同一个
222
+ event id 是完全可能的,不同来源系统更是必然。
223
223
  """
224
224
 
225
225
  subject_id: str
226
+ #: 哪个来源系统(``ios`` / ``google`` / ``exchange`` …)。
227
+ #:
228
+ #: 🔴 **它是唯一身份的一部分,不是标签。** 少了它,一次
229
+ #: ``source="ios"`` 的全量同步会把概念上属于 Google 的日程一起删掉 ——
230
+ #: 快照收尾删的是「这轮没见到的」,而另一个来源的条目当然没在这轮里。
231
+ #: 用户发现自己另一个日历账户的日程凭空消失了,且不可逆。
232
+ source: str
226
233
  source_account_id: str
227
234
  source_calendar_id: str
228
235
  source_event_id: str
@@ -242,6 +249,8 @@ class ReminderItemMirror:
242
249
  """外部提醒里现在还存在的一条待办。"""
243
250
 
244
251
  subject_id: str
252
+ #: 哪个来源系统。理由同 :class:`CalendarEventMirror.source`。
253
+ source: str
245
254
  source_account_id: str
246
255
  source_list_id: str
247
256
  source_reminder_id: str
@@ -215,6 +215,27 @@ class StoragePort(Protocol):
215
215
  """镜像里现在还存在的提醒事项。``offset`` 的要求同上。"""
216
216
  ...
217
217
 
218
+ def delete_source_items(
219
+ self, *, subject_id: str, source: str, collection_kind: str,
220
+ source_item_ids: Sequence[str],
221
+ ) -> int:
222
+ """删掉来源**明确说删了**的那几条,返回删了几条。
223
+
224
+ 和 :meth:`apply_source_snapshot` 是两件事,别合并:
225
+
226
+ 全量收尾 "覆盖范围内、这轮没见到的" —— 推断出来的,所以只有
227
+ 全量有资格,而且必须限定在声明的范围内
228
+ 这个方法 "来源说这条删了" —— 确定的事实,增量也必须执行
229
+
230
+ 没有这个方法的话,增量同步只能选:要么一条都不删(用户在手机上
231
+ 删掉的日程,在 agent 眼里永远还在,还会一直出现在"接下来有什么
232
+ 安排"里),要么拿局部列表当全量删(更糟,且不可逆)。
233
+
234
+ 🔴 ``source`` 是删除范围的一部分。少了它,一次 ``ios`` 的删除会
235
+ 命中另一个来源系统里碰巧同 id 的条目。
236
+ """
237
+ ...
238
+
218
239
  def apply_source_snapshot(
219
240
  self, *, subject_id: str, source: str, collection_kind: str,
220
241
  sync_id: str, coverage_start: datetime, coverage_end: datetime,
@@ -46,6 +46,9 @@ INCREMENTAL = "incremental"
46
46
  CALENDAR = "calendar"
47
47
  REMINDERS = "reminders"
48
48
 
49
+ #: 集合种类 -> 它唯一接受的条目类型。
50
+ _ITEM_TYPE = {CALENDAR: CalendarEventMirror, REMINDERS: ReminderItemMirror}
51
+
49
52
 
50
53
  class SyncContractError(ValueError):
51
54
  """这一批的声明本身自相矛盾,处理它会造成不可逆的损失。
@@ -73,6 +76,13 @@ class SyncBatch:
73
76
  cursor: str | None = None
74
77
  attempted_at: datetime | None = None
75
78
  completed_at: datetime | None = None
79
+ #: 来源**明确说**被删掉的条目身份。见 ``sync_source_mirror`` 的文档。
80
+ #:
81
+ #: 和「这一批里没出现」是两回事:没出现推断不出删除(增量只知道变了什么,
82
+ #: 不知道还剩什么),但来源的 change feed 明确传来一条删除时,
83
+ #: 那是确定的事实,必须执行 —— 否则用户在手机上删掉的日程,
84
+ #: 在 agent 眼里永远还在。
85
+ deleted_item_ids: Sequence[str] = field(default_factory=tuple)
76
86
  #: 非空 = 这一批**没有成功拿到**。见 ``SyncOutcome`` 的文档。
77
87
  error_code: str | None = None
78
88
 
@@ -82,7 +92,12 @@ class SyncOutcome:
82
92
  """这一轮做了什么。``failed`` 时 ``upserted`` / ``deleted`` 一定是 0。"""
83
93
 
84
94
  upserted: int = 0
95
+ #: 全量收尾按范围删掉的条数。
85
96
  deleted: int = 0
97
+ #: 按来源明确的 tombstone 删掉的条数。和上面一个分开数 —— 一个是
98
+ #: "这轮没见到所以删",一个是"来源说删了",混在一起就分不清
99
+ #: 某次异常删除是范围判断出错还是来源真的删了。
100
+ tombstoned: int = 0
86
101
  failed: bool = False
87
102
  #: 记下来的错误码,原样来自这一批。
88
103
  error_code: str | None = None
@@ -162,6 +177,8 @@ def sync_source_mirror(
162
177
 
163
178
  with storage.transaction():
164
179
  upserted = _upsert(storage, batch, context)
180
+ # 来源明确的删除,全量和增量都执行 —— 它不是推断出来的。
181
+ tombstoned = _apply_tombstones(storage, batch, context)
165
182
  deleted = 0
166
183
  if kind == FULL:
167
184
  # 只有全量才有资格删,而且只在它自己声明的范围内。
@@ -189,10 +206,34 @@ def sync_source_mirror(
189
206
  last_error_code=None,
190
207
  ))
191
208
  return SyncOutcome(upserted=upserted, deleted=deleted,
209
+ tombstoned=tombstoned,
192
210
  cursor=batch.cursor if batch.cursor is not None
193
211
  else prior_cursor)
194
212
 
195
213
 
214
+ def _apply_tombstones(storage: StoragePort, batch: SyncBatch,
215
+ context: IngestContext) -> int:
216
+ """执行来源**明确传来**的删除。
217
+
218
+ 这和全量收尾的删除是两件事,别合并:
219
+
220
+ 全量收尾 "覆盖范围内、这轮没见到的" —— 推断出来的,所以只有全量
221
+ 有资格,而且必须限定在声明的范围内
222
+ tombstone "来源说这条删了" —— 确定的事实,增量也必须执行
223
+
224
+ 早先把增量定义成「一条都不许删」,防住了「拿局部列表当全量」,
225
+ 但同时也堵死了这条:用户在手机上删掉的日程,在 agent 眼里永远还在,
226
+ 而且它会一直出现在"接下来有什么安排"里。
227
+ """
228
+ ids = [str(i) for i in (batch.deleted_item_ids or ()) if str(i).strip()]
229
+ if not ids:
230
+ return 0
231
+ return int(storage.delete_source_items(
232
+ subject_id=context.subject_id, source=batch.source,
233
+ collection_kind=batch.collection_kind, source_item_ids=ids,
234
+ ) or 0)
235
+
236
+
196
237
  def _upsert(storage: StoragePort, batch: SyncBatch,
197
238
  context: IngestContext) -> int:
198
239
  items = list(batch.items)
@@ -204,20 +245,51 @@ def _upsert(storage: StoragePort, batch: SyncBatch,
204
245
  # 不打的话,刚写进去的条目在同一个事务里被自己的快照收尾删掉 ——
205
246
  # 一次"成功"的全量同步,结果是镜像空了。
206
247
  # 这个由 kit 打,不指望宿主记得:忘了不报错,只是数据没了。
207
- items = [replace(i, last_seen_sync_id=batch.sync_id) for i in items]
248
+ # 这一批声明的 source 和 sync_id 由 kit 盖上去,不要求宿主在每个条目上
249
+ # 再写一遍。两者忘了都不报错,但后果不一样:
250
+ # sync_id 忘了 → 刚写进去的被自己的快照收尾删掉
251
+ # source 忘了 → 这批条目落在别的来源名下,下次那个来源的全量同步删掉它们
252
+ # 🔴 subject 一律用**可信上下文**的,不用条目自己带的那个。
253
+ #
254
+ # 条目是宿主从来源数据翻译出来的,它带的 subject_id 最好的情况是冗余、
255
+ # 最坏的情况是把 A 的日程写进 B 的花园。可信 subject 只有一个来源:
256
+ # IngestContext —— 那是宿主鉴权之后填的,不经过来源数据也不经过模型。
257
+ # 校验一致再拒绝也行,但覆盖更彻底:没有"该信哪个"这个问题存在。
258
+ items = [replace(i, subject_id=context.subject_id, source=batch.source,
259
+ last_seen_sync_id=batch.sync_id)
260
+ for i in items]
208
261
  kinds = {type(i) for i in items}
209
- if kinds == {CalendarEventMirror}:
210
- storage.upsert_calendar_events(subject_id=context.subject_id,
211
- events=items)
212
- elif kinds == {ReminderItemMirror}:
213
- storage.upsert_reminders(subject_id=context.subject_id, items=items)
214
- else:
262
+ if len(kinds) > 1:
215
263
  # 混着来说明宿主那边的翻译出了问题。挑一部分写进去、剩下的丢掉,
216
264
  # 会得到一份"成功了"的半份镜像。
217
265
  raise SyncContractError(
218
266
  f"一批里混了 {sorted(k.__name__ for k in kinds)}:"
219
267
  f"一次同步只处理一种集合,混着来会写出一份看起来成功的半份镜像"
220
268
  )
269
+ # 🔴 声明的集合种类必须和条目类型对上。
270
+ #
271
+ # 不校验的话,「collection_kind=reminders + 一批日历条目」会被照单全收:
272
+ # 日历表被写进去了,而**提醒的同步游标往前推进了** —— 数据和游标从此
273
+ # 互相矛盾,下一轮增量提醒同步会以为上一轮成功了,那段提醒永远补不回来。
274
+ expected = _ITEM_TYPE.get(batch.collection_kind)
275
+ if expected is None:
276
+ raise SyncContractError(
277
+ f"不认识的 collection_kind={batch.collection_kind!r}:"
278
+ f"只有 {CALENDAR!r} 和 {REMINDERS!r}。放过去的话,"
279
+ f"这批数据会落在一个没人读的地方,而同步状态说成功了"
280
+ )
281
+ actual = kinds.pop()
282
+ if actual is not expected:
283
+ raise SyncContractError(
284
+ f"collection_kind={batch.collection_kind!r} 声明的是 "
285
+ f"{expected.__name__},实际给的是 {actual.__name__}:"
286
+ f"照做会把数据写进一张表、把另一张表的游标往前推"
287
+ )
288
+ if expected is CalendarEventMirror:
289
+ storage.upsert_calendar_events(subject_id=context.subject_id,
290
+ events=items)
291
+ else:
292
+ storage.upsert_reminders(subject_id=context.subject_id, items=items)
221
293
  return len(items)
222
294
 
223
295
 
@@ -28,6 +28,12 @@ KEEP_FOREVER = None
28
28
 
29
29
  _DAY = 86400.0
30
30
 
31
+ # ⚠️ **这张表不再是任何查询的依据。** 它是早期设计的记录,键还是旧目录名。
32
+ # 唯一的真相是 manifest(``manifest.minimal.MINIMAL_SIGNALS``),
33
+ # ``retention_days()`` / ``stores_history()`` 现在都从那里查。
34
+ # 保留它是因为下面那段 weather 的注释记着一次真实的四表打架,值得留着;
35
+ # 但**别再往里加条目,也别照它做判断**。
36
+ #
31
37
  # signal -> 保留天数;None = 永久;不在表里 = 不进历史表
32
38
  RETENTION_DAYS: dict[str, int | None] = {
33
39
  # 永久:趋势本身就是价值
@@ -178,17 +184,64 @@ def plan_retention(
178
184
  return plan
179
185
 
180
186
 
187
+ #: 旧目录名 -> manifest 里的信号名。
188
+ #:
189
+ #: 这是一张**显式的兼容表**,不是第二套目录。它存在的唯一理由是老调用方还在
190
+ #: 用旧名;查出来的答案一律来自 manifest。
191
+ #:
192
+ #: 🔴 审查(2026-09-03 §9)复现的:这两个公开函数原本读的是本模块自己那张
193
+ #: ``RETENTION_DAYS``,和 manifest 七条全对不上 ——
194
+ #: ``retention_days("focus_state")`` 抛 KeyError(manifest 说 365)、
195
+ #: ``retention_days("audio_route")`` 返回 90(manifest 说 7)。
196
+ #: 接入方和工程 AI 调公开 API 会拿到错的结论,而且没有任何地方报错。
197
+ #: **同一件事有两个真相时,公开 API 必须指向那个会被执行的那个。**
198
+ _LEGACY_NAMES: dict[str, str] = {
199
+ "focus": "focus_state",
200
+ "playback": "music_playback",
201
+ "location_signal": "location_city",
202
+ }
203
+
204
+
205
+ def _resolve(signal: str) -> "SignalDefinition | None":
206
+ from .manifest.minimal import MINIMAL_SIGNALS
207
+ return MINIMAL_SIGNALS.get(_LEGACY_NAMES.get(signal, signal))
208
+
209
+
181
210
  def stores_history(signal: str) -> bool:
182
- """这个信号进不进历史表。"""
183
- return signal in RETENTION_DAYS
211
+ """这个信号进不进历史表。**答案来自 manifest。**"""
212
+ sig = _resolve(signal)
213
+ return bool(sig and sig.stores_history)
214
+
215
+
216
+ def retention_days(signal: str) -> int:
217
+ """明细保留多少天;``PERMANENT``(-1)= 永久。
184
218
 
219
+ **答案来自 manifest**,不是本模块顶上那张历史表 —— 那张表现在只作为
220
+ 早期设计的记录留着,不再是任何查询的依据。
185
221
 
186
- def retention_days(signal: str) -> int | None:
187
- """保留天数;``KEEP_FOREVER``(None)表示永久。
222
+ 🔴 **不进历史表的信号抛 ``KeyError``,不返回 ``None``。**
223
+ 这是本模块早就定下的决策,改用 manifest 之后仍然成立,而且理由更硬了:
224
+ 旧表里 ``None`` 是「永久保存」(``KEEP_FOREVER``)。静默返回 ``None``
225
+ 的话,一个照旧词表理解的调用方会把「这个信号根本不存历史」读成
226
+ 「永久保留」—— 两个意思正好相反,而且不会有任何地方报错。
188
227
 
189
- 不进历史表的信号调用这个是调用方的错,直接抛 —— 静默返回 None 会被
190
- 误当成「永久」,那是最坏的一种默认值。
228
+ 问"存不存历史"用 :func:`stores_history`,那才是它该回答的问题。
191
229
  """
192
- if signal not in RETENTION_DAYS:
193
- raise KeyError(f"signal {signal!r} 不进历史表,先用 stores_history() 判断")
194
- return RETENTION_DAYS[signal]
230
+ sig = _resolve(signal)
231
+ if sig is None or not sig.stores_history:
232
+ raise KeyError(
233
+ f"{signal!r} 不进历史表,没有明细保留期。"
234
+ f"想问存不存历史用 stores_history();"
235
+ f"这里不返回 None —— 旧词表里 None 是「永久保存」,"
236
+ f"两个意思正好相反"
237
+ )
238
+ return sig.history_retention_days
239
+
240
+
241
+ __all__ = [
242
+ "KEEP_FOREVER", "RETENTION_DAYS", "MEASURED_AT_TTL_SEC", "LIFECYCLE_NOTE",
243
+ "RetentionAction", "RetentionPlan", "SkippedSignal", "plan_retention",
244
+ "SKIP_NO_HISTORY", "SKIP_DETAILS_PERMANENT", "SKIP_DETAILS_UNDECLARED",
245
+ "SKIP_AGGREGATES_PERMANENT", "SKIP_AGGREGATES_UNDECLARED",
246
+ "stores_history", "retention_days",
247
+ ]
@@ -36,11 +36,11 @@ def build() -> tuple[PerceptionKit, InMemoryStorage]:
36
36
  ],
37
37
  }, context=IngestContext("u1", when("09:00")))
38
38
  s.upsert_calendar_events(subject_id="u1", events=[CalendarEventMirror(
39
- subject_id="u1", source_account_id="a", source_calendar_id="c",
39
+ subject_id="u1", source="ios", source_account_id="a", source_calendar_id="c",
40
40
  source_event_id="e1",
41
41
  event_fields={"title": "牙医", "start_at": when("15:00")})])
42
42
  s.upsert_reminders(subject_id="u1", items=[ReminderItemMirror(
43
- subject_id="u1", source_account_id="a", source_list_id="l",
43
+ subject_id="u1", source="ios", source_account_id="a", source_list_id="l",
44
44
  source_reminder_id="r1",
45
45
  reminder_fields={"title": "交房租", "is_completed": True})])
46
46
  return kit, s
@@ -480,7 +480,7 @@ def test_the_calendar_pages_like_everything_else():
480
480
  s = InMemoryStorage()
481
481
  s.upsert_calendar_events(subject_id="u1", events=[
482
482
  CalendarEventMirror(
483
- subject_id="u1", source_account_id="a", source_calendar_id="c",
483
+ subject_id="u1", source="ios", source_account_id="a", source_calendar_id="c",
484
484
  source_event_id=f"e{i}",
485
485
  event_fields={"title": f"会 {i}", "start_at": t(f"2026-08-{i+1:02d}")})
486
486
  for i in range(5)])
@@ -497,7 +497,7 @@ def test_the_reminder_list_pages_too():
497
497
  s = InMemoryStorage()
498
498
  s.upsert_reminders(subject_id="u1", items=[
499
499
  ReminderItemMirror(
500
- subject_id="u1", source_account_id="a", source_list_id="l",
500
+ subject_id="u1", source="ios", source_account_id="a", source_list_id="l",
501
501
  source_reminder_id=f"r{i}",
502
502
  reminder_fields={"title": f"事 {i}", "is_completed": False})
503
503
  for i in range(5)])
@@ -149,7 +149,7 @@ def series(rule: dict | None = None) -> CalendarEventMirror:
149
149
  if rule is not None:
150
150
  fields["recurrence"] = rule
151
151
  return CalendarEventMirror(
152
- subject_id="u1", source_account_id="a", source_calendar_id="c",
152
+ subject_id="u1", source="ios", source_account_id="a", source_calendar_id="c",
153
153
  source_event_id="weekly-standup", event_fields=fields,
154
154
  recurrence_identity="series-1",
155
155
  )
@@ -929,7 +929,7 @@ def _reminders(storage, subject, n):
929
929
  for i in range(n):
930
930
  rid = f"r{i:04d}"
931
931
  storage.reminders[(subject, rid)] = ReminderItemMirror(
932
- subject_id=subject, source_account_id="a", source_list_id="l",
932
+ subject_id=subject, source="ios", source_account_id="a", source_list_id="l",
933
933
  source_reminder_id=rid,
934
934
  reminder_fields={"title": rid, "due_at": base + timedelta(minutes=i)},
935
935
  )
@@ -963,7 +963,7 @@ def test_calendar_pagination_walks_the_whole_mirror():
963
963
  for i in range(600):
964
964
  eid = f"c{i:04d}"
965
965
  s.calendar[("u", eid)] = CalendarEventMirror(
966
- subject_id="u", source_account_id="a", source_calendar_id="c",
966
+ subject_id="u", source="ios", source_account_id="a", source_calendar_id="c",
967
967
  source_event_id=eid,
968
968
  event_fields={"title": eid, "start_at": base + timedelta(minutes=i)},
969
969
  )