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,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
+ }