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.
- perceptkit/__init__.py +85 -0
- perceptkit/algorithms/__init__.py +40 -0
- perceptkit/algorithms/attribution.py +147 -0
- perceptkit/algorithms/glance.py +236 -0
- perceptkit/algorithms/history.py +663 -0
- perceptkit/algorithms/identity.py +43 -0
- perceptkit/algorithms/observation.py +44 -0
- perceptkit/algorithms/streaks.py +111 -0
- perceptkit/algorithms/trend_models.py +184 -0
- perceptkit/algorithms/wake.py +149 -0
- perceptkit/catalog.py +252 -0
- perceptkit/conformance/__init__.py +28 -0
- perceptkit/conformance/memory.py +364 -0
- perceptkit/conformance/report.py +170 -0
- perceptkit/conformance/suite.py +419 -0
- perceptkit/conformance/wake.py +151 -0
- perceptkit/contracts/__init__.py +97 -0
- perceptkit/contracts/_time.py +89 -0
- perceptkit/contracts/availability.py +77 -0
- perceptkit/contracts/context.py +50 -0
- perceptkit/contracts/delivery.py +167 -0
- perceptkit/contracts/errors.py +22 -0
- perceptkit/contracts/event.py +137 -0
- perceptkit/contracts/observation.py +172 -0
- perceptkit/contracts/receipt.py +129 -0
- perceptkit/contracts/records.py +367 -0
- perceptkit/contracts/report.py +127 -0
- perceptkit/contracts/versioning.py +63 -0
- perceptkit/fields.py +184 -0
- perceptkit/kit.py +223 -0
- perceptkit/manifest/__init__.py +57 -0
- perceptkit/manifest/checks.py +323 -0
- perceptkit/manifest/mapping.py +96 -0
- perceptkit/manifest/minimal.py +1282 -0
- perceptkit/manifest/types.py +211 -0
- perceptkit/manifest/units.py +84 -0
- perceptkit/ports/__init__.py +19 -0
- perceptkit/ports/storage.py +288 -0
- perceptkit/ports/wake.py +43 -0
- perceptkit/processing/__init__.py +49 -0
- perceptkit/processing/aggregate.py +80 -0
- perceptkit/processing/dispatch.py +356 -0
- perceptkit/processing/normalize.py +458 -0
- perceptkit/processing/pipeline.py +406 -0
- perceptkit/processing/recompute.py +170 -0
- perceptkit/processing/recurrence.py +166 -0
- perceptkit/processing/scheduled.py +233 -0
- perceptkit/prompts.py +75 -0
- perceptkit/queries/__init__.py +32 -0
- perceptkit/queries/api.py +457 -0
- perceptkit/retention.py +84 -0
- perceptkit/rules/__init__.py +19 -0
- perceptkit/rules/engine.py +112 -0
- perceptkit/rules/evaluators.py +228 -0
- perceptkit/rules/types.py +236 -0
- perceptkit-0.2.2.dist-info/METADATA +439 -0
- perceptkit-0.2.2.dist-info/RECORD +59 -0
- perceptkit-0.2.2.dist-info/WHEEL +4 -0
- perceptkit-0.2.2.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""测量的幂等键。
|
|
2
|
+
|
|
3
|
+
★ 为什么不能用测量时间(设计文档修订 B):同一时刻可以有不同设备、不同指标、
|
|
4
|
+
多条样本;同一条样本还会被修订或删除。按时间去重会同时造成误删和重复累计,
|
|
5
|
+
而 min/max/sum/count 这类累积形状一旦被重复上传污染就无法回滚 —— 后端不存
|
|
6
|
+
原始点,扣不回去。
|
|
7
|
+
|
|
8
|
+
★ 三段都必须由来源提供。缺任何一段就拒绝造键:造一个假键出来,
|
|
9
|
+
等于让这条记录每次上传都被当成新的。
|
|
10
|
+
|
|
11
|
+
★ 零 I/O、无随机、跨进程稳定(不用内置 hash(),它每进程加盐)。
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MissingIdentity(ValueError):
|
|
19
|
+
"""来源没给齐 (source, metric, sample_id),无法安全去重。"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _encode_part(value: str) -> str:
|
|
23
|
+
"""长度前缀编码单个字段:``f"{len(v)}:{v}"``。
|
|
24
|
+
|
|
25
|
+
★ 为什么不能用分隔符拼接(Codex code_review 2026-08-23 抓到):任何固定
|
|
26
|
+
分隔符都可能出现在字段内容里,造成两个不同的三元组拼出同一段字节:
|
|
27
|
+
``("s", "a\\x1fb", "c")`` 与 ``("s\\x1fa", "b", "c")`` 用 ``\\x1f`` 拼接
|
|
28
|
+
结果完全相同,无论截多少位哈希都会撞键。长度前缀是自描述的:每一段
|
|
29
|
+
自带自己的字节长度,解析在读到冒号后精确消费 N 个字节,不依赖内容
|
|
30
|
+
里不出现某个字符这条无法保证的假设——第三方适配器迟早会传入任意字节。
|
|
31
|
+
"""
|
|
32
|
+
v = value.strip()
|
|
33
|
+
return f"{len(v.encode('utf-8'))}:{v}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def measurement_key(*, source: str, metric: str, sample_id: str) -> str:
|
|
37
|
+
"""`(来源命名空间, 指标, 来源侧稳定样本 id)` -> 稳定的十六进制键。"""
|
|
38
|
+
parts = {"source": source, "metric": metric, "sample_id": sample_id}
|
|
39
|
+
missing = sorted(k for k, v in parts.items() if not str(v or "").strip())
|
|
40
|
+
if missing:
|
|
41
|
+
raise MissingIdentity(f"缺少 {missing},无法构造幂等键")
|
|
42
|
+
raw = "".join(_encode_part(str(parts[k])) for k in ("source", "metric", "sample_id"))
|
|
43
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""观测四态 —— 「没测到」和「测到是零」和「不能测」是三件不同的事。
|
|
2
|
+
|
|
3
|
+
★ 为什么要分(设计文档修订 A):HealthKit 没返回睡眠样本,可能是没戴表、
|
|
4
|
+
没授权、没同步、查询窗口不对。把这些压成一个「没数据」,下游只有两条错路:
|
|
5
|
+
当成零值 → 编造健康事实(「你昨晚没睡」);当成不存在 → 连续性判断把
|
|
6
|
+
周一三五当成连续三天。
|
|
7
|
+
|
|
8
|
+
★ 零 I/O、纯判断。
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
OBSERVED = "observed" # 有测量值
|
|
13
|
+
OBSERVED_ZERO = "observed_zero" # 来源明确记录为零(步数/活动量会有;睡眠几乎不会)
|
|
14
|
+
NO_OBSERVATION = "no_observation" # 查询成功但无样本 —— 「没戴表」和「没睡」都长这样
|
|
15
|
+
UNAVAILABLE = "unavailable" # 未授权 / 查询失败 / 尚未同步
|
|
16
|
+
|
|
17
|
+
# 只有这两种可以进数值趋势。其余是 coverage 信息,不是数值。
|
|
18
|
+
TREND_ELIGIBLE: frozenset[str] = frozenset({OBSERVED, OBSERVED_ZERO})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def classify(value, *, source_reported_zero: bool = False, available: bool = True) -> str:
|
|
22
|
+
"""把一次取数的结果归到四态之一。
|
|
23
|
+
|
|
24
|
+
``available=False`` 优先级最高:拿不到授权时即使带了值也不能采信。
|
|
25
|
+
``source_reported_zero`` 必须由来源背书 —— 我们不从 ``value == 0``
|
|
26
|
+
自作主张推断,因为多数指标的 0 只是一个普通数值。
|
|
27
|
+
"""
|
|
28
|
+
if not available:
|
|
29
|
+
return UNAVAILABLE
|
|
30
|
+
if value is None:
|
|
31
|
+
return NO_OBSERVATION
|
|
32
|
+
if source_reported_zero:
|
|
33
|
+
return OBSERVED_ZERO
|
|
34
|
+
return OBSERVED
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_trend_eligible(state: str) -> bool:
|
|
38
|
+
"""这一天的观测能不能作为一个数值参与趋势。未知状态一律不能。"""
|
|
39
|
+
return state in TREND_ELIGIBLE
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def breaks_streak(state: str) -> bool:
|
|
43
|
+
"""这一天会不会打断「连续 N 天」。未知状态一律打断(往安全那边倒)。"""
|
|
44
|
+
return state not in TREND_ELIGIBLE
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""「连续 N 天偏离」到底怎么算。
|
|
2
|
+
|
|
3
|
+
★ 四个坑(设计文档修订 G,第④条 2026-08-23 review 补):
|
|
4
|
+
① 按「最近 N 行」算 —— 历史读法会丢掉空日,于是周一三五被排在一起,
|
|
5
|
+
看起来像连续三天。必须按日历日期判相邻。
|
|
6
|
+
② 把「没数据」当成「正常」或「继续」—— 没戴表的那天既不能算偏离,
|
|
7
|
+
也不能让连续性跨过去。
|
|
8
|
+
③ 异常持续超过冷却期就再叫一次 —— 同一个 episode 会被反复播报。
|
|
9
|
+
所以是 edge-trigger:只在从正常跨入异常那一刻叫。
|
|
10
|
+
④ 信任调用方「一定是按日期升序传入」—— 一旦上游(比如读历史表那层)的
|
|
11
|
+
排序出了问题,会安静地数错,而不是报错或归零。所以 ``current_streak``
|
|
12
|
+
的输入契约是顺序无关的:自己先排序再走,不假设调用方给对了。
|
|
13
|
+
|
|
14
|
+
★ 零 I/O、不读时钟:日期与状态都由调用方给。
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Mapping, Sequence
|
|
19
|
+
import datetime as _dt
|
|
20
|
+
from typing import NamedTuple
|
|
21
|
+
|
|
22
|
+
from .observation import breaks_streak
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Trigger(NamedTuple):
|
|
26
|
+
"""``should_trigger`` 的返回值。
|
|
27
|
+
|
|
28
|
+
★ 为什么加 ``next_firing``(Codex code_review 2026-08-23 抓到):
|
|
29
|
+
旧签名只返回 ``(fire, reason)``,``already_firing`` 这个锁存状态完全
|
|
30
|
+
交给调用方自己维护——但调用方唯一能看到的信号就是这个返回值,如果
|
|
31
|
+
不把"这段异常有没有结束"算在这里,调用方要么手写一套重复的判断
|
|
32
|
+
(容易和这里的日历/edge-trigger 逻辑漂移),要么干脆一直传
|
|
33
|
+
``already_firing=True`` 直到手动清掉,锁存了就再也打不开——
|
|
34
|
+
「恢复过再复发」永远叫不出第二次。
|
|
35
|
+
现在锁存的开合由 ``current_streak`` 算出来,调用方只需要把
|
|
36
|
+
``next_firing`` 原样存起来、下次调用原样传回来。
|
|
37
|
+
"""
|
|
38
|
+
fire: bool
|
|
39
|
+
reason: str
|
|
40
|
+
next_firing: bool
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _date(raw) -> _dt.date | None:
|
|
44
|
+
try:
|
|
45
|
+
return _dt.date.fromisoformat(str(raw)[:10])
|
|
46
|
+
except (TypeError, ValueError):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sort_key(row) -> tuple[bool, _dt.date]:
|
|
51
|
+
"""结构损坏的行(非 dict、日期解析不了)统一排到最后,让它们在倒序
|
|
52
|
+
遍历时第一个被撞见 —— 与「结构损坏即不可信」的降级方式保持一致。"""
|
|
53
|
+
d = _date(row.get("date")) if isinstance(row, Mapping) else None
|
|
54
|
+
return (d is None, d or _dt.date.min)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def current_streak(days: Sequence[Mapping]) -> int:
|
|
58
|
+
"""从最后一天往回数,连续的「有观测且偏离」有几天。
|
|
59
|
+
|
|
60
|
+
``days``:``[{"date", "state", "abnormal"}]``,顺序无关 —— 不依赖调用方
|
|
61
|
+
保证升序,函数自己先按日期排序再从最近一天往回走。结构损坏的行(非
|
|
62
|
+
dict、日期解析不了)排到最后,一旦撞见就安全降级、返回 0,而不是抛错
|
|
63
|
+
或凭空报出一个看似合理实则算错的数字。
|
|
64
|
+
日历上不相邻、观测缺失、或不偏离,三者任一都终止计数。
|
|
65
|
+
"""
|
|
66
|
+
ordered = sorted(list(days or ()), key=_sort_key)
|
|
67
|
+
count = 0
|
|
68
|
+
expected: _dt.date | None = None
|
|
69
|
+
for row in reversed(ordered):
|
|
70
|
+
if not isinstance(row, Mapping):
|
|
71
|
+
break
|
|
72
|
+
d = _date(row.get("date"))
|
|
73
|
+
if d is None:
|
|
74
|
+
break
|
|
75
|
+
if expected is not None and d != expected:
|
|
76
|
+
break # 日历上断开了
|
|
77
|
+
if breaks_streak(str(row.get("state") or "")):
|
|
78
|
+
break # 没观测:不算偏离,也不许跨过去
|
|
79
|
+
if not row.get("abnormal"):
|
|
80
|
+
break # 回到正常
|
|
81
|
+
count += 1
|
|
82
|
+
expected = d - _dt.timedelta(days=1)
|
|
83
|
+
return count
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def should_trigger(
|
|
87
|
+
days: Sequence[Mapping],
|
|
88
|
+
*,
|
|
89
|
+
min_days: int,
|
|
90
|
+
already_firing: bool,
|
|
91
|
+
) -> Trigger:
|
|
92
|
+
"""要不要为这段连续偏离发一次事件,以及调用方下次该存的锁存状态。
|
|
93
|
+
|
|
94
|
+
``already_firing``:调用方记录的「这一段异常是否已经叫过」。它就是
|
|
95
|
+
hysteresis —— 只有恢复过(连续中断)之后再次达标,才算新事件。
|
|
96
|
+
返回的 ``reason`` 是给日志和回执用的机器可读串,不是给模型看的措辞。
|
|
97
|
+
|
|
98
|
+
``already_firing=True`` 时不会无条件一直锁着:会用 ``current_streak``
|
|
99
|
+
检查这段异常是不是已经结束(回到 0)——已经结束就把 ``next_firing``
|
|
100
|
+
复位成 ``False``,这样调用方下次传回来就能重新触发,而不是永久锁死。
|
|
101
|
+
复位本身不叫醒(那一刻还没有新的连续异常),真正的下一次叫醒要等
|
|
102
|
+
新一段异常重新攒够 ``min_days``。
|
|
103
|
+
"""
|
|
104
|
+
streak = current_streak(days)
|
|
105
|
+
if already_firing:
|
|
106
|
+
if streak == 0:
|
|
107
|
+
return Trigger(False, "recovered", False)
|
|
108
|
+
return Trigger(False, "already_firing", True)
|
|
109
|
+
if streak < max(1, int(min_days)):
|
|
110
|
+
return Trigger(False, "streak_too_short", False)
|
|
111
|
+
return Trigger(True, "streak_reached", True)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""趋势按三类模型分开算。
|
|
2
|
+
|
|
3
|
+
★ 为什么不能一套算法走天下(这是本模块存在的全部理由):
|
|
4
|
+
现有 read_trend 是「跟最近 N 天的中位数比」,它只适合围绕一个稳定值波动的量。
|
|
5
|
+
体重一年从 80 掉到 60,用它会得出「你比平时轻 10 公斤」——而这个人
|
|
6
|
+
从来没有一个叫「平时」的体重。周期性的量(经期)同样不适用。
|
|
7
|
+
|
|
8
|
+
★ 零 I/O、纯函数:日期只做字符串解析与差值,不取当前时间。
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
import datetime as _dt
|
|
14
|
+
|
|
15
|
+
FLUCTUATING = "fluctuating" # 有「平时水平」,偏离才是信号
|
|
16
|
+
DRIFTING = "drifting" # 没有「平时水平」,方向与速率才是信号
|
|
17
|
+
CYCLICAL = "cyclical" # 看间隔,不看数值高低
|
|
18
|
+
|
|
19
|
+
# signal -> 模型。未列出的信号沿用波动型(现有 read_trend 的行为)。
|
|
20
|
+
TREND_MODEL: dict[str, str] = {
|
|
21
|
+
"health_sleep": FLUCTUATING,
|
|
22
|
+
"health_vitals": FLUCTUATING,
|
|
23
|
+
"health_activity": FLUCTUATING,
|
|
24
|
+
"health_body": DRIFTING,
|
|
25
|
+
"health_cycle": CYCLICAL,
|
|
26
|
+
# 只查询、不叫醒(见下方 QUERY_ONLY):血糖/血压/当前心率
|
|
27
|
+
"health_metabolic": FLUCTUATING,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# 允许进入主动叫醒的指标必须同时满足:有模型 + 不在这张表里。
|
|
31
|
+
#
|
|
32
|
+
# 血糖、血压、当前心率缺餐前/餐后、体位、运动关系与稳定采样协议,
|
|
33
|
+
# 这些数不能直接做同质趋势比较 —— 给 agent 查得到,但不主动打扰。
|
|
34
|
+
QUERY_ONLY: frozenset[str] = frozenset({
|
|
35
|
+
"health_metabolic",
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
# 字段级的"只查询、不叫醒"——比 QUERY_ONLY 更细一档(Codex code_review
|
|
39
|
+
# 2026-08-23 抓到):当前心率不是一个独立信号,是 health_vitals 信号里的
|
|
40
|
+
# 一个字段;而 health_vitals 整体是 FLUCTUATING 且不在 QUERY_ONLY 里,
|
|
41
|
+
# 单看"有模型 + 不在 QUERY_ONLY"会让当前心率被判定成可以叫醒——但它每次
|
|
42
|
+
# 心跳都在变,跟血糖血压一样缺采样协议,不该拿来触发主动打扰。
|
|
43
|
+
# 存 (signal, field) 二元组,不是单独一张 field 名单:同名字段换了信号
|
|
44
|
+
# 语境可能就该叫醒,必须连着信号一起认。
|
|
45
|
+
QUERY_ONLY_FIELDS: frozenset[tuple[str, str]] = frozenset({
|
|
46
|
+
("health_vitals", "current_heart_rate"),
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
# 不参与趋势的派生量 / 常量。
|
|
50
|
+
#
|
|
51
|
+
# BMI 是体重的派生量:体重掉了 BMI 必然跟着掉,两个都叫醒等于同一件事叫两次。
|
|
52
|
+
# 成人身高基本是常量,趋势无意义。
|
|
53
|
+
DERIVED_OR_CONSTANT_FIELDS: frozenset[str] = frozenset({
|
|
54
|
+
"bmi",
|
|
55
|
+
"height_cm",
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def wake_eligible(signal: str, field: str | None = None) -> bool:
|
|
60
|
+
"""这个 (signal, field) 允不允许触发主动叫醒 —— 唯一的判定入口。
|
|
61
|
+
|
|
62
|
+
``QUERY_ONLY``(整个信号只查询)、``QUERY_ONLY_FIELDS``(信号内单个字段
|
|
63
|
+
只查询,如当前心率)、``DERIVED_OR_CONSTANT_FIELDS``(派生量/常量字段,
|
|
64
|
+
与信号无关)三张表一起查——接线层不该自己拼「有模型 + 不在 QUERY_ONLY」
|
|
65
|
+
这条逻辑,那样会漏掉字段级例外。``field`` 缺省时只判信号级。
|
|
66
|
+
"""
|
|
67
|
+
if field is not None and field in DERIVED_OR_CONSTANT_FIELDS:
|
|
68
|
+
return False
|
|
69
|
+
if signal in QUERY_ONLY:
|
|
70
|
+
return False
|
|
71
|
+
if field is not None and (signal, field) in QUERY_ONLY_FIELDS:
|
|
72
|
+
return False
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
_DAYS_PER_MONTH = 30.4375 # 365.25 / 12,避免月份长度参差
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def model_for(signal: str) -> str:
|
|
79
|
+
return TREND_MODEL.get(signal, FLUCTUATING)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _date(raw) -> _dt.date | None:
|
|
83
|
+
try:
|
|
84
|
+
return _dt.date.fromisoformat(str(raw)[:10])
|
|
85
|
+
except (TypeError, ValueError):
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _numeric(v) -> float | None:
|
|
90
|
+
if isinstance(v, bool):
|
|
91
|
+
return None
|
|
92
|
+
if isinstance(v, (int, float)):
|
|
93
|
+
return float(v)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _points(rows: Sequence[Mapping], field: str | None) -> list[tuple[_dt.date, float]]:
|
|
98
|
+
out: list[tuple[_dt.date, float]] = []
|
|
99
|
+
for row in rows or ():
|
|
100
|
+
if not isinstance(row, Mapping):
|
|
101
|
+
continue
|
|
102
|
+
d = _date(row.get("date"))
|
|
103
|
+
doc = row.get("doc")
|
|
104
|
+
v = _numeric(doc.get(field)) if (isinstance(doc, Mapping) and field) else None
|
|
105
|
+
if d is not None and v is not None:
|
|
106
|
+
out.append((d, v))
|
|
107
|
+
out.sort(key=lambda p: p[0])
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _rate_per_month(points: list[tuple[_dt.date, float]]) -> float | None:
|
|
112
|
+
if len(points) < 2:
|
|
113
|
+
return None
|
|
114
|
+
span_days = (points[-1][0] - points[0][0]).days
|
|
115
|
+
if span_days <= 0:
|
|
116
|
+
return None
|
|
117
|
+
delta = points[-1][1] - points[0][1]
|
|
118
|
+
return round(delta / (span_days / _DAYS_PER_MONTH), 3)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def read_drift(rows: Sequence[Mapping], signal: str, field: str | None = None) -> dict:
|
|
122
|
+
"""漂移型:起止对比 + 每月变化速率 + 近期是否在加速。"""
|
|
123
|
+
points = _points(rows, field)
|
|
124
|
+
first = {"date": points[0][0].isoformat(), "value": points[0][1]} if points else None
|
|
125
|
+
last = {"date": points[-1][0].isoformat(), "value": points[-1][1]} if points else None
|
|
126
|
+
total = round(points[-1][1] - points[0][1], 3) if len(points) >= 2 else (
|
|
127
|
+
0.0 if len(points) == 1 else None
|
|
128
|
+
)
|
|
129
|
+
overall = _rate_per_month(points)
|
|
130
|
+
|
|
131
|
+
# 近期速率:最后三分之一的点(至少 2 个)
|
|
132
|
+
recent = None
|
|
133
|
+
if len(points) >= 4:
|
|
134
|
+
recent = _rate_per_month(points[-max(2, len(points) // 3):])
|
|
135
|
+
|
|
136
|
+
accelerating = False
|
|
137
|
+
if overall is not None and recent is not None and overall != 0.0:
|
|
138
|
+
# 同向且更陡才算加速;反向或走平都不算
|
|
139
|
+
accelerating = (recent * overall > 0) and (abs(recent) > abs(overall))
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
"signal": signal,
|
|
143
|
+
"field": field,
|
|
144
|
+
"model": DRIFTING,
|
|
145
|
+
"first": first,
|
|
146
|
+
"last": last,
|
|
147
|
+
"total_delta": total,
|
|
148
|
+
"per_month": overall,
|
|
149
|
+
"recent_per_month": recent,
|
|
150
|
+
"accelerating": accelerating,
|
|
151
|
+
"n": len(points),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def read_cycles(events: Sequence[str], *, today: str) -> dict:
|
|
156
|
+
"""周期型:看相邻两次的间隔,以及这次是否比往常推迟。
|
|
157
|
+
|
|
158
|
+
``events`` 是事件日期(升序或乱序都行,内部会排序去重);
|
|
159
|
+
``today`` 由调用方传入 —— 内核不取当前时间。
|
|
160
|
+
"""
|
|
161
|
+
dates = sorted({d for d in (_date(e) for e in (events or ())) if d is not None})
|
|
162
|
+
now = _date(today)
|
|
163
|
+
|
|
164
|
+
intervals = [(b - a).days for a, b in zip(dates, dates[1:])]
|
|
165
|
+
typical = None
|
|
166
|
+
if intervals:
|
|
167
|
+
ordered = sorted(intervals)
|
|
168
|
+
mid = len(ordered) // 2
|
|
169
|
+
typical = (ordered[mid] if len(ordered) % 2
|
|
170
|
+
else round((ordered[mid - 1] + ordered[mid]) / 2))
|
|
171
|
+
|
|
172
|
+
days_since = (now - dates[-1]).days if (now is not None and dates) else None
|
|
173
|
+
overdue = None
|
|
174
|
+
if typical is not None and days_since is not None:
|
|
175
|
+
overdue = max(0, days_since - typical)
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
"model": CYCLICAL,
|
|
179
|
+
"intervals": intervals,
|
|
180
|
+
"typical_interval": typical,
|
|
181
|
+
"days_since_last": days_since,
|
|
182
|
+
"overdue_by": overdue,
|
|
183
|
+
"n": len(dates),
|
|
184
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""「这次上报算不算一件事」「值不值得戳一下 agent」的纯判据。
|
|
2
|
+
|
|
3
|
+
★ wake ≠ 该开口了。这里只回答要不要戳;戳醒之后 agent 继续睡 / 只看一眼 /
|
|
4
|
+
开口说话,是三个平行选项,内核不参与。``should_wake`` 的第二个返回值是
|
|
5
|
+
**给日志和回执用的机器可读原因**,不是给模型看的措辞,更不是「该说什么」。
|
|
6
|
+
|
|
7
|
+
★ 零 I/O:不查库、不看时钟、不发 metrics。时间由调用方传进来
|
|
8
|
+
(``now`` / ``last_wake_ts`` / ``observed`` / ``previous_seen``),
|
|
9
|
+
这样才可测、才能被任意宿主复用。
|
|
10
|
+
|
|
11
|
+
★ 判据搬过来,机制一行不动:事务、行锁、指纹比对这类「怎么保证不重复触发」的
|
|
12
|
+
机制,全部留在宿主那一侧。内核只回答「算不算 / 值不值得」,不管怎么落地。
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# 感知叫醒源
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# 🔴 **刻意不叫 "wake kind"。** 宿主自己的运行时往往已经有别的、含义不同的
|
|
22
|
+
# "wake kind" 概念——比如「这次叫醒走哪条投递通道」,或者「哪几类叫醒要
|
|
23
|
+
# 互相防撞去重」。这些都是宿主接线层的关注点,和这里要回答的问题不是一件事:
|
|
24
|
+
#
|
|
25
|
+
# 宿主·投递通道选择 —— 「这次叫醒该走哪条路径送出去」
|
|
26
|
+
# 宿主·防撞分类 —— 「哪几类叫醒要互相防重复」
|
|
27
|
+
# 本模块 `PERCEPTION_WAKE_SOURCES` —— 「这次戳是被什么感知到的」
|
|
28
|
+
#
|
|
29
|
+
# 三者关注的问题不同,字面上即使有重叠的词(比如都用到 "screen_watch"
|
|
30
|
+
# 这个名字),含义也不能互换。本模块这套讲的是**感知来源**,不是运行时
|
|
31
|
+
# 通道、也不是防撞分类,故用 `PERCEPTION_WAKE_SOURCES` 这个名字,和宿主
|
|
32
|
+
# 自己那几套划清界限——接线时不要为了「统一命名」反过来去改宿主已有的
|
|
33
|
+
# 契约,那是两件独立的事。
|
|
34
|
+
#
|
|
35
|
+
# 下面五个是感知来源的语义分类,各自对应「一类可能触发叫醒的信号变化」:
|
|
36
|
+
#
|
|
37
|
+
# source 语义
|
|
38
|
+
# ------------ ---------------------------------------------
|
|
39
|
+
# arrival 到达某个持久锚点附近(比如常去的地点)
|
|
40
|
+
# unlock 长时间静默后重新解锁
|
|
41
|
+
# photo 新增照片
|
|
42
|
+
# screen_watch 屏幕内容/场景发生显著变化
|
|
43
|
+
# broadcast 一段广播式状态开始或结束
|
|
44
|
+
#
|
|
45
|
+
# broadcast 没有独立开关——宿主实现里可能把它挂在 screen_watch 那个总开关下;
|
|
46
|
+
# 但它在信号目录里是独立的一个 wake capability(自带独立的 debounce),所以
|
|
47
|
+
# 去重要分开算。两件事,都保留。
|
|
48
|
+
PERCEPTION_WAKE_SOURCES: tuple[str, ...] = (
|
|
49
|
+
"arrival",
|
|
50
|
+
"unlock",
|
|
51
|
+
"photo",
|
|
52
|
+
"screen_watch",
|
|
53
|
+
"broadcast",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# 「值变了算不算一件事」
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# 这份名单的语义是**默认算数 + 一张明确的否决名单**,不是「白名单里才算」——
|
|
61
|
+
# 真正的叫醒源(photo_added / screen_phash / unlock_after_absence / 几个
|
|
62
|
+
# anchor 类信号 / broadcast_state)压根不在信号目录(catalog)的 SIGNALS
|
|
63
|
+
# 表里(那张表装的是设备上报字段的 key,两套词表交集为空),用白名单会把
|
|
64
|
+
# 每一个真实叫醒源都判成「不算」——这是一个真实踩过的坑:换成白名单实现后,
|
|
65
|
+
# 会静默停发全部叫醒事件,而当时的回归测试全绿,因为没有一条测试直接对着
|
|
66
|
+
# `is_wake_worthy_signal` 断言过真实叫醒信号该返回 True。
|
|
67
|
+
#
|
|
68
|
+
# motion 的特例:它是可拉取的上下文,但变得太频繁,故意不作为叫醒源。
|
|
69
|
+
NOT_WAKE_WORTHY_SIGNALS: frozenset[str] = frozenset({
|
|
70
|
+
"motion_state",
|
|
71
|
+
"battery",
|
|
72
|
+
"now_playing",
|
|
73
|
+
"time",
|
|
74
|
+
"place_label",
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_wake_worthy_signal(signal: str) -> bool:
|
|
79
|
+
"""这个信号变了,值不值得发一次叫醒事件(不问值本身变没变)。
|
|
80
|
+
|
|
81
|
+
给已经在别处 durable 地判完「变没变」的调用方用——调用方自己负责判断
|
|
82
|
+
「这次上报和上一次相比是否真的不同」(比如做指纹比对),这里只回答
|
|
83
|
+
「即使真的变了,这类信号是否值得为此叫醒一次」。
|
|
84
|
+
"""
|
|
85
|
+
return signal not in NOT_WAKE_WORTHY_SIGNALS
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_significant_change(signal: str, previous, current) -> bool:
|
|
89
|
+
"""值变了、且这个信号的变化本身值得注意,才算一件事。
|
|
90
|
+
|
|
91
|
+
调用方同时握着新旧两个值时用这个;只握着「变没变」这个结论时用
|
|
92
|
+
``is_wake_worthy_signal``。两者是同一条判据的两半,不是两套。
|
|
93
|
+
"""
|
|
94
|
+
if previous == current:
|
|
95
|
+
return False
|
|
96
|
+
return is_wake_worthy_signal(signal)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# 「这条上报是不是迟到 / 撞点了」
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# 纯粹的先后判断。调用方通常是在加锁读出上一条记录的时间戳之后调它——
|
|
103
|
+
# **锁、事务、指纹比对这些机制都留在宿主**,内核只回答先后关系。
|
|
104
|
+
OBSERVATION_STALE = "stale" # 比上一条还早:迟到的乱序上报
|
|
105
|
+
OBSERVATION_SAME_TS = "same_ts" # 和上一条同一时刻:可能是重复,也可能是撞点冲突
|
|
106
|
+
OBSERVATION_NEWER = "newer" # 比上一条新:正常的下一条
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def observation_order(observed, previous_seen) -> str:
|
|
110
|
+
"""比较两个时刻,返回 ``stale`` / ``same_ts`` / ``newer`` 之一。
|
|
111
|
+
|
|
112
|
+
只用 ``<`` 和 ``==``,对 float 和 tz-aware datetime 都成立;不做任何
|
|
113
|
+
转换,免得把调用方原本的比较语义改掉。
|
|
114
|
+
"""
|
|
115
|
+
if observed < previous_seen:
|
|
116
|
+
return OBSERVATION_STALE
|
|
117
|
+
if observed == previous_seen:
|
|
118
|
+
return OBSERVATION_SAME_TS
|
|
119
|
+
return OBSERVATION_NEWER
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# 「值不值得戳一下 agent」
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# ⚠️ 接线提醒:下面这几个原因串是**内核自己的词**(``source_disabled`` /
|
|
126
|
+
# ``debounced``),宿主如果已经有一套用户可见的原因字符串在用(比如按
|
|
127
|
+
# source 分开命名、或写进事件流 / 审计日志),接线时要先决定「统一成一套」
|
|
128
|
+
# 还是「维护一张映射表」——这属于用户可见的行为变更,不要在接线时顺手改掉。
|
|
129
|
+
def should_wake(
|
|
130
|
+
source: str,
|
|
131
|
+
*,
|
|
132
|
+
enabled_sources: Sequence[str],
|
|
133
|
+
last_wake_ts: float | None,
|
|
134
|
+
now: float,
|
|
135
|
+
debounce_sec: float,
|
|
136
|
+
) -> tuple[bool, str]:
|
|
137
|
+
"""返回 ``(要不要戳, 原因)``。
|
|
138
|
+
|
|
139
|
+
原因是给日志和回执用的机器可读短语,**不是给模型看的**:这里不产出、不暗示
|
|
140
|
+
任何跟「该说什么」有关的东西。戳醒之后 agent 接着睡、只查一个工具、还是开口
|
|
141
|
+
说话,是三个平行且同等合法的结局,内核不参与那个决定。
|
|
142
|
+
"""
|
|
143
|
+
if source not in PERCEPTION_WAKE_SOURCES:
|
|
144
|
+
return False, "unknown_source"
|
|
145
|
+
if source not in tuple(enabled_sources or ()):
|
|
146
|
+
return False, "source_disabled"
|
|
147
|
+
if last_wake_ts is not None and (now - last_wake_ts) < debounce_sec:
|
|
148
|
+
return False, "debounced"
|
|
149
|
+
return True, source
|