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,663 @@
|
|
|
1
|
+
"""Quantitative perception history — incremental daily aggregation (Tier 2).
|
|
2
|
+
|
|
3
|
+
Field-agnostic by design: each signal declares ONE *shape* in ``SHAPE``; a
|
|
4
|
+
per-shape merge function folds new observations into the running daily doc.
|
|
5
|
+
Adding a field flows through automatically (numeric fields are discovered
|
|
6
|
+
from the values dict); adding a signal is one ``SHAPE`` line. There is no
|
|
7
|
+
per-field list to keep in sync with the client — exactly so a field rename
|
|
8
|
+
on the client side never breaks history again.
|
|
9
|
+
|
|
10
|
+
Everything here is a PURE function (no DB / no I/O): ``record_daily`` takes the
|
|
11
|
+
previous day-doc + a new observation and returns the next day-doc;
|
|
12
|
+
``read_trend`` derives a baseline/delta from a list of day-docs. Storage, the
|
|
13
|
+
ingest hook, and the read endpoint are the host's job to wire in separately —
|
|
14
|
+
that's exactly what keeps the math unit-testable without a real database.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
import math
|
|
21
|
+
from typing import Any
|
|
22
|
+
from zoneinfo import ZoneInfo
|
|
23
|
+
|
|
24
|
+
from .. import catalog
|
|
25
|
+
|
|
26
|
+
# --- shapes -----------------------------------------------------------------
|
|
27
|
+
NUMERIC_DIST = "numeric_dist" # per numeric field: min/max/sum/count -> avg
|
|
28
|
+
CUMULATIVE = "cumulative" # per numeric field: running max (= daily total)
|
|
29
|
+
MAIN_OF_DAY = "main_of_day" # latest non-null point values (replace)
|
|
30
|
+
DURATION_BY_STATE = "duration_by_state" # minutes spent in each categorical state
|
|
31
|
+
EVENT_LIST = "event_list" # discrete items, deduped by id/key
|
|
32
|
+
SUBJECTIVE = "subjective" # append each self-report entry
|
|
33
|
+
PLACE_DWELL = "place_dwell" # minutes spent at each place label
|
|
34
|
+
TALLY = "tally" # daily digest: total minutes + top artists/tracks
|
|
35
|
+
|
|
36
|
+
_TALLY_CAP = 30 # keep only the top-N artists/tracks per day
|
|
37
|
+
|
|
38
|
+
# Signal (canonical catalog input key) -> shape. ONE line per signal; fields are
|
|
39
|
+
# discovered from the observation. Signals absent here are NOT historized
|
|
40
|
+
# (pure-instant / no daily pattern: time, battery, broadcast, now, app).
|
|
41
|
+
SHAPE: dict[str, str] = {
|
|
42
|
+
"health_vitals": NUMERIC_DIST,
|
|
43
|
+
"health_metabolic": NUMERIC_DIST,
|
|
44
|
+
"weather": NUMERIC_DIST,
|
|
45
|
+
"health_activity": CUMULATIVE,
|
|
46
|
+
"health_sleep": MAIN_OF_DAY,
|
|
47
|
+
"health_body": MAIN_OF_DAY,
|
|
48
|
+
"health_cycle": MAIN_OF_DAY,
|
|
49
|
+
"health_mood": SUBJECTIVE,
|
|
50
|
+
"motion_state": DURATION_BY_STATE,
|
|
51
|
+
"focus": DURATION_BY_STATE,
|
|
52
|
+
"audio_route": DURATION_BY_STATE,
|
|
53
|
+
"location_signal": PLACE_DWELL,
|
|
54
|
+
"playback": TALLY,
|
|
55
|
+
"health_workout": EVENT_LIST,
|
|
56
|
+
"calendar_next_event": EVENT_LIST,
|
|
57
|
+
"reminders": EVENT_LIST,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# The single categorical field that names the "state" for duration/place shapes.
|
|
61
|
+
# (Field-agnostic everywhere else; these two shapes need to know which key is the
|
|
62
|
+
# state label vs. the timestamp accounting.)
|
|
63
|
+
_STATE_FIELD = {
|
|
64
|
+
"motion_state": "motion_state",
|
|
65
|
+
"focus": "in_focus",
|
|
66
|
+
"audio_route": "output_type",
|
|
67
|
+
"location_signal": "place_label",
|
|
68
|
+
}
|
|
69
|
+
# Numeric fields that are cumulative-within-the-day (monotonic), so their daily
|
|
70
|
+
# representative is max(=total), not the average. Read-side hint only — they
|
|
71
|
+
# still aggregate through numeric_dist's {min,max,sum,count}.
|
|
72
|
+
_NUMERIC_MAX_FIELDS = {"step_count"}
|
|
73
|
+
_COMPARABLE_SHAPES = {NUMERIC_DIST, CUMULATIVE, MAIN_OF_DAY}
|
|
74
|
+
_SIGNIFICANCE_FLOOR = 1.0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_historized(signal: str) -> bool:
|
|
78
|
+
return signal in SHAPE
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def comparable_signals() -> list[str]:
|
|
82
|
+
"""Historized signals that can yield per-day numeric trend values."""
|
|
83
|
+
return [signal for signal, shape in SHAPE.items() if shape in _COMPARABLE_SHAPES]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _numeric(v: Any) -> float | None:
|
|
87
|
+
if isinstance(v, bool):
|
|
88
|
+
return None
|
|
89
|
+
if isinstance(v, (int, float)):
|
|
90
|
+
return float(v)
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _flatten_state(v: Any) -> str | None:
|
|
95
|
+
"""Coerce a state value to a categorical label (motion_state is a nested
|
|
96
|
+
dict {state, confidence, ...}; focus in_focus is a bool)."""
|
|
97
|
+
if isinstance(v, Mapping):
|
|
98
|
+
s = v.get("state")
|
|
99
|
+
return str(s) if s is not None else None
|
|
100
|
+
if isinstance(v, bool):
|
|
101
|
+
return "focused" if v else "unfocused"
|
|
102
|
+
if v is None:
|
|
103
|
+
return None
|
|
104
|
+
return str(v)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --- per-shape incremental merges ------------------------------------------
|
|
108
|
+
def _merge_numeric_dist(doc: dict, values: Mapping, **_) -> dict:
|
|
109
|
+
out = dict(doc)
|
|
110
|
+
for field, raw in values.items():
|
|
111
|
+
n = _numeric(raw)
|
|
112
|
+
if n is None:
|
|
113
|
+
continue
|
|
114
|
+
cell = out.get(field) or {}
|
|
115
|
+
out[field] = {
|
|
116
|
+
"min": n if cell.get("min") is None else min(cell["min"], n),
|
|
117
|
+
"max": n if cell.get("max") is None else max(cell["max"], n),
|
|
118
|
+
"sum": (cell.get("sum") or 0.0) + n,
|
|
119
|
+
"count": (cell.get("count") or 0) + 1,
|
|
120
|
+
}
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _merge_cumulative(doc: dict, values: Mapping, **_) -> dict:
|
|
125
|
+
out = dict(doc)
|
|
126
|
+
for field, raw in values.items():
|
|
127
|
+
n = _numeric(raw)
|
|
128
|
+
if n is None:
|
|
129
|
+
continue
|
|
130
|
+
prev = out.get(field)
|
|
131
|
+
out[field] = {"total": n if prev is None else max(prev.get("total", n), n)}
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _merge_main_of_day(doc: dict, values: Mapping, *, ts: float | None = None, **_) -> dict:
|
|
136
|
+
out = dict(doc)
|
|
137
|
+
for field, raw in values.items():
|
|
138
|
+
if raw is not None:
|
|
139
|
+
out[field] = raw
|
|
140
|
+
if ts is not None:
|
|
141
|
+
out["_at"] = ts
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _merge_duration_by_state(doc: dict, values: Mapping, *, signal: str = "",
|
|
146
|
+
ts: float | None = None, state_field: str | None = None,
|
|
147
|
+
**_) -> dict:
|
|
148
|
+
out = dict(doc)
|
|
149
|
+
buckets = dict(out.get("minutes") or {})
|
|
150
|
+
# manifest 按【字段】声明聚合方式,所以状态字段可以显式传进来;
|
|
151
|
+
# 不传时回退到按信号查表,旧调用方行为逐字节不变。
|
|
152
|
+
key = state_field or _STATE_FIELD.get(signal, "")
|
|
153
|
+
state = _flatten_state(values.get(key))
|
|
154
|
+
last_state = out.get("_last_state")
|
|
155
|
+
last_ts = out.get("_last_ts")
|
|
156
|
+
if last_state is not None and last_ts is not None and ts is not None and ts >= last_ts:
|
|
157
|
+
mins = (ts - last_ts) / 60.0
|
|
158
|
+
buckets[last_state] = round((buckets.get(last_state) or 0.0) + mins, 2)
|
|
159
|
+
out["minutes"] = buckets
|
|
160
|
+
if state is not None:
|
|
161
|
+
out["_last_state"] = state
|
|
162
|
+
if ts is not None:
|
|
163
|
+
out["_last_ts"] = ts
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _merge_place_dwell(doc: dict, values: Mapping, *, ts: float | None = None, **_) -> dict:
|
|
168
|
+
out = dict(doc)
|
|
169
|
+
buckets = dict(out.get("minutes") or {})
|
|
170
|
+
place = values.get("place_label")
|
|
171
|
+
last_place = out.get("_last_place")
|
|
172
|
+
last_ts = out.get("_last_ts")
|
|
173
|
+
if last_place and last_ts is not None and ts is not None and ts >= last_ts:
|
|
174
|
+
mins = (ts - last_ts) / 60.0
|
|
175
|
+
buckets[last_place] = round((buckets.get(last_place) or 0.0) + mins, 2)
|
|
176
|
+
out["minutes"] = buckets
|
|
177
|
+
if place:
|
|
178
|
+
out["_last_place"] = place
|
|
179
|
+
visited = set(out.get("visited") or [])
|
|
180
|
+
visited.add(place)
|
|
181
|
+
out["visited"] = sorted(visited)
|
|
182
|
+
if ts is not None:
|
|
183
|
+
out["_last_ts"] = ts
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _event_key(ev: Mapping) -> str:
|
|
188
|
+
for k in ("id", "event_id", "identifier"):
|
|
189
|
+
if ev.get(k):
|
|
190
|
+
return str(ev[k])
|
|
191
|
+
return "|".join(str(ev.get(k) or "") for k in ("title", "next_event_time", "start_time", "due_time"))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _merge_event_list(doc: dict, values: Mapping, **_) -> dict:
|
|
195
|
+
out = dict(doc)
|
|
196
|
+
items = list(out.get("events") or [])
|
|
197
|
+
seen = {_event_key(e) for e in items if isinstance(e, Mapping)}
|
|
198
|
+
# event-list signals carry their events under a list-valued field (e.g.
|
|
199
|
+
# calendar_events / reminders); fall back to the values dict as one event.
|
|
200
|
+
candidates: list = []
|
|
201
|
+
for v in values.values():
|
|
202
|
+
if isinstance(v, list):
|
|
203
|
+
candidates.extend(x for x in v if isinstance(x, Mapping))
|
|
204
|
+
if not candidates and any(values.get(k) for k in ("title", "workout_type")):
|
|
205
|
+
candidates = [dict(values)]
|
|
206
|
+
for ev in candidates:
|
|
207
|
+
key = _event_key(ev)
|
|
208
|
+
if key and key not in seen:
|
|
209
|
+
seen.add(key)
|
|
210
|
+
items.append(ev)
|
|
211
|
+
out["events"] = items
|
|
212
|
+
return out
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _cap_top(d: dict, n: int = _TALLY_CAP) -> dict:
|
|
216
|
+
if len(d) <= n:
|
|
217
|
+
return d
|
|
218
|
+
return dict(sorted(d.items(), key=lambda kv: kv[1], reverse=True)[:n])
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _merge_tally(doc: dict, values: Mapping, *, ts: float | None = None, **_) -> dict:
|
|
222
|
+
"""now_playing daily music digest: credit each listening interval (between
|
|
223
|
+
observations, while playing) to the previously-playing track + artist; track
|
|
224
|
+
distinct titles. Stores total_minutes + by_artist/by_track minutes (top-N) +
|
|
225
|
+
distinct titles — taste over time, not a per-play stream."""
|
|
226
|
+
out = dict(doc)
|
|
227
|
+
np = values.get("now_playing")
|
|
228
|
+
np = np if isinstance(np, Mapping) else {}
|
|
229
|
+
playing = str(np.get("playback_state") or "").lower() == "playing"
|
|
230
|
+
title = np.get("title")
|
|
231
|
+
artist = np.get("artist")
|
|
232
|
+
last_ts = out.get("_last_ts")
|
|
233
|
+
if out.get("_last_playing") and last_ts is not None and ts is not None and ts >= last_ts:
|
|
234
|
+
mins = round((ts - last_ts) / 60.0, 2)
|
|
235
|
+
out["total_minutes"] = round((out.get("total_minutes") or 0.0) + mins, 2)
|
|
236
|
+
la, lt = out.get("_last_artist"), out.get("_last_track")
|
|
237
|
+
if la:
|
|
238
|
+
by_a = dict(out.get("by_artist") or {})
|
|
239
|
+
by_a[la] = round((by_a.get(la) or 0.0) + mins, 2)
|
|
240
|
+
out["by_artist"] = _cap_top(by_a)
|
|
241
|
+
if lt:
|
|
242
|
+
by_t = dict(out.get("by_track") or {})
|
|
243
|
+
by_t[lt] = round((by_t.get(lt) or 0.0) + mins, 2)
|
|
244
|
+
out["by_track"] = _cap_top(by_t)
|
|
245
|
+
if playing and title:
|
|
246
|
+
distinct = set(out.get("distinct") or [])
|
|
247
|
+
distinct.add(title)
|
|
248
|
+
out["distinct"] = sorted(distinct)[:200]
|
|
249
|
+
out["_last_ts"] = ts
|
|
250
|
+
out["_last_playing"] = playing
|
|
251
|
+
out["_last_track"] = title if playing else None
|
|
252
|
+
out["_last_artist"] = artist if playing else None
|
|
253
|
+
return out
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _merge_subjective(doc: dict, values: Mapping, *, ts: float | None = None, **_) -> dict:
|
|
257
|
+
out = dict(doc)
|
|
258
|
+
entries = list(out.get("entries") or [])
|
|
259
|
+
entry = {k: v for k, v in values.items() if v is not None}
|
|
260
|
+
if ts is not None:
|
|
261
|
+
entry["_at"] = ts
|
|
262
|
+
if entry:
|
|
263
|
+
entries.append(entry)
|
|
264
|
+
out["entries"] = entries
|
|
265
|
+
return out
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
_MERGERS = {
|
|
269
|
+
NUMERIC_DIST: _merge_numeric_dist,
|
|
270
|
+
CUMULATIVE: _merge_cumulative,
|
|
271
|
+
MAIN_OF_DAY: _merge_main_of_day,
|
|
272
|
+
DURATION_BY_STATE: _merge_duration_by_state,
|
|
273
|
+
PLACE_DWELL: _merge_place_dwell,
|
|
274
|
+
EVENT_LIST: _merge_event_list,
|
|
275
|
+
SUBJECTIVE: _merge_subjective,
|
|
276
|
+
TALLY: _merge_tally,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def record_daily(prev_doc: Mapping | None, signal: str, values: Mapping, *, ts: float | None = None) -> dict:
|
|
281
|
+
"""Fold one observation of ``signal`` into its running day-doc and return the
|
|
282
|
+
next day-doc. Caller is responsible for keying by (user, local-date, signal)
|
|
283
|
+
and for resetting prev_doc to {} when the local date rolls over."""
|
|
284
|
+
shape = SHAPE.get(signal)
|
|
285
|
+
if shape is None:
|
|
286
|
+
raise ValueError(f"signal {signal!r} is not historized; guard with is_historized()")
|
|
287
|
+
if not isinstance(values, Mapping):
|
|
288
|
+
return dict(prev_doc or {})
|
|
289
|
+
merge = _MERGERS[shape]
|
|
290
|
+
return merge(dict(prev_doc or {}), values, signal=signal, ts=ts)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def apply_shape(
|
|
294
|
+
shape: str,
|
|
295
|
+
prev_doc: Mapping | None,
|
|
296
|
+
values: Mapping,
|
|
297
|
+
*,
|
|
298
|
+
signal: str = "",
|
|
299
|
+
state_field: str | None = None,
|
|
300
|
+
ts: float | None = None,
|
|
301
|
+
) -> dict:
|
|
302
|
+
"""按 shape 名字直接折叠一条观测,不经过 signal -> shape 的查表。
|
|
303
|
+
|
|
304
|
+
``record_daily`` 是按信号查表的(旧路径,保留不动);manifest 驱动的管线
|
|
305
|
+
是按**字段**声明聚合方式的,需要一个能直接指定算法和状态字段的入口。
|
|
306
|
+
|
|
307
|
+
两条路共用同一批 merger —— 算法只有一份,不会漂移。
|
|
308
|
+
"""
|
|
309
|
+
merge = _MERGERS.get(shape)
|
|
310
|
+
if merge is None:
|
|
311
|
+
raise ValueError(f"unknown aggregation shape {shape!r}; known: {sorted(_MERGERS)}")
|
|
312
|
+
if not isinstance(values, Mapping):
|
|
313
|
+
return dict(prev_doc or {})
|
|
314
|
+
return merge(dict(prev_doc or {}), values,
|
|
315
|
+
signal=signal, state_field=state_field, ts=ts)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# --- read side: trend / baseline -------------------------------------------
|
|
319
|
+
def _series_value(doc: Mapping, shape: str, field: str | None) -> float | None:
|
|
320
|
+
"""Pull a single comparable daily number out of a day-doc for trending."""
|
|
321
|
+
if shape == NUMERIC_DIST:
|
|
322
|
+
cell = doc.get(field) if field else None
|
|
323
|
+
if not isinstance(cell, Mapping):
|
|
324
|
+
return None
|
|
325
|
+
if field in _NUMERIC_MAX_FIELDS: # cumulative-within-day -> daily total
|
|
326
|
+
return cell.get("max")
|
|
327
|
+
if cell.get("count"):
|
|
328
|
+
return round(cell["sum"] / cell["count"], 3)
|
|
329
|
+
return None
|
|
330
|
+
if shape == CUMULATIVE:
|
|
331
|
+
cell = doc.get(field) if field else None
|
|
332
|
+
return cell.get("total") if isinstance(cell, Mapping) else None
|
|
333
|
+
if shape == MAIN_OF_DAY:
|
|
334
|
+
v = doc.get(field) if field else None
|
|
335
|
+
return _numeric(v)
|
|
336
|
+
if shape == TALLY: # e.g. field=total_minutes
|
|
337
|
+
return _numeric(doc.get(field)) if field else None
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _median(xs: list[float]) -> float | None:
|
|
342
|
+
if not xs:
|
|
343
|
+
return None
|
|
344
|
+
s = sorted(xs)
|
|
345
|
+
n = len(s)
|
|
346
|
+
mid = n // 2
|
|
347
|
+
return s[mid] if n % 2 else (s[mid - 1] + s[mid]) / 2.0
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _percentile(xs: list[float], p: float) -> float | None:
|
|
351
|
+
if not xs:
|
|
352
|
+
return None
|
|
353
|
+
s = sorted(xs)
|
|
354
|
+
idx = min(len(s) - 1, max(0, int(round(p * (len(s) - 1)))))
|
|
355
|
+
return s[idx]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def read_trend(rows: list[Mapping], signal: str, field: str | None = None) -> dict:
|
|
359
|
+
"""rows: [{date, doc}] ascending by date. Returns daily series + rolling
|
|
360
|
+
baseline (median/p25/p75) + current + delta vs baseline + direction."""
|
|
361
|
+
shape = SHAPE.get(signal)
|
|
362
|
+
daily = []
|
|
363
|
+
for r in rows:
|
|
364
|
+
v = _series_value(r.get("doc") or {}, shape, field) if shape else None
|
|
365
|
+
if v is not None:
|
|
366
|
+
daily.append({"date": r.get("date"), "value": v})
|
|
367
|
+
vals = [d["value"] for d in daily]
|
|
368
|
+
baseline_vals = vals[:-1] if len(vals) > 1 else vals
|
|
369
|
+
median = _median(baseline_vals)
|
|
370
|
+
current = vals[-1] if vals else None
|
|
371
|
+
delta = round(current - median, 3) if (current is not None and median is not None) else None
|
|
372
|
+
direction = "flat"
|
|
373
|
+
if delta is not None:
|
|
374
|
+
direction = "up" if delta > 0 else ("down" if delta < 0 else "flat")
|
|
375
|
+
return {
|
|
376
|
+
"signal": signal,
|
|
377
|
+
"field": field,
|
|
378
|
+
"daily": daily,
|
|
379
|
+
"baseline": {
|
|
380
|
+
"median": median,
|
|
381
|
+
"p25": _percentile(baseline_vals, 0.25),
|
|
382
|
+
"p75": _percentile(baseline_vals, 0.75),
|
|
383
|
+
"n": len(baseline_vals),
|
|
384
|
+
},
|
|
385
|
+
"current": current,
|
|
386
|
+
"delta": delta,
|
|
387
|
+
"direction": direction,
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _numeric_fields(rows: list[Mapping], signal: str) -> list[str]:
|
|
392
|
+
shape = SHAPE.get(signal)
|
|
393
|
+
if shape not in _COMPARABLE_SHAPES:
|
|
394
|
+
return []
|
|
395
|
+
fields: list[str] = []
|
|
396
|
+
seen: set[str] = set()
|
|
397
|
+
for row in rows:
|
|
398
|
+
doc = row.get("doc") or {}
|
|
399
|
+
if not isinstance(doc, Mapping):
|
|
400
|
+
continue
|
|
401
|
+
for field in doc:
|
|
402
|
+
if not isinstance(field, str) or field.startswith("_") or field in seen:
|
|
403
|
+
continue
|
|
404
|
+
if _series_value(doc, shape, field) is None:
|
|
405
|
+
continue
|
|
406
|
+
seen.add(field)
|
|
407
|
+
fields.append(field)
|
|
408
|
+
return fields
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _finite_number(v: Any) -> float | None:
|
|
412
|
+
n = _numeric(v)
|
|
413
|
+
if n is None or not math.isfinite(n):
|
|
414
|
+
return None
|
|
415
|
+
return n
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _field_report_freshness(
|
|
419
|
+
signal: str,
|
|
420
|
+
field: str,
|
|
421
|
+
*,
|
|
422
|
+
last_report_ts_by_signal: Mapping[str, Mapping[str, Any]],
|
|
423
|
+
now: float,
|
|
424
|
+
) -> tuple[float | None, float | None, bool]:
|
|
425
|
+
"""Return the field report timestamp, age, and catalog-TTL verdict."""
|
|
426
|
+
|
|
427
|
+
raw_by_field = last_report_ts_by_signal.get(signal)
|
|
428
|
+
raw_ts = raw_by_field.get(field) if isinstance(raw_by_field, Mapping) else None
|
|
429
|
+
try:
|
|
430
|
+
report_ts = float(raw_ts)
|
|
431
|
+
except (TypeError, ValueError):
|
|
432
|
+
return None, None, False
|
|
433
|
+
if report_ts <= 0 or not math.isfinite(report_ts):
|
|
434
|
+
return None, None, False
|
|
435
|
+
age = float(now) - report_ts
|
|
436
|
+
return report_ts, age, age <= catalog.SIGNALS[signal].ttl_sec
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _format_report_as_of(report_ts: float | None, timezone_name: str | None) -> str | None:
|
|
440
|
+
"""Format a stable, model-readable report time in the user's timezone."""
|
|
441
|
+
|
|
442
|
+
if report_ts is None:
|
|
443
|
+
return None
|
|
444
|
+
if timezone_name:
|
|
445
|
+
try:
|
|
446
|
+
return datetime.fromtimestamp(report_ts, ZoneInfo(timezone_name)).strftime(
|
|
447
|
+
"%Y-%m-%d %H:%M"
|
|
448
|
+
)
|
|
449
|
+
except Exception:
|
|
450
|
+
pass
|
|
451
|
+
return datetime.fromtimestamp(report_ts, timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def notable_changes(
|
|
455
|
+
rows_by_signal: Mapping[str, list[Mapping]],
|
|
456
|
+
*,
|
|
457
|
+
last_report_ts_by_signal: Mapping[str, Mapping[str, Any]],
|
|
458
|
+
now: float,
|
|
459
|
+
timezone_name: str | None,
|
|
460
|
+
max_changes: int = 8,
|
|
461
|
+
) -> list[dict]:
|
|
462
|
+
"""Return top-N relative numeric changes across all comparable history.
|
|
463
|
+
|
|
464
|
+
``rows_by_signal`` maps canonical catalog signals to ascending daily rows,
|
|
465
|
+
the same shape consumed by ``read_trend``. Fields are discovered from the
|
|
466
|
+
stored day-docs, then each (signal, field) delegates baseline/current/delta
|
|
467
|
+
calculation to ``read_trend`` so digest semantics stay aligned with the
|
|
468
|
+
existing trend endpoint.
|
|
469
|
+
"""
|
|
470
|
+
cap = max(0, int(max_changes or 0))
|
|
471
|
+
if cap <= 0:
|
|
472
|
+
return []
|
|
473
|
+
changes: list[dict] = []
|
|
474
|
+
for signal in comparable_signals():
|
|
475
|
+
rows = list(rows_by_signal.get(signal) or [])
|
|
476
|
+
for field in _numeric_fields(rows, signal):
|
|
477
|
+
trend = read_trend(rows, signal, field)
|
|
478
|
+
baseline = trend.get("baseline") if isinstance(trend.get("baseline"), Mapping) else {}
|
|
479
|
+
baseline_median = _finite_number(baseline.get("median"))
|
|
480
|
+
current = _finite_number(trend.get("current"))
|
|
481
|
+
delta = _finite_number(trend.get("delta"))
|
|
482
|
+
if (baseline.get("n") or 0) < 2 or baseline_median is None or current is None or delta is None:
|
|
483
|
+
continue
|
|
484
|
+
denom = max(abs(baseline_median), _SIGNIFICANCE_FLOOR)
|
|
485
|
+
magnitude = round(abs(delta) / denom, 6)
|
|
486
|
+
report_ts, _age, fresh = _field_report_freshness(
|
|
487
|
+
signal,
|
|
488
|
+
field,
|
|
489
|
+
last_report_ts_by_signal=last_report_ts_by_signal,
|
|
490
|
+
now=now,
|
|
491
|
+
)
|
|
492
|
+
change = {
|
|
493
|
+
"signal": signal,
|
|
494
|
+
"field": field,
|
|
495
|
+
"baseline_median": baseline_median,
|
|
496
|
+
"delta": delta,
|
|
497
|
+
"direction": trend.get("direction") or "flat",
|
|
498
|
+
"magnitude": magnitude,
|
|
499
|
+
}
|
|
500
|
+
if fresh:
|
|
501
|
+
change["current"] = current
|
|
502
|
+
else:
|
|
503
|
+
# Preserve the useful historical value and trend, but remove
|
|
504
|
+
# the false claim that the last daily rollup is current. The
|
|
505
|
+
# stable report timestamp also avoids heartbeat fingerprint
|
|
506
|
+
# churn while giving the model an exact "as of" anchor.
|
|
507
|
+
change["last_known"] = current
|
|
508
|
+
change["as_of"] = _format_report_as_of(report_ts, timezone_name)
|
|
509
|
+
changes.append(change)
|
|
510
|
+
changes.sort(key=lambda c: (-c["magnitude"], c["signal"], c["field"]))
|
|
511
|
+
return changes[:cap]
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# --- cross-domain digest board ---------------------------------------------
|
|
515
|
+
# notable_changes() ranks health/numeric deltas only, so the wake digest skews
|
|
516
|
+
# into a body-monitoring readout. cross_domain_recent() instead lays out ONE
|
|
517
|
+
# compact entry per life-context domain (location / media / app / health /
|
|
518
|
+
# weather / mood / reminders / calendar / photos / screen) so the agent keeps
|
|
519
|
+
# music/place/app/photo context. The backend does NOT pick the 2-3 things that
|
|
520
|
+
# matter — it only sets a balanced table; the agent reads it and judges. Light,
|
|
521
|
+
# factual per-domain `novelty` hints (new_artist / long_dwell) are context, not
|
|
522
|
+
# a cross-domain ranking. Pure function (no I/O): the route fetches
|
|
523
|
+
# snapshot/pull_snapshot/history/photos and passes them in.
|
|
524
|
+
_LONG_DWELL_MIN = 240.0 # >=4h at one place today -> a light "long_dwell" hint
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _as_mapping(v: Any) -> dict:
|
|
528
|
+
return dict(v) if isinstance(v, Mapping) else {}
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _media_domain(snapshot: Mapping, rows: list[Mapping]) -> dict:
|
|
532
|
+
np = _as_mapping(snapshot.get("now_playing"))
|
|
533
|
+
title = np.get("title")
|
|
534
|
+
artist = np.get("artist")
|
|
535
|
+
title = str(title) if title else None
|
|
536
|
+
artist = str(artist) if artist else None
|
|
537
|
+
today = _as_mapping(rows[-1].get("doc")) if rows else {}
|
|
538
|
+
by_artist = _as_mapping(today.get("by_artist"))
|
|
539
|
+
top_artists = [a for a, _ in sorted(by_artist.items(), key=lambda kv: kv[1], reverse=True)[:3]]
|
|
540
|
+
distinct = today.get("distinct")
|
|
541
|
+
novelty = None
|
|
542
|
+
if artist:
|
|
543
|
+
prior: set[str] = set()
|
|
544
|
+
for r in rows[:-1]:
|
|
545
|
+
prior.update(_as_mapping(_as_mapping(r.get("doc")).get("by_artist")).keys())
|
|
546
|
+
if artist not in prior:
|
|
547
|
+
novelty = "new_artist"
|
|
548
|
+
return {
|
|
549
|
+
"now": ({"title": title, "artist": artist} if (title or artist) else None),
|
|
550
|
+
"top_artists_today": top_artists,
|
|
551
|
+
"minutes_today": _finite_number(today.get("total_minutes")),
|
|
552
|
+
"distinct_today": (len(distinct) if isinstance(distinct, list) else None),
|
|
553
|
+
"novelty": novelty,
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _location_domain(snapshot: Mapping, rows: list[Mapping]) -> dict:
|
|
558
|
+
place = snapshot.get("place_label")
|
|
559
|
+
today = _as_mapping(rows[-1].get("doc")) if rows else {}
|
|
560
|
+
minutes = _as_mapping(today.get("minutes"))
|
|
561
|
+
visited = today.get("visited")
|
|
562
|
+
minutes_here = _finite_number(minutes.get(place)) if place else None
|
|
563
|
+
novelty = "long_dwell" if (minutes_here is not None and minutes_here >= _LONG_DWELL_MIN) else None
|
|
564
|
+
return {
|
|
565
|
+
"now": place,
|
|
566
|
+
"minutes_today": minutes_here,
|
|
567
|
+
"visited_today": (list(visited) if isinstance(visited, list) else []),
|
|
568
|
+
"novelty": novelty,
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _app_domain(snapshot: Mapping) -> dict:
|
|
573
|
+
recent = snapshot.get("recent_apps")
|
|
574
|
+
entries = [e for e in recent if isinstance(e, Mapping)] if isinstance(recent, list) else []
|
|
575
|
+
entries.sort(key=lambda e: (_finite_number(e.get("ts")) or 0.0), reverse=True)
|
|
576
|
+
now_app = entries[0].get("app") if entries else None
|
|
577
|
+
names: list[str] = []
|
|
578
|
+
seen: set[str] = set()
|
|
579
|
+
for e in entries:
|
|
580
|
+
a = e.get("app")
|
|
581
|
+
if a and a not in seen:
|
|
582
|
+
seen.add(a)
|
|
583
|
+
names.append(str(a))
|
|
584
|
+
return {"now": now_app, "recent": names[:5], "novelty": None}
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _weather_domain(pull: Mapping) -> dict:
|
|
588
|
+
cond, temp = pull.get("condition"), pull.get("temperature")
|
|
589
|
+
if cond is None and temp is None:
|
|
590
|
+
return {"status": "none"}
|
|
591
|
+
return {"condition": cond, "temperature": temp}
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _mood_domain(pull: Mapping) -> dict:
|
|
595
|
+
val, cls = pull.get("valence"), pull.get("valence_classification")
|
|
596
|
+
if val is None and cls is None and not pull.get("recorded_today"):
|
|
597
|
+
return {"status": "none"}
|
|
598
|
+
return {"valence": val, "classification": cls}
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _reminders_domain(pull: Mapping) -> dict:
|
|
602
|
+
reminders = pull.get("reminders")
|
|
603
|
+
overdue: list[str] = []
|
|
604
|
+
if isinstance(reminders, list):
|
|
605
|
+
for r in reminders:
|
|
606
|
+
if isinstance(r, Mapping) and r.get("overdue") and r.get("title"):
|
|
607
|
+
overdue.append(str(r.get("title")))
|
|
608
|
+
return {
|
|
609
|
+
"due_today": pull.get("due_today_count"),
|
|
610
|
+
"overdue_count": pull.get("overdue_count"),
|
|
611
|
+
"overdue": overdue[:5],
|
|
612
|
+
"next": pull.get("next_reminder"),
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _photos_domain(photos: Any) -> dict:
|
|
617
|
+
items = photos if isinstance(photos, list) else []
|
|
618
|
+
scenes: list[str] = []
|
|
619
|
+
seen: set[str] = set()
|
|
620
|
+
for p in items:
|
|
621
|
+
meta = _as_mapping(p.get("metadata")) if isinstance(p, Mapping) else {}
|
|
622
|
+
sc = meta.get("scene_hint")
|
|
623
|
+
if sc and sc not in seen:
|
|
624
|
+
seen.add(sc)
|
|
625
|
+
scenes.append(str(sc))
|
|
626
|
+
return {"recent_count": len(items), "scenes": scenes[:5]}
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def cross_domain_recent(
|
|
630
|
+
*,
|
|
631
|
+
snapshot: Mapping | None,
|
|
632
|
+
pull_snapshot: Mapping | None,
|
|
633
|
+
rows_by_signal: Mapping[str, list[Mapping]] | None,
|
|
634
|
+
last_report_ts_by_signal: Mapping[str, Mapping[str, Any]],
|
|
635
|
+
now: float,
|
|
636
|
+
timezone_name: str | None,
|
|
637
|
+
photos: Any = None,
|
|
638
|
+
max_health_notable: int = 8,
|
|
639
|
+
) -> dict:
|
|
640
|
+
"""Balanced cross-domain digest board for the wake turn (see module note)."""
|
|
641
|
+
snap = _as_mapping(snapshot)
|
|
642
|
+
pull = _as_mapping(pull_snapshot)
|
|
643
|
+
rbs = rows_by_signal if isinstance(rows_by_signal, Mapping) else {}
|
|
644
|
+
return {
|
|
645
|
+
"location": _location_domain(snap, list(rbs.get("location_signal") or [])),
|
|
646
|
+
"media": _media_domain(snap, list(rbs.get("playback") or [])),
|
|
647
|
+
"app": _app_domain(snap),
|
|
648
|
+
"health": {
|
|
649
|
+
"notable": notable_changes(
|
|
650
|
+
rbs,
|
|
651
|
+
last_report_ts_by_signal=last_report_ts_by_signal,
|
|
652
|
+
now=now,
|
|
653
|
+
timezone_name=timezone_name,
|
|
654
|
+
max_changes=max_health_notable,
|
|
655
|
+
)
|
|
656
|
+
},
|
|
657
|
+
"weather": _weather_domain(pull),
|
|
658
|
+
"mood": _mood_domain(pull),
|
|
659
|
+
"reminders": _reminders_domain(pull),
|
|
660
|
+
"calendar": {"next": snap.get("calendar_next_event")},
|
|
661
|
+
"photos": _photos_domain(photos),
|
|
662
|
+
"screen": {"state": snap.get("broadcast_state")},
|
|
663
|
+
}
|