perceptkit 0.2.2__py3-none-any.whl

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 (59) hide show
  1. perceptkit/__init__.py +85 -0
  2. perceptkit/algorithms/__init__.py +40 -0
  3. perceptkit/algorithms/attribution.py +147 -0
  4. perceptkit/algorithms/glance.py +236 -0
  5. perceptkit/algorithms/history.py +663 -0
  6. perceptkit/algorithms/identity.py +43 -0
  7. perceptkit/algorithms/observation.py +44 -0
  8. perceptkit/algorithms/streaks.py +111 -0
  9. perceptkit/algorithms/trend_models.py +184 -0
  10. perceptkit/algorithms/wake.py +149 -0
  11. perceptkit/catalog.py +252 -0
  12. perceptkit/conformance/__init__.py +28 -0
  13. perceptkit/conformance/memory.py +364 -0
  14. perceptkit/conformance/report.py +170 -0
  15. perceptkit/conformance/suite.py +419 -0
  16. perceptkit/conformance/wake.py +151 -0
  17. perceptkit/contracts/__init__.py +97 -0
  18. perceptkit/contracts/_time.py +89 -0
  19. perceptkit/contracts/availability.py +77 -0
  20. perceptkit/contracts/context.py +50 -0
  21. perceptkit/contracts/delivery.py +167 -0
  22. perceptkit/contracts/errors.py +22 -0
  23. perceptkit/contracts/event.py +137 -0
  24. perceptkit/contracts/observation.py +172 -0
  25. perceptkit/contracts/receipt.py +129 -0
  26. perceptkit/contracts/records.py +367 -0
  27. perceptkit/contracts/report.py +127 -0
  28. perceptkit/contracts/versioning.py +63 -0
  29. perceptkit/fields.py +184 -0
  30. perceptkit/kit.py +223 -0
  31. perceptkit/manifest/__init__.py +57 -0
  32. perceptkit/manifest/checks.py +323 -0
  33. perceptkit/manifest/mapping.py +96 -0
  34. perceptkit/manifest/minimal.py +1282 -0
  35. perceptkit/manifest/types.py +211 -0
  36. perceptkit/manifest/units.py +84 -0
  37. perceptkit/ports/__init__.py +19 -0
  38. perceptkit/ports/storage.py +288 -0
  39. perceptkit/ports/wake.py +43 -0
  40. perceptkit/processing/__init__.py +49 -0
  41. perceptkit/processing/aggregate.py +80 -0
  42. perceptkit/processing/dispatch.py +356 -0
  43. perceptkit/processing/normalize.py +458 -0
  44. perceptkit/processing/pipeline.py +406 -0
  45. perceptkit/processing/recompute.py +170 -0
  46. perceptkit/processing/recurrence.py +166 -0
  47. perceptkit/processing/scheduled.py +233 -0
  48. perceptkit/prompts.py +75 -0
  49. perceptkit/queries/__init__.py +32 -0
  50. perceptkit/queries/api.py +457 -0
  51. perceptkit/retention.py +84 -0
  52. perceptkit/rules/__init__.py +19 -0
  53. perceptkit/rules/engine.py +112 -0
  54. perceptkit/rules/evaluators.py +228 -0
  55. perceptkit/rules/types.py +236 -0
  56. perceptkit-0.2.2.dist-info/METADATA +439 -0
  57. perceptkit-0.2.2.dist-info/RECORD +59 -0
  58. perceptkit-0.2.2.dist-info/WHEEL +4 -0
  59. perceptkit-0.2.2.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,211 @@
1
+ """Manifest 的类型 —— 每个信号、每个字段的完整声明。
2
+
3
+ **为什么要有这一层。** 在这之前,一个字段的属性散在七个互不关联的地方:
4
+ catalog 声明输入和 TTL、fields 决定 agent 能不能看、history 决定怎么聚合、
5
+ retention 决定留多久、attribution 决定算哪天、trend_models 决定用哪种趋势、
6
+ prompts 决定怎么讲给模型。加一个字段要同时改七处,**漏一处就是静默出错**
7
+ —— 没有任何测试会因为"你忘了给新字段声明 retention"而变红。
8
+
9
+ manifest 把这七处并成一处,并让四条自动检查有了施力点(见 ``checks.py``)。
10
+
11
+ manifest 只声明**属性**,不实现算法。``normalizer`` / ``aggregation_strategy``
12
+ 这类字段存的是名字,具体实现由 kit 或宿主注册 —— 但名字必须能解析到实现,
13
+ 否则就是一个永远不会被发现的空指针(现状:catalog 里有 10 个 resolver 名字
14
+ 没有任何实现)。
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from typing import Any
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # 值域
23
+ # ---------------------------------------------------------------------------
24
+
25
+ #: 字段的类型。刻意只有这几种 —— 协议要能被非 Python 的 producer 实现。
26
+ VALUE_TYPES: frozenset[str] = frozenset({
27
+ "integer", "number", "boolean", "string", "enum", "timestamp", "array", "object",
28
+ })
29
+
30
+ #: 隐私级别。决定这个字段能不能进日志、能不能持久化、能不能给 agent 看。
31
+ #: kit 不做加解密 —— 这只是给宿主的处理提示。
32
+ PRIVACY_CLASSES: frozenset[str] = frozenset({
33
+ "public", # 无所谓,如电量
34
+ "personal", # 用户的日常事实,如城市、专注状态
35
+ "sensitive", # 健康、日历内容、照片属性
36
+ "restricted", # 默认不持久化、不给 agent,如精确坐标、BSSID
37
+ })
38
+
39
+ #: 四种存储形态。每个信号必须明确属于其中一种,不能"看情况"。
40
+ STORAGE_MODES: frozenset[str] = frozenset({
41
+ "current_only", # 只留最近可信值。电量、屏幕变化
42
+ "current_timeline_aggregate", # 最新 + 变化明细 + 日聚合。位置、专注、健康
43
+ "current_short_timeline", # 最新 + 有限期明细。Wi-Fi、天气、音频路由
44
+ "source_mirror", # 跟随外部可变集合。日历、提醒
45
+ })
46
+
47
+ #: 日聚合的算法。名字要能解析到实现。
48
+ AGGREGATION_STRATEGIES: frozenset[str] = frozenset({
49
+ "none", "daily_total", "numeric_dist",
50
+ "duration_by_state", "event_list", "tally", "main_of_day", "cumulative",
51
+ })
52
+
53
+ #: 怎么判"这个字段该触发了"。事件规则和 wake 判断都读它。
54
+ #: 注意 ``occurrence`` 不是在比较 —— 它表示"这条观测到达本身就是事件"
55
+ #: (解锁、新增照片这类),没有前后值可比,靠 source_event_id 去重。
56
+ COMPARISON_STRATEGIES: frozenset[str] = frozenset({
57
+ "none", "exact", "numeric_delta", "threshold_crossing", "state_change",
58
+ "occurrence",
59
+ })
60
+
61
+ #: 一条观测的去重身份怎么来。
62
+ IDENTITY_STRATEGIES: frozenset[str] = frozenset({
63
+ # 上游给稳定 id(HealthKit sample、日历事件)。最可靠。
64
+ "source_event_id",
65
+ # 上游给不了 id,用 (signal, occurred_at, 值摘要) 造一个确定性的键。
66
+ # 音乐、照片现在只能走这条 —— 见 FACTS.md ③ 和 3.1。
67
+ "deterministic_digest",
68
+ # 每个 subject+signal 只有一条,后来的直接覆盖。电量这类纯快照。
69
+ "singleton",
70
+ })
71
+
72
+ #: 一条观测算哪一天。
73
+ ATTRIBUTION_STRATEGIES: frozenset[str] = frozenset({
74
+ "instant", # 瞬时值,按发生时刻的本地日期
75
+ "episode_end", # 区间,按结束时刻(睡眠算醒来那天)
76
+ "split_at_midnight", # 区间,跨午夜按本地日切开分摊
77
+ "source_local_date", # 上游直接给了本地日期,原样用
78
+ })
79
+
80
+ #: 这个字段的历史该用哪种趋势读法。三种算法结论完全不同,选错就是错的:
81
+ #: fluctuating 有"平时水平",偏离才是信号(睡眠时长、步数)
82
+ #: drifting 没有平时水平,方向和速率才是信号(体重)
83
+ #: cyclical 看间隔不看数值高低(经期)
84
+ TREND_MODELS: frozenset[str] = frozenset({
85
+ "none", "fluctuating", "drifting", "cyclical",
86
+ })
87
+
88
+ #: agent 能不能看到这个字段。
89
+ QUERY_VISIBILITY: frozenset[str] = frozenset({
90
+ "always", # 直接进上下文
91
+ "on_demand", # agent 主动查才给
92
+ "never", # 只在 kit 内部用,永不出给模型(如原始坐标)
93
+ })
94
+
95
+ #: 需要额外语义的来源。不是所有信号都要背 revision / cursor / tombstone。
96
+ SOURCE_PROFILES: frozenset[str] = frozenset({
97
+ "health_sample", "calendar_sync", "location", "device_occurrence", "proximity_anchor",
98
+ })
99
+
100
+ #: 保留期用这个值表示"永久"。用 ``None`` 会和"忘了声明"混淆。
101
+ PERMANENT = -1
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # 声明
106
+ # ---------------------------------------------------------------------------
107
+
108
+ @dataclass(frozen=True)
109
+ class FieldDefinition:
110
+ """一个字段的完整声明。"""
111
+
112
+ key: str
113
+ value_type: str
114
+ privacy_class: str
115
+ #: **标准**单位。无量纲的(布尔、枚举、标签)写 ``None``,但**数值型必须有** ——
116
+ #: 没有单位的数字在跨宿主传递时必然被解释错。
117
+ unit: str | None = None
118
+ #: producer 还可能发来哪些单位。收到这些一律换算成 ``unit``,
119
+ #: 原始单位留作 metadata。不在这个名单里的单位**拒收** ——
120
+ #: 一个磅的数字当公斤存进去,比拒收难查得多(没有报错,只有一个悄悄
121
+ #: 错掉一半的体重)。
122
+ accepted_units: tuple[str, ...] = ()
123
+ #: 相邻两次测量的相对变化上限。超过就报 conflict(**不拒收**,交给宿主决定)。
124
+ #: 这是唯一能挡住"单位标错"的一道:体重一次掉一半,不管单位对不对都不正常。
125
+ #: 值域校验拦不住它 —— 31.8 kg 完全合法。
126
+ #: 会误伤(用户真换了体重计),所以只报冲突。``None`` = 不检查。
127
+ max_relative_jump: float | None = None
128
+ nullable: bool = True
129
+ #: 数值型的合法区间 ``(min, max)``,任一端可为 ``None``。
130
+ valid_range: tuple[float | None, float | None] | None = None
131
+ #: 枚举型的合法取值。
132
+ enum: tuple[str, ...] | None = None
133
+ aggregation_strategy: str = "none"
134
+ comparison_strategy: str = "none"
135
+ #: 这个字段的变化能不能触发唤醒。
136
+ wake_eligible: bool = False
137
+ query_visibility: str = "on_demand"
138
+ trend_model: str = "none"
139
+ #: 标准化函数的名字。``None`` = 原样存。
140
+ normalizer: str | None = None
141
+ #: 这个字段为什么长这样 —— 和规范不一致的地方、平台限制、放弃的替代方案。
142
+ #: **写进数据结构而不是注释**:读 manifest 的人(和 dump 出来的文档)一定
143
+ #: 看得到,注释只有读源码的人看得到。信号级有同名字段,这里补上字段级。
144
+ note: str | None = None
145
+
146
+
147
+ @dataclass(frozen=True)
148
+ class SignalDefinition:
149
+ """一个信号的完整声明。"""
150
+
151
+ key: str
152
+ label: str
153
+ schema_version: int
154
+ #: 权限门。用户关掉这个 capability,整个信号停止读取和唤醒。
155
+ capability: str
156
+ storage_mode: str
157
+ #: 超过这个秒数就不能再叫"当前值"。仍可作为带 ``as_of`` 的 last known 返回。
158
+ current_ttl_sec: float
159
+ identity_strategy: str
160
+ attribution_strategy: str
161
+ fields: tuple[FieldDefinition, ...]
162
+ #: **明细**(逐条观测)保留多少天。``PERMANENT`` = 永久;``0`` = 不存历史。
163
+ history_retention_days: int = 0
164
+ #: **聚合**(日统计)保留多少天。默认跟着明细走 —— 但两者常常不该一样。
165
+ #:
166
+ #: 明细是聚合的几十倍体量,而能回答的问题正好反过来:
167
+ #: 「上周三下午你专注了多久」时间越久越没人问,
168
+ #: 「你今年专注时间比去年长了吗」时间越久越值钱。
169
+ #: 所以典型形态是**明细短、聚合永久** —— 省的全在明细上,
170
+ #: 多花的不到 2%,长期趋势保住了。
171
+ #:
172
+ #: ``None`` = 跟明细一样。
173
+ #:
174
+ #: ⚠️ 明细过期而聚合永久时,**去重记录必须比明细活得久**,
175
+ #: 否则旧数据重放会把永久聚合的数字加两遍且无法回滚。
176
+ aggregate_retention_days: int | None = None
177
+ source_profile: str | None = None
178
+ #: 这条声明为什么长这样 —— 特别是和产品规范有出入的地方。
179
+ #: 写进数据结构而不是注释,是为了让它跟着 manifest 一起被读到。
180
+ note: str | None = None
181
+ extensions: dict[str, Any] = field(default_factory=dict)
182
+
183
+ def field_map(self) -> dict[str, FieldDefinition]:
184
+ return {f.key: f for f in self.fields}
185
+
186
+ @property
187
+ def stores_history(self) -> bool:
188
+ return self.history_retention_days != 0
189
+
190
+ @property
191
+ def keeps_history_forever(self) -> bool:
192
+ return self.history_retention_days == PERMANENT
193
+
194
+ @property
195
+ def effective_aggregate_retention_days(self) -> int:
196
+ """聚合实际留多久。没单独声明就跟明细一样。"""
197
+ return (self.history_retention_days if self.aggregate_retention_days is None
198
+ else self.aggregate_retention_days)
199
+
200
+ @property
201
+ def keeps_aggregates_forever(self) -> bool:
202
+ return self.effective_aggregate_retention_days == PERMANENT
203
+
204
+
205
+ __all__ = [
206
+ "VALUE_TYPES", "PRIVACY_CLASSES", "STORAGE_MODES",
207
+ "AGGREGATION_STRATEGIES", "COMPARISON_STRATEGIES",
208
+ "IDENTITY_STRATEGIES", "ATTRIBUTION_STRATEGIES",
209
+ "QUERY_VISIBILITY", "TREND_MODELS", "SOURCE_PROFILES", "PERMANENT",
210
+ "FieldDefinition", "SignalDefinition",
211
+ ]
@@ -0,0 +1,84 @@
1
+ """单位换算 —— 把 producer 发来的单位统一成 manifest 声明的那个。
2
+
3
+ **为什么必须换算而不是拒收**:同一个数据可能来自不同来源,各自习惯的单位不一样
4
+ (美区设备报磅、国区报公斤)。拒收会让一整类用户的数据进不来。
5
+
6
+ **但换算会放大错误,所以从来不能单独用**:
7
+
8
+ 用户体重 70 kg,设备把单位标成了 lb(标错了)
9
+ → 我们老老实实换算:70 lb = 31.8 kg
10
+ → 用户的体重记录突然掉了一半,而且没有任何报错
11
+ → 值域校验也拦不住(31.8 kg 完全合法)
12
+
13
+ 所以三道一起:
14
+
15
+ ① 换算 按声明的单位转成标准单位,原始单位留作 metadata
16
+ ② 值域校验 换算【之后】再查一遍范围
17
+ ③ 跳变阈值 和上一次比,变化超过阈值就报 conflict —— 不拒收,交给宿主决定
18
+
19
+ 第 ③ 道是唯一能挡住"单位标错"的:体重一次掉一半,不管单位对不对都不正常。
20
+ 它也会误伤(用户真换了体重计),所以只报 conflict 不拒收。
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from typing import Callable
25
+
26
+ #: 换算表:``(来的单位, 目标单位) -> 换算函数``。
27
+ #: 刻意只放真实会遇到的,不做通用单位库 —— 那是另一个包的活。
28
+ _CONVERSIONS: dict[tuple[str, str], Callable[[float], float]] = {
29
+ # 质量
30
+ ("lb", "kg"): lambda v: v * 0.45359237,
31
+ ("g", "kg"): lambda v: v / 1000.0,
32
+ # 温度
33
+ ("fahrenheit", "celsius"): lambda v: (v - 32.0) * 5.0 / 9.0,
34
+ # 长度
35
+ ("in", "cm"): lambda v: v * 2.54,
36
+ ("m", "cm"): lambda v: v * 100.0,
37
+ ("ft", "cm"): lambda v: v * 30.48,
38
+ # 距离
39
+ ("km", "m"): lambda v: v * 1000.0,
40
+ ("mi", "m"): lambda v: v * 1609.344,
41
+ # 能量
42
+ ("kj", "kcal"): lambda v: v / 4.184,
43
+ # 血糖 —— 换算系数取决于摩尔质量,葡萄糖是 18.0182
44
+ ("mg_dl", "mmol_l"): lambda v: v / 18.0182,
45
+ }
46
+
47
+
48
+ class UnitError(ValueError):
49
+ """来的单位换算不到目标单位。"""
50
+
51
+
52
+ def can_convert(source: str, target: str) -> bool:
53
+ return source == target or (source, target) in _CONVERSIONS
54
+
55
+
56
+ def convert(value: float, *, source: str, target: str) -> float:
57
+ """把 ``value`` 从 ``source`` 换算成 ``target``。
58
+
59
+ 换不了就抛 —— **不要静默按原值放行**:一个 lb 的数字当 kg 存进去,
60
+ 比拒收难查得多(没有报错,只有一个悄悄错掉一半的体重)。
61
+ """
62
+ if source == target:
63
+ return float(value)
64
+ fn = _CONVERSIONS.get((source, target))
65
+ if fn is None:
66
+ raise UnitError(f"没有 {source!r} -> {target!r} 的换算")
67
+ return fn(float(value))
68
+
69
+
70
+ def relative_jump(new: float, old: float) -> float | None:
71
+ """两次测量的相对变化幅度。``old`` 为 0 或缺失时返回 ``None``(比不了)。
72
+
73
+ 用相对值不用绝对值:体重差 5 公斤和血糖差 5 mmol/L 是完全不同量级的事,
74
+ 每个字段各写一个绝对阈值既啰嗦又容易写错。
75
+ """
76
+ if old in (None, 0) or new is None:
77
+ return None
78
+ try:
79
+ return abs((float(new) - float(old)) / float(old))
80
+ except (TypeError, ValueError, ZeroDivisionError):
81
+ return None
82
+
83
+
84
+ __all__ = ["UnitError", "can_convert", "convert", "relative_jump"]
@@ -0,0 +1,19 @@
1
+ """端口 —— 宿主要实现的两个接口。
2
+
3
+ StoragePort 数据落到哪儿、怎么保证一致
4
+ WakePort 事件交给谁
5
+
6
+ **端口只定行为,不定实现。** 宿主填的每个方法都是孤立的一件事;
7
+ "先落地再投递""迟到数据不覆盖当前值"这些顺序和规则在 kit 的处理管线里,
8
+ 宿主没有那个入口 —— 让写错的那条路根本不存在,比在文档里提醒别写错可靠。
9
+
10
+ 这两个 Protocol 都是 ``runtime_checkable`` 的,宿主可以用 ``isinstance``
11
+ 自查有没有漏方法;但真正的验收是跑 ``perceptkit.conformance`` 那套测试 ——
12
+ 方法签名对得上不代表语义对得上。
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from .storage import StoragePort
17
+ from .wake import WakePort
18
+
19
+ __all__ = ["StoragePort", "WakePort"]
@@ -0,0 +1,288 @@
1
+ """存储端口 —— 宿主要填的方法体。
2
+
3
+ **这里只定行为,不定 SQL。** 用 PostgreSQL、SQLite、文档数据库、甚至内存,
4
+ 都行;但必须满足同样的查询、幂等、重算、删除和一致性语义 —— 这一点由
5
+ ``perceptkit.conformance`` 的测试来证明,不靠自觉。
6
+
7
+ 宿主实现的每个方法都是**孤立的一件事**(写一条、读一批、提交一次)。
8
+ "先落地再投递""迟到数据不覆盖当前值""同一时刻不同内容要报冲突"这些顺序
9
+ 和规则不在宿主手里 —— 它们在 kit 的处理管线里,宿主没有那个入口。
10
+ 这不是不信任宿主,是让"写错的那条路根本不存在"。
11
+
12
+ **每个方法都必须按 subject 隔离。** ``subject_id`` 一律来自
13
+ :class:`~perceptkit.contracts.context.IngestContext`,绝不来自上报信封 ——
14
+ 信封是设备写的,设备可以被改。
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from datetime import date, datetime
19
+ from typing import Any, ContextManager, Protocol, Sequence, runtime_checkable
20
+
21
+ from ..contracts.records import (
22
+ CalendarEventMirror,
23
+ CurrentProjection,
24
+ DailyAggregate,
25
+ DurableDedupeIdentity,
26
+ EventOutboxEntry,
27
+ ReminderItemMirror,
28
+ SourceSyncState,
29
+ StoredObservation,
30
+ )
31
+ from ..contracts.receipt import IngestReceipt, WakeReceipt
32
+
33
+
34
+ @runtime_checkable
35
+ class StoragePort(Protocol):
36
+ """宿主的存储适配器。
37
+
38
+ 方法分六组:批级幂等 / 观测 / 当前值 / 聚合 / 来源镜像 / 事件与投递。
39
+ """
40
+
41
+ # -- 事务 ------------------------------------------------------------
42
+
43
+ def transaction(self) -> ContextManager[None]:
44
+ """一个原子边界。
45
+
46
+ kit 会把"写规则状态 + 写待发件箱"这类**必须一起成功**的操作包在
47
+ 同一个 ``with`` 里。宿主如果做不到真正的单事务,必须提供可证明的
48
+ 补偿/对账机制,并在一致性测试里证明它 —— 不能默认它不会出问题。
49
+
50
+ 产品规范这里留了活口("处于同一原子边界,**或有可证明的恢复机制**"),
51
+ 所以不强制单事务,但强制"能证明"。
52
+ """
53
+ ...
54
+
55
+ # -- 批级幂等 --------------------------------------------------------
56
+
57
+ def claim_report(
58
+ self, *, subject_id: str, producer: str, report_id: str, payload_digest: str,
59
+ received_at: datetime,
60
+ ) -> IngestReceipt:
61
+ """认领一批上报,同时回答"这批处理过没有"。
62
+
63
+ 同 identity + 同摘要 → 返回原来那份回执(``duplicate``),**不重复处理**。
64
+ 同 identity + 异摘要 → ``conflict``,不能静默挑一个覆盖。
65
+ 没见过 → ``accepted``,并占住这个 identity。
66
+
67
+ 必须是原子的 check-and-claim:两个并发请求带同一个 ``report_id``
68
+ 进来,只能有一个拿到 ``accepted``。
69
+ """
70
+ ...
71
+
72
+ # -- 观测 ------------------------------------------------------------
73
+
74
+ def append_observation(self, observation: StoredObservation) -> bool:
75
+ """追加一条观测。已经存在(同一去重身份)时返回 ``False`` 且不重复写。
76
+
77
+ 返回值不是可有可无的:调用方靠它决定要不要去更新聚合 ——
78
+ 重复的观测如果也去加一遍日总数,那就是重复累计。
79
+ """
80
+ ...
81
+
82
+ def list_observations(
83
+ self, *, subject_id: str, signal: str,
84
+ start: datetime | None = None, end: datetime | None = None,
85
+ cursor: str | None = None, limit: int = 100,
86
+ ) -> tuple[Sequence[StoredObservation], str | None]:
87
+ """按时间取观测。返回 ``(结果, 下一页游标)``。
88
+
89
+ **必须分页。** agent 问一句"我这个月都去过哪",不设上限就是几千条
90
+ 直接塞进模型上下文。
91
+ """
92
+ ...
93
+
94
+ def delete_observations(
95
+ self, *, subject_id: str, signal: str | None = None,
96
+ before: datetime | None = None,
97
+ ) -> int:
98
+ """按保留期清理明细,返回删了多少条。
99
+
100
+ 🔴 **绝不能删掉永久聚合还在依赖的唯一事实,也不能删掉去重身份。**
101
+ 删了去重身份,旧数据重放时会把永久聚合的数字加两遍,而且无法回滚。
102
+ """
103
+ ...
104
+
105
+ # -- 当前值 ----------------------------------------------------------
106
+
107
+ def get_current(
108
+ self, *, subject_id: str, signals: Sequence[str],
109
+ ) -> dict[str, Sequence[CurrentProjection]]:
110
+ """取当前值。TTL 判定不在这里做 —— 这里只负责把存的东西读出来,
111
+ "过期了算不算当前"由查询层按 manifest 判。
112
+ """
113
+ ...
114
+
115
+ def compare_and_put_current(
116
+ self, projection: CurrentProjection, *, expected_version: int,
117
+ ) -> bool:
118
+ """乐观并发地写当前值。版本对不上返回 ``False``,调用方重读重试。
119
+
120
+ 为什么不是简单的 upsert:两条并发上报(一条新一条旧)同时到达时,
121
+ 简单覆盖的结果取决于谁后写完 —— 旧数据可能赢。带版本号才能保证
122
+ "只有一个胜者,而且是应该赢的那个"。
123
+ """
124
+ ...
125
+
126
+ # -- 聚合 ------------------------------------------------------------
127
+
128
+ def get_aggregate(
129
+ self, *, subject_id: str, signal: str,
130
+ start_date: date, end_date: date,
131
+ aggregation_kind: str | None = None,
132
+ ) -> Sequence[DailyAggregate]:
133
+ ...
134
+
135
+ def put_aggregate(self, aggregate: DailyAggregate) -> None:
136
+ """写入或替换一个聚合。
137
+
138
+ 按 ``(subject, signal, date, kind, aggregation_version)`` 覆盖 ——
139
+ 换了 ``aggregation_version`` 就是新的一份,旧的留着,**不原地改写
140
+ 旧统计的语义**。
141
+ """
142
+ ...
143
+
144
+ # -- 去重身份 --------------------------------------------------------
145
+ #
146
+ # 产品规范的端口清单里没有这两个,但它的一致性保证第 4 条("永久聚合不会
147
+ # 因重放重复累计")和第 9 条("清理不会误删 dedupe 身份")离开它们没法实现。
148
+
149
+ def remember_identity(self, identity: DurableDedupeIdentity) -> bool:
150
+ """记住"这条我处理过了"。已经记过返回 ``False``。"""
151
+ ...
152
+
153
+ def has_seen_identity(
154
+ self, *, subject_id: str, signal: str, source: str, digest: str,
155
+ ) -> bool:
156
+ """这条处理过没有。明细已按保留期删掉之后,这是唯一还能回答的东西。"""
157
+ ...
158
+
159
+ # -- 来源镜像 --------------------------------------------------------
160
+
161
+ def get_sync_state(
162
+ self, *, subject_id: str, source: str, collection_kind: str,
163
+ ) -> SourceSyncState | None:
164
+ ...
165
+
166
+ def put_sync_state(self, state: SourceSyncState) -> None:
167
+ ...
168
+
169
+ def upsert_calendar_events(
170
+ self, *, subject_id: str, events: Sequence[CalendarEventMirror],
171
+ ) -> None:
172
+ ...
173
+
174
+ def upsert_reminders(
175
+ self, *, subject_id: str, items: Sequence[ReminderItemMirror],
176
+ ) -> None:
177
+ ...
178
+
179
+ def list_calendar_events(
180
+ self, *, subject_id: str,
181
+ start: datetime | None = None, end: datetime | None = None,
182
+ limit: int = 50,
183
+ ) -> Sequence[CalendarEventMirror]:
184
+ """镜像里现在还存在的日程,按开始时间排序。
185
+
186
+ 产品规范的端口清单里只有写入没有读取 —— 但读取侧要用,不给它一个
187
+ 端口方法,实现就只能去摸具体存储的内部结构,换个宿主就静默返回空。
188
+ """
189
+ ...
190
+
191
+ def list_reminders(
192
+ self, *, subject_id: str, include_completed: bool = False, limit: int = 50,
193
+ ) -> Sequence[ReminderItemMirror]:
194
+ """镜像里现在还存在的提醒事项。理由同上。"""
195
+ ...
196
+
197
+ def apply_source_snapshot(
198
+ self, *, subject_id: str, source: str, collection_kind: str,
199
+ sync_id: str, coverage_start: datetime, coverage_end: datetime,
200
+ snapshot_kind: str,
201
+ ) -> int:
202
+ """全量同步收尾:删掉**覆盖范围内**这轮没见到的条目,返回删了几条。
203
+
204
+ 🔴 ``coverage_start`` / ``coverage_end`` 是硬边界。拿一个局部窗口去删
205
+ 窗口外的数据,是同步实现最容易犯的错,而且删完不可逆 —— 用户会发现
206
+ 自己去年的日程凭空消失了。
207
+
208
+ ``snapshot_kind`` 不是 ``full`` 时,这个方法必须什么都不删。
209
+ """
210
+ ...
211
+
212
+ # -- 规则状态 --------------------------------------------------------
213
+
214
+ def get_rule_state(
215
+ self, *, subject_id: str, definition_id: str, scope_key: str,
216
+ ) -> dict[str, Any] | None:
217
+ ...
218
+
219
+ def put_rule_state(
220
+ self, *, subject_id: str, definition_id: str, scope_key: str,
221
+ state: dict[str, Any],
222
+ ) -> None:
223
+ """写规则状态。
224
+
225
+ **必须和 ``enqueue_event`` 在同一个事务里。** 分开的话,可能出现
226
+ "状态说已经触发过了,但事件没进发件箱" —— 那这次触发就永远丢了,
227
+ 而且规则要等到下一个 scope 才会 rearm。
228
+ """
229
+ ...
230
+
231
+ # -- 事件与投递 ------------------------------------------------------
232
+
233
+ def enqueue_event(self, entry: EventOutboxEntry) -> bool:
234
+ """把事件写进待发件箱。同 ``event_id`` 已存在时返回 ``False``。
235
+
236
+ **提交成功那一刻,事件就丢不了了。** 之后崩多少次都能重投。
237
+ """
238
+ ...
239
+
240
+ def claim_pending_event(
241
+ self, *, worker_id: str, now: datetime, lease_seconds: float,
242
+ ) -> EventOutboxEntry | None:
243
+ """领一个待投递的事件,拿一个到期的租约。
244
+
245
+ 必须原子地做三件事:挑一个 ``pending``(或租约已过期的 ``claimed``)、
246
+ 置为 ``claimed``、写上 ``lease_owner`` 和 ``lease_expires_at``。
247
+
248
+ 租约过期能被别人接管,是因为原持有者可能已经死了;而"到期才接管"
249
+ 保证了正常情况下同一个事件同时只有一个 worker 在处理。
250
+ """
251
+ ...
252
+
253
+ def record_wake_receipt(
254
+ self, *, receipt: WakeReceipt, next_state: str,
255
+ claim_token: str | None = None,
256
+ next_attempt_at: datetime | None = None,
257
+ ) -> None:
258
+ """存回执并推进投递状态。返回 ``False`` 表示令牌过期、状态未改。
259
+
260
+ **必须和"兑现或释放冷却额度占位"在同一个事务里。** 分开的话,
261
+ "已送达但额度没扣"和"额度扣了但状态还是 pending"两种错都会出现,
262
+ 后者更糟:用户被打扰了两次。
263
+
264
+ **``claim_token`` 对不上时只能记审计,不能改状态。** 旧 worker 租约
265
+ 过期、事件被别人接管之后它才返回 —— 让它推进状态,等于一次超时
266
+ 变成一次错误的覆盖,而且看起来完全正常。
267
+ """
268
+ ...
269
+
270
+ def list_pending_events(
271
+ self, *, subject_id: str | None = None, limit: int = 100,
272
+ ) -> Sequence[EventOutboxEntry]:
273
+ """列出还没送达的事件。给宿主的 worker 和 backlog 告警用。"""
274
+ ...
275
+
276
+ # -- 用户数据 --------------------------------------------------------
277
+
278
+ def purge_subject(self, *, subject_id: str) -> dict[str, int]:
279
+ """删掉这个用户的全部数据,返回各类删了多少条。
280
+
281
+ 必须覆盖:观测、当前值、聚合、来源镜像、同步状态、去重身份、
282
+ 规则状态、待发件箱、回执。**漏一类就是删不干净**,而"删除我的数据"
283
+ 这件事没有部分成功。
284
+ """
285
+ ...
286
+
287
+
288
+ __all__ = ["StoragePort"]
@@ -0,0 +1,43 @@
1
+ """唤醒端口 —— 把事件交给宿主的 agent runtime。
2
+
3
+ 只有一个方法。它刻意很窄:kit 不知道也不该知道宿主是用队列、消息、
4
+ 还是直接函数调用把事件送到 runtime 的。
5
+
6
+ **戳醒 ≠ 该开口。** runtime 收下这次唤醒之后,继续睡、只看一眼、还是开口
7
+ 说话,是三个平行选项 —— 这个包不参与那个决定,也不产出任何"该说话了"式的
8
+ 措辞。回执里的 ``accepted`` 只表示"我收到了并且会处理",不表示"我会说话"。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import Protocol, runtime_checkable
13
+
14
+ from ..contracts.delivery import DeliveryAttempt
15
+ from ..contracts.event import PerceptionEvent
16
+ from ..contracts.receipt import WakeReceipt
17
+
18
+
19
+ @runtime_checkable
20
+ class WakePort(Protocol):
21
+ """宿主的 runtime 适配器。"""
22
+
23
+ def wake(self, event: PerceptionEvent, attempt: DeliveryAttempt) -> WakeReceipt:
24
+ """把一个事件交给 runtime,返回它的应答。
25
+
26
+ **实现必须按 ``event.event_id`` 幂等。** 崩溃重投是常态不是异常:
27
+ 投出去之后、回执存下来之前进程挂掉,重启后一定会再投一次。
28
+ runtime 认得这个 id 就返回 ``duplicate``,不要真的再处理一遍 ——
29
+ 否则用户会被同一件事提醒两次。
30
+
31
+ ``attempt`` 带着"这是第几次"。``event_id`` 跨重试不变,
32
+ ``attempt_id`` 每次都变 —— 后者用来在日志里分辨"第三次重试失败"
33
+ 和"三个并发投递失败"。
34
+
35
+ **不要在这里抛异常表示"runtime 拒绝"** —— 拒绝是一种正常应答,
36
+ 用 ``rejected`` / ``conversation_suppressed`` 表达。异常留给
37
+ 真正的意外(连接断了、序列化失败),调用方会把它当作
38
+ ``enqueue_failed`` 处理并安排重试。
39
+ """
40
+ ...
41
+
42
+
43
+ __all__ = ["WakePort"]