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
perceptkit/__init__.py ADDED
@@ -0,0 +1,85 @@
1
+ """perceptkit —— 主动感知内核:纯函数、零 I/O,与宿主环境无关。
2
+
3
+ 这个包只做判断,不做执行:
4
+
5
+ · 有没有发生什么值得留意的事(glance,只出 bool,不泄露具体数值)
6
+ · 这次变化值不值得叫醒一次 agent(wake)
7
+ · 一条测量到底是"有观测到零值"还是"压根没测到"(observation 四态)
8
+ · 一条测量该记在本地日历的哪一天(attribution)
9
+ · "连续 N 天"怎么断续、只在跨入异常时触发一次(streaks)
10
+ · 按天汇总出什么趋势 —— 波动 / 漂移 / 周期性,三种判读方式(history / trend_models)
11
+ · agent 能看哪些字段、该不该给(fields)
12
+ · 该怎么把以上这些讲给模型(prompts)
13
+ · 每类信号的历史该留多久、一条测量的去重键怎么造(retention / identity)
14
+
15
+ **wake ≠ 该开口了。** 戳醒之后继续睡 / 只看一眼 / 开口说话是三个平行选项,
16
+ 这个包不参与那个决定,也不产出任何"该说话了"式的措辞。
17
+
18
+ 不在这里的(由调用方提供):
19
+
20
+ 数据采集 · 存储 · 加解密 · 账号身份与鉴权 · 定时器 / 调度 ·
21
+ 真正调模型 · 决定 agent 最终该说什么话
22
+
23
+ 硬指标:**本包只依赖标准库**,不 import 任何宿主模块、不碰网络、不碰数据库、
24
+ 不碰文件系统。一旦这条破了,"内核可独立发布 / 可被任意宿主嵌入"就都不成立
25
+ ——见 ``tests/test_purity.py``(AST 扫描)与 ``tests/test_no_host_leakage.py``。
26
+
27
+ 详见 ``README.md``。
28
+ """
29
+ from __future__ import annotations
30
+
31
+ from . import contracts, manifest, ports, processing, rules
32
+ from .algorithms.attribution import attribute_episode, attribute_instant, split_across_midnight
33
+ from .catalog import CAPABILITIES, SIGNALS
34
+ from .contracts import IngestContext, Observation, PerceptionEvent, ReportEnvelope, WakeReceipt
35
+ from .kit import PerceptionKit
36
+ from .fields import AGENT_PERCEPTION_SIGNALS, project_signal
37
+ from .algorithms.glance import build_perception_glance
38
+ from .algorithms.history import is_historized
39
+ from .algorithms.identity import MissingIdentity, measurement_key
40
+ from .algorithms.observation import (
41
+ NO_OBSERVATION,
42
+ OBSERVED,
43
+ OBSERVED_ZERO,
44
+ UNAVAILABLE,
45
+ classify,
46
+ is_trend_eligible,
47
+ )
48
+ from .retention import retention_days, stores_history
49
+ from .algorithms.streaks import current_streak, should_trigger
50
+ from .algorithms.trend_models import model_for, wake_eligible
51
+ from .algorithms.wake import is_wake_worthy_signal, is_significant_change, should_wake
52
+
53
+ __all__ = [
54
+ # 接入口
55
+ "PerceptionKit",
56
+ "ReportEnvelope", "Observation", "PerceptionEvent", "WakeReceipt", "IngestContext",
57
+ "contracts", "manifest", "ports", "processing", "rules",
58
+ # 算法
59
+ "attribute_episode",
60
+ "attribute_instant",
61
+ "split_across_midnight",
62
+ "CAPABILITIES",
63
+ "SIGNALS",
64
+ "AGENT_PERCEPTION_SIGNALS",
65
+ "project_signal",
66
+ "build_perception_glance",
67
+ "is_historized",
68
+ "MissingIdentity",
69
+ "measurement_key",
70
+ "NO_OBSERVATION",
71
+ "OBSERVED",
72
+ "OBSERVED_ZERO",
73
+ "UNAVAILABLE",
74
+ "classify",
75
+ "is_trend_eligible",
76
+ "retention_days",
77
+ "stores_history",
78
+ "current_streak",
79
+ "should_trigger",
80
+ "model_for",
81
+ "wake_eligible",
82
+ "is_wake_worthy_signal",
83
+ "is_significant_change",
84
+ "should_wake",
85
+ ]
@@ -0,0 +1,40 @@
1
+ """纯计算 —— 给定输入算出结果,不碰存储、不碰网络、不认识任何宿主。
2
+
3
+ 产品规范 §18 要求这一层单独存在:「不能再把 contract、算法、存储、
4
+ 宿主 runtime 接线混成一层」。先前这些模块散在包的顶层,和 ``kit.py``
5
+ (装配和接线)平级,正是那句话说的情况。
6
+
7
+ **分出来不只是好看。** 这一层是唯一可以放心大改的地方 —— 它没有副作用、
8
+ 没有顺序依赖,一个函数改错了测试当场红,而不会在某个宿主的生产环境里
9
+ 变成一条静默错掉的记录。
10
+
11
+ attribution 一条观测算哪一天(跨午夜、跨时区、夏令时)
12
+ glance 把感知事实压成一组布尔,给不需要细节的调用方
13
+ history 日聚合的各种形状与合并
14
+ identity 上游给不了稳定 id 时怎么造一个确定性的
15
+ observation 三态判断(测到 / 没测到 / 不能测)
16
+ streaks 连续 N 天
17
+ trend_models 三种趋势模型的数学
18
+ wake 值不值得戳醒(**不回答该不该开口**)
19
+
20
+ **刻意留在顶层、没搬进来的四个**:``catalog`` / ``fields`` / ``retention``
21
+ 是声明表不是算法(而且正在被 manifest 取代),``prompts`` 归属还没定
22
+ (见给产品方的回复 §三)。把它们塞进 algorithms/ 只会让这个词失去意义。
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from . import ( # noqa: F401
27
+ attribution,
28
+ glance,
29
+ history,
30
+ identity,
31
+ observation,
32
+ streaks,
33
+ trend_models,
34
+ wake,
35
+ )
36
+
37
+ __all__ = [
38
+ "attribution", "glance", "history", "identity",
39
+ "observation", "streaks", "trend_models", "wake",
40
+ ]
@@ -0,0 +1,147 @@
1
+ """一条测量该算哪一天。
2
+
3
+ ★ 为什么需要这个模块(设计文档修订 D):标准里的 effective_time_frame 只描述
4
+ 「事实发生于某个点或某段区间」,它不替产品决定「算哪一天」。睡眠 23:00–07:00
5
+ 按直觉是「第二天的睡眠」,而在公司待的时长跨午夜时必须切成两天 —— 这是两条
6
+ 不同的规则,不能一刀切。
7
+
8
+ ★ 时区:一律用时间自带的 offset。缺 offset 直接报错,不猜 —— 静默按 UTC 或
9
+ 按用户当前时区重解释,会让历史数据在用户出国时集体漂一天。
10
+
11
+ ★ 零 I/O、不读时钟。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import datetime as _dt
16
+ import zoneinfo as _zoneinfo
17
+
18
+ INSTANT = "instant" # 单点:按其自带 offset 的本地日期
19
+ EPISODE_END = "episode_end" # 区间:整体归结束(醒来)那天
20
+ SPLIT_AT_MIDNIGHT = "split_at_midnight" # 可加总时长:按本地午夜切分
21
+ SOURCE_LOCAL_DATE = "source_local_date" # 周期事件:用来源记录的本地日期,不重解释
22
+
23
+ ATTRIBUTION: dict[str, str] = {
24
+ "health_sleep": EPISODE_END,
25
+ "health_workout": EPISODE_END,
26
+ "health_body": INSTANT,
27
+ "health_vitals": INSTANT,
28
+ "health_metabolic": INSTANT,
29
+ "health_activity": INSTANT,
30
+ "health_mood": INSTANT,
31
+ "health_cycle": SOURCE_LOCAL_DATE,
32
+ "location_signal": SPLIT_AT_MIDNIGHT,
33
+ "playback": SPLIT_AT_MIDNIGHT,
34
+ "motion_state": SPLIT_AT_MIDNIGHT,
35
+ "focus": SPLIT_AT_MIDNIGHT,
36
+ "audio_route": SPLIT_AT_MIDNIGHT,
37
+ # calendar_next_event / reminders:条目自带日期(来自日历/提醒事项存储),
38
+ # 按用户「当前」时区重解释会在他出国时把一场会议错移到另一天 ——
39
+ # 正是这个模块存在的目的要防的那类漂移,所以用来源本地日期,不重算。
40
+ "calendar_next_event": SOURCE_LOCAL_DATE,
41
+ "reminders": SOURCE_LOCAL_DATE,
42
+ # weather 现在的 SHAPE 是 NUMERIC_DIST(history.py),是单点测量,
43
+ # 跟体重/心率同类 —— 归属规则是 INSTANT。
44
+ # ⚠️ Codex code_review 2026-08-23 抓到:早先按"weather 即将改成仅当前+预报、
45
+ # 不再产生 rollup"的未来态把这条声明成了"故意缺席",但 SHAPE/record_daily
46
+ # 从未真的改过去,导致四张声明表互相矛盾(history 说存、retention/attribution
47
+ # 说不存)。真要把 weather 改成不存历史时,这一行、retention.RETENTION_DAYS
48
+ # 里的 weather 条目、history.SHAPE 里的 weather 条目,三处必须在同一批
49
+ # 一起删,不许只删一处。
50
+ "weather": INSTANT,
51
+ }
52
+
53
+
54
+ def _aware(raw: str) -> _dt.datetime:
55
+ """解析成带 offset 的时刻。没有 offset 就报错 —— 不猜。"""
56
+ try:
57
+ parsed = _dt.datetime.fromisoformat(str(raw))
58
+ except (TypeError, ValueError) as exc:
59
+ raise ValueError(f"无法解析时间:{raw!r}") from exc
60
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
61
+ raise ValueError(f"时间缺少时区 offset,拒绝按 UTC 或本机时区猜测:{raw!r}")
62
+ return parsed
63
+
64
+
65
+ def _utc_naive(dtobj: _dt.datetime) -> _dt.datetime:
66
+ """把一个 aware datetime 精确转成"UTC 等价的裸 datetime",用于比较/相减。
67
+
68
+ 见 ``split_across_midnight`` 里的坑注释:两个 aware datetime 若共享同一个
69
+ ``tzinfo`` 对象,CPython 的 `<`/`-` 会走一条忽略 offset 变化的捷径,对
70
+ ``ZoneInfo`` 这种 offset 会随日期变的时区算错。显式转成裸 UTC datetime
71
+ 绕开这条捷径;用 timedelta 精确运算,不经过 float epoch,避免精度损失。
72
+ """
73
+ return dtobj.replace(tzinfo=None) - dtobj.utcoffset()
74
+
75
+
76
+ def attribute_instant(when: str) -> str:
77
+ """单点测量:按它自己那个 offset 下的本地日期。"""
78
+ return _aware(when).date().isoformat()
79
+
80
+
81
+ def attribute_episode(start: str, end: str) -> str:
82
+ """区间事件(睡眠、一次运动):整体归结束那天。"""
83
+ s, e = _aware(start), _aware(end)
84
+ if e < s:
85
+ raise ValueError(f"区间结束早于开始:{start!r} -> {end!r}")
86
+ return e.date().isoformat()
87
+
88
+
89
+ def split_across_midnight(start: str, end: str, *, tz: str | None = None) -> list[tuple[str, float]]:
90
+ """可加总的时长:按本地午夜切开,返回 ``[(本地日期, 分钟数), ...]``。
91
+
92
+ ``tz``(可选):传入 IANA 时区名(如 ``"America/New_York"``)时,用
93
+ ``zoneinfo.ZoneInfo`` 按该时区的真实换日规则计算本地午夜 —— 跨夏令时
94
+ 切换的那一天会正确算出 23 小时(春季提前)或 25 小时(秋季回退),
95
+ 不会被硬当成 1440 分钟。
96
+
97
+ 不传 ``tz`` 时(默认):本地午夜用 ``start`` 自带的 offset 推算 —— 这是
98
+ 一个**固定** offset,不是一整套带换日规则的时区。★ 老实说明局限:跨夏
99
+ 令时切换的那一天,本函数并不知道当地钟表在那天真的跳了一小时,切分点
100
+ 仍按「每天 1440 分钟」机械推进,那一天算出来的分钟数会是错的(例如秋季
101
+ 回退的 25 小时天,仍会被切成 24×60)。要正确处理夏令时切换,必须显式
102
+ 传入 ``tz``。
103
+
104
+ 各段分钟数不做单独四舍五入 —— 调用方需要展示或落库时再自己 round,
105
+ 否则多段各自舍入会破坏「各段之和 = 总时长」这个不变式(例如跨午夜的
106
+ 0.08 秒会被两段各自舍成 0.001,加总 0.002,而真实值是 0.001333...)。
107
+ """
108
+ s, e = _aware(start), _aware(end)
109
+ if e < s:
110
+ raise ValueError(f"区间结束早于开始:{start!r} -> {end!r}")
111
+ if e == s:
112
+ return []
113
+
114
+ zone = _zoneinfo.ZoneInfo(tz) if tz else None
115
+ if zone is not None:
116
+ s = s.astimezone(zone)
117
+ e = e.astimezone(zone)
118
+
119
+ # ★ 坑(真实调试过,别删):下面比较/相减都拿 `_utc_naive()` 转换过的值,
120
+ # 不直接对两个 aware datetime 做 `<` / `-`。原因是 CPython 对"两个 aware
121
+ # datetime 共享同一个 tzinfo 对象"有一条捷径:直接比较墙上时间、完全不
122
+ # 重新问 tzinfo 要 offset —— 对固定 offset 的 tzinfo 这条捷径没问题
123
+ # (offset 反正不变),但 ZoneInfo 的 offset 会随日期变(换季),同一个
124
+ # ZoneInfo 对象在夏令时切换前后 offset 不同,这条捷径会把跨切换的那一段
125
+ # 整整算错一小时。`_utc_naive()` 每次都显式调用 `.utcoffset()` 拿当下
126
+ # 那个墙上时间对应的真实 offset,不吃这条捷径;且转换本身是 timedelta
127
+ # 精确运算(不经过 float epoch),不会像 `.timestamp()` 那样在秒级精度
128
+ # 之外引入浮点误差。
129
+ e_utc = _utc_naive(e)
130
+ out: list[tuple[str, float]] = []
131
+ cursor = s
132
+ cursor_utc = _utc_naive(cursor)
133
+ while cursor_utc < e_utc:
134
+ next_midnight = _dt.datetime.combine(
135
+ cursor.date() + _dt.timedelta(days=1),
136
+ _dt.time(0, 0),
137
+ tzinfo=cursor.tzinfo,
138
+ )
139
+ next_midnight_utc = _utc_naive(next_midnight)
140
+ if next_midnight_utc < e_utc:
141
+ chunk_end, chunk_end_utc = next_midnight, next_midnight_utc
142
+ else:
143
+ chunk_end, chunk_end_utc = e, e_utc
144
+ minutes = (chunk_end_utc - cursor_utc).total_seconds() / 60.0
145
+ out.append((cursor.date().isoformat(), minutes))
146
+ cursor, cursor_utc = chunk_end, chunk_end_utc
147
+ return out
@@ -0,0 +1,236 @@
1
+ """Pure, number-free projections for Runtime V2 proactive perception."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Mapping, Sequence
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import sys
9
+ from typing import Any, Callable
10
+
11
+ _HEALTH_SIGNALS = ("steps", "sleep", "workout", "vitals", "activity", "body", "metabolic", "cycle")
12
+ _HEALTH_HISTORY = frozenset({
13
+ "health_vitals", "health_sleep", "health_workout", "health_activity",
14
+ "health_body", "health_metabolic", "health_cycle",
15
+ })
16
+ _EXPIRED_MARKERS = frozenset({"expired", "stale", "is_expired", "is_stale"})
17
+ _EVENT_FIELDS = {
18
+ "unlock_after_absence": {"trigger": "unlock_after_absence", "returned_after_absence": True},
19
+ "arrived_at_anchor": {"trigger": "arrived_at_anchor", "anchor_changed": True},
20
+ "photo_added": {"trigger": "photo_added", "new_photo": True},
21
+ "scene_change": {"trigger": "scene_change"},
22
+ "broadcast_opened": {"trigger": "broadcast_opened", "screen_share_started": True},
23
+ "broadcast_closed": {"trigger": "broadcast_closed", "screen_share_ended": True},
24
+ }
25
+ V1_PRESENCE_HINT_FIELDS = (
26
+ "place_label",
27
+ "motion_state",
28
+ "now_playing",
29
+ "locale",
30
+ "broadcast_state",
31
+ "broadcast_active",
32
+ )
33
+
34
+
35
+ def _finite_number(value: Any) -> bool:
36
+ if type(value) is int:
37
+ # Preserve the projector's historical "finite float" input range
38
+ # without coercing an arbitrary-precision int through float(), which
39
+ # raises OverflowError for malformed giant values.
40
+ return -sys.float_info.max <= value <= sys.float_info.max
41
+ if type(value) is float:
42
+ return math.isfinite(value)
43
+ return False
44
+
45
+
46
+ def _text(value: Any) -> bool:
47
+ return isinstance(value, str) and bool(value.strip())
48
+
49
+
50
+ def _items(value: Any, predicate: Callable[[Any], bool]) -> bool:
51
+ return (
52
+ isinstance(value, Sequence)
53
+ and not isinstance(value, (str, bytes, bytearray))
54
+ and any(predicate(item) for item in value)
55
+ )
56
+
57
+
58
+ def _event(value: Any) -> bool:
59
+ return isinstance(value, Mapping) and any((
60
+ _text(value.get("title")),
61
+ _text(value.get("start_time")),
62
+ _finite_number(value.get("starts_in_min")),
63
+ _finite_number(value.get("minutes_until_start")),
64
+ ))
65
+
66
+
67
+ def _reminder(value: Any) -> bool:
68
+ return isinstance(value, Mapping) and any((
69
+ _text(value.get("title")),
70
+ _text(value.get("due_time")),
71
+ _text(value.get("due_date")),
72
+ type(value.get("overdue")) is bool,
73
+ ))
74
+
75
+
76
+ def _alert(value: Any) -> bool:
77
+ return isinstance(value, Mapping) and any(
78
+ _text(value.get(field)) for field in ("title", "summary", "headline", "description", "severity")
79
+ )
80
+
81
+
82
+ def _doc(signals: Mapping[str, Any], name: str) -> Mapping[str, Any]:
83
+ value = signals.get(name)
84
+ if (
85
+ not isinstance(value, Mapping)
86
+ or value.get("disabled") is True
87
+ or value.get("fresh") is False
88
+ or any(value.get(marker) is True for marker in _EXPIRED_MARKERS)
89
+ ):
90
+ return {}
91
+ return value
92
+
93
+
94
+ def _available(
95
+ doc: Mapping[str, Any],
96
+ *,
97
+ number_fields: Sequence[str] = (),
98
+ text_fields: Sequence[str] = (),
99
+ bool_fields: Sequence[str] = (),
100
+ event_fields: Sequence[str] = (),
101
+ ) -> bool:
102
+ return any(_finite_number(doc.get(field)) for field in number_fields) or any(
103
+ _text(doc.get(field)) for field in text_fields
104
+ ) or any(type(doc.get(field)) is bool for field in bool_fields) or any(
105
+ _event(doc.get(field)) for field in event_fields
106
+ )
107
+
108
+
109
+ def _positive_count(value: Any) -> bool:
110
+ return _finite_number(value) and value > 0
111
+
112
+
113
+ def build_perception_glance(
114
+ signals: Mapping[str, Mapping[str, Any]],
115
+ *,
116
+ notable_changes: Sequence[Mapping[str, Any]] = (),
117
+ ) -> dict[str, dict[str, bool]]:
118
+ safe_signals = signals if isinstance(signals, Mapping) else {}
119
+ changed = {
120
+ str(item.get("signal") or "")
121
+ for item in notable_changes
122
+ if isinstance(item, Mapping)
123
+ }
124
+ out: dict[str, dict[str, bool]] = {}
125
+ location = _doc(safe_signals, "location")
126
+ if _available(location, text_fields=("place_label", "wifi_label", "country", "locality", "wifi_anchor_id")):
127
+ out["location"] = {"available": True, "notable_change": "location_signal" in changed}
128
+ now = _doc(safe_signals, "now")
129
+ playing = now.get("now_playing")
130
+ if isinstance(playing, Mapping) and _available(playing, text_fields=("title", "artist", "album", "playback_state")):
131
+ out["media"] = {"available": True, "active": True, "notable_change": "playback" in changed}
132
+ app = _doc(safe_signals, "app")
133
+ if _available(app, text_fields=("app_name", "app_category", "app_state")):
134
+ out["app"] = {"available": True, "recent_activity": True}
135
+ health_docs = [_doc(safe_signals, name) for name in _HEALTH_SIGNALS]
136
+ if any((
137
+ _available(health_docs[0], number_fields=("step_count",)),
138
+ _available(health_docs[1], number_fields=("asleep_minutes", "core_minutes", "deep_minutes", "rem_minutes")),
139
+ _available(health_docs[2], number_fields=("duration_min", "count_today"), text_fields=("workout_type",)),
140
+ _available(health_docs[3], number_fields=("resting_heart_rate", "step_count", "current_heart_rate", "hrv_sdnn_ms", "respiratory_rate", "oxygen_saturation_pct", "vo2_max")),
141
+ _available(health_docs[4], number_fields=("active_energy_kcal", "exercise_minutes", "stand_minutes", "mindful_minutes")),
142
+ _available(health_docs[5], number_fields=("weight_kg", "bmi", "body_fat_pct", "height_cm")),
143
+ _available(health_docs[6], number_fields=("blood_glucose_mmol_l", "blood_pressure_systolic", "blood_pressure_diastolic")),
144
+ _available(health_docs[7], text_fields=("flow_level",), bool_fields=("is_active_period",)),
145
+ )):
146
+ out["health"] = {"available": True, "notable_change": bool(changed & _HEALTH_HISTORY)}
147
+ weather = _doc(safe_signals, "weather")
148
+ if _available(
149
+ weather,
150
+ number_fields=("temperature", "apparent_temperature", "humidity", "precipitation_chance", "uv_index"),
151
+ text_fields=("condition",),
152
+ bool_fields=("is_daylight",),
153
+ ) or _items(weather.get("alerts"), _alert):
154
+ out["weather"] = {"available": True, "notable_change": "weather" in changed}
155
+ mood = _doc(safe_signals, "mood")
156
+ if _available(mood, number_fields=("valence", "label_count"), text_fields=("valence_classification", "kind"), bool_fields=("recorded_today",)):
157
+ out["mood"] = {"available": True, "recorded": mood.get("recorded_today") is True}
158
+ reminders = _doc(safe_signals, "reminders")
159
+ if _available(
160
+ reminders,
161
+ number_fields=("overdue_count", "due_today_count"),
162
+ text_fields=("next_reminder",),
163
+ bool_fields=("reminders_truncated",),
164
+ ) or _items(reminders.get("reminders"), _reminder):
165
+ out["reminders"] = {
166
+ "available": True,
167
+ "has_due": _positive_count(reminders.get("due_today_count")),
168
+ "has_overdue": _positive_count(reminders.get("overdue_count")),
169
+ }
170
+ calendar = _doc(safe_signals, "calendar")
171
+ if _available(calendar, event_fields=("calendar_next_event",)) or _items(calendar.get("calendar_events"), _event):
172
+ out["calendar"] = {
173
+ "available": True,
174
+ "has_upcoming": _event(calendar.get("calendar_next_event")) or _items(calendar.get("calendar_events"), _event),
175
+ }
176
+ return out
177
+
178
+
179
+ def project_perception_wake_events(items: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
180
+ """Project the producer-bounded wake facts, excluding internal cursor data.
181
+
182
+ ``serve_worker._read_perception_wake_context`` is the trust boundary that
183
+ limits every string/list/scalar. The old projection discarded those facts
184
+ and retained only a trigger-derived constant, leaving the provider unable to
185
+ tell what actually changed. Copy only the closed field set consumed by the
186
+ prompt; never pass through arbitrary keys from storage.
187
+ """
188
+ out: list[dict[str, Any]] = []
189
+ for item in items:
190
+ if not isinstance(item, Mapping):
191
+ continue
192
+ trigger = str(item.get("trigger") or "")
193
+ if trigger not in _EVENT_FIELDS:
194
+ continue
195
+ projected: dict[str, Any] = dict(_EVENT_FIELDS[trigger])
196
+ for field, cap in (
197
+ ("wake_id", 160),
198
+ ("source", 120),
199
+ ("change_digest", 2000),
200
+ ):
201
+ value = str(item.get(field) or "")[:cap]
202
+ if value:
203
+ projected[field] = value
204
+ origin_refs = [
205
+ str(ref)[:200] for ref in list(item.get("origin_refs") or [])[:10]
206
+ ]
207
+ if origin_refs:
208
+ projected["origin_refs"] = origin_refs
209
+ raw_hints = item.get("presence_hints")
210
+ hints: dict[str, bool | int | float | str] = {}
211
+ if isinstance(raw_hints, Mapping):
212
+ for key in V1_PRESENCE_HINT_FIELDS:
213
+ value = raw_hints.get(key)
214
+ safe_key = str(key)[:80]
215
+ if isinstance(value, bool):
216
+ hints[safe_key] = value
217
+ elif isinstance(value, int):
218
+ hints[safe_key] = value
219
+ elif isinstance(value, float) and math.isfinite(value):
220
+ hints[safe_key] = value
221
+ elif isinstance(value, str):
222
+ hints[safe_key] = value[:200]
223
+ if hints:
224
+ projected["presence_hints"] = hints
225
+ if trigger == "photo_added":
226
+ for field, cap in (("photo_id", 160), ("scene", 200), ("time_of_day", 80)):
227
+ value = str(item.get(field) or "")[:cap]
228
+ if value:
229
+ projected[field] = value
230
+ out.append(projected)
231
+ return out
232
+
233
+
234
+ def perception_glance_fingerprint(glance: Mapping[str, Any]) -> str:
235
+ canonical = json.dumps(glance, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
236
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()