tensorcode 0.1.0a1__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 (53) hide show
  1. tensorcode/__init__.py +84 -0
  2. tensorcode/actions.py +137 -0
  3. tensorcode/answer_type.py +222 -0
  4. tensorcode/awareness.py +344 -0
  5. tensorcode/backends/__init__.py +0 -0
  6. tensorcode/backends/builtin.py +167 -0
  7. tensorcode/backends/hf_local.py +89 -0
  8. tensorcode/backends/linear.py +133 -0
  9. tensorcode/backends/neural.py +361 -0
  10. tensorcode/causal.py +262 -0
  11. tensorcode/change.py +566 -0
  12. tensorcode/chunking.py +195 -0
  13. tensorcode/cognition.py +311 -0
  14. tensorcode/context.py +97 -0
  15. tensorcode/control.py +291 -0
  16. tensorcode/cues.py +192 -0
  17. tensorcode/expectation.py +270 -0
  18. tensorcode/frames.py +232 -0
  19. tensorcode/language/__init__.py +36 -0
  20. tensorcode/language/chart.py +558 -0
  21. tensorcode/language/discourse.py +132 -0
  22. tensorcode/language/domains/__init__.py +0 -0
  23. tensorcode/language/domains/desktop.py +552 -0
  24. tensorcode/language/english.py +459 -0
  25. tensorcode/language/features.py +112 -0
  26. tensorcode/language/generate.py +574 -0
  27. tensorcode/language/grammar.py +893 -0
  28. tensorcode/language/semantics.py +349 -0
  29. tensorcode/learning/__init__.py +30 -0
  30. tensorcode/learning/certificate.py +148 -0
  31. tensorcode/learning/induce.py +304 -0
  32. tensorcode/learning/library.py +217 -0
  33. tensorcode/learning/literals.py +126 -0
  34. tensorcode/learning/verify.py +253 -0
  35. tensorcode/memory.py +303 -0
  36. tensorcode/metacognition.py +351 -0
  37. tensorcode/ops.py +207 -0
  38. tensorcode/outcomes.py +99 -0
  39. tensorcode/permanence.py +376 -0
  40. tensorcode/priming.py +191 -0
  41. tensorcode/py.typed +0 -0
  42. tensorcode/quantity.py +311 -0
  43. tensorcode/records.py +728 -0
  44. tensorcode/relation.py +771 -0
  45. tensorcode/runtime.py +471 -0
  46. tensorcode/semantics_bridge.py +308 -0
  47. tensorcode/social.py +380 -0
  48. tensorcode/temporal.py +189 -0
  49. tensorcode/wants.py +185 -0
  50. tensorcode-0.1.0a1.dist-info/METADATA +196 -0
  51. tensorcode-0.1.0a1.dist-info/RECORD +53 -0
  52. tensorcode-0.1.0a1.dist-info/WHEEL +4 -0
  53. tensorcode-0.1.0a1.dist-info/licenses/LICENSE +21 -0
tensorcode/change.py ADDED
@@ -0,0 +1,566 @@
1
+ """Noticing change: snapshots of what was perceived, and the difference between two of them.
2
+
3
+ Perception as it stands answers "what is there now". It cannot answer "what changed", which is
4
+ the most basic temporal perception there is: a snapshot scope retracts what left view and keeps
5
+ no record of the leaving. This module keeps the record.
6
+
7
+ snap = snapshot(items, tag="turn:3") # what was perceived, reduced to identity + attributes
8
+ diff(before, snap) # typed changes, each with provenance and a time
9
+ Volatility().watch(...).is_volatile(key) # what changes every frame anyway, learned not declared
10
+
11
+ A live screen changes constantly — clocks tick, carets blink, terminals scroll — so a diff that
12
+ reports everything is useless. Volatility is *measured*: a key that changed in most recent
13
+ comparisons is volatile, and a change to it is flagged rather than silently dropped, so both the
14
+ signal and the noise can be counted.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections import defaultdict, deque
20
+ from dataclasses import dataclass, field, replace
21
+ from datetime import datetime, timezone
22
+ from typing import Any, Iterable, Mapping, Sequence
23
+
24
+ from .outcomes import Score
25
+
26
+ APPEARED, DISAPPEARED, MOVED, VALUE, WINDOW_OPENED, WINDOW_CLOSED, FOCUS, OCCLUDED = (
27
+ "appeared", "disappeared", "moved", "value_changed", "window_opened", "window_closed", "focus_changed", "occluded")
28
+ REPLACED = "replaced"
29
+
30
+
31
+ def _now() -> datetime:
32
+ return datetime.now(timezone.utc)
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Item:
37
+ """One perceived thing, reduced to what a difference needs."""
38
+
39
+ key: str
40
+ kind: str = "control" # control | text | window
41
+ label: str = ""
42
+ value: str = ""
43
+ window: str = ""
44
+ where: tuple[int, int, int, int] | None = None
45
+ focused: bool = False
46
+
47
+ @property
48
+ def centre(self) -> tuple[int, int] | None:
49
+ if self.where is None:
50
+ return None
51
+ x, y, w, h = self.where
52
+ return (x + w // 2, y + h // 2)
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Snapshot:
57
+ """What was perceived at one moment, addressable by identity."""
58
+
59
+ at: datetime
60
+ items: Mapping[str, Item]
61
+ tag: str = ""
62
+
63
+ @property
64
+ def windows(self) -> tuple[str, ...]:
65
+ return tuple(sorted({i.window for i in self.items.values() if i.window}))
66
+
67
+ def __len__(self) -> int:
68
+ return len(self.items)
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Change:
73
+ """One difference, with what it was, what it is, and when it was noticed."""
74
+
75
+ kind: str
76
+ key: str
77
+ at: datetime
78
+ label: str = ""
79
+ window: str = ""
80
+ was: str | None = None
81
+ now: str | None = None
82
+ moved_by: tuple[int, int] | None = None
83
+ volatile: bool = False # this key changes on its own; reported so it can be filtered, not hidden
84
+ brought: int = 0 # parts that came or went with this whole, reported as one change rather than many
85
+
86
+ def describe(self) -> str:
87
+ what = self.label or self.key
88
+ where = f" in {self.window}" if self.window else ""
89
+ if self.kind == APPEARED:
90
+ return f"{what} appeared{where}"
91
+ if self.kind == DISAPPEARED:
92
+ return f"{what} went away{where}"
93
+ if self.kind == OCCLUDED:
94
+ return f"{what} is covered by {self.now}, not gone"
95
+ if self.kind == REPLACED:
96
+ return f"{self.was} became {self.now}{where}"
97
+ if self.kind == WINDOW_OPENED:
98
+ return f"the {what} window opened" + (f" (with {self.brought} things in it)" if self.brought else "")
99
+ if self.kind == WINDOW_CLOSED:
100
+ return f"the {what} window closed" + (f" (taking {self.brought} things with it)" if self.brought else "")
101
+ if self.kind == MOVED:
102
+ dx, dy = self.moved_by or (0, 0)
103
+ return f"{what} moved{where} by ({dx}, {dy})"
104
+ if self.kind == FOCUS:
105
+ return f"focus moved to {what}{where}" if self.now == "focused" else f"{what} lost focus{where}"
106
+ return f"{what}{where} changed from {self.was!r} to {self.now!r}"
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class Changes:
111
+ """The difference between two snapshots, separated into signal and self-moving noise."""
112
+
113
+ since: datetime
114
+ at: datetime
115
+ all: tuple[Change, ...] = ()
116
+
117
+ @property
118
+ def steady(self) -> tuple[Change, ...]:
119
+ """Changes to things that do not change on their own."""
120
+ return tuple(c for c in self.all if not c.volatile)
121
+
122
+ @property
123
+ def volatile(self) -> tuple[Change, ...]:
124
+ return tuple(c for c in self.all if c.volatile)
125
+
126
+ def of(self, *kinds: str) -> tuple[Change, ...]:
127
+ return tuple(c for c in self.steady if c.kind in kinds)
128
+
129
+ def summary(self, limit: int = 8) -> str:
130
+ if not self.steady:
131
+ return "nothing changed" if not self.volatile else f"nothing changed (besides {len(self.volatile)} things that change on their own)"
132
+ lines = [c.describe() for c in self.steady[:limit]]
133
+ if len(self.steady) > limit:
134
+ lines.append(f"and {len(self.steady) - limit} more")
135
+ return "; ".join(lines)
136
+
137
+ def __len__(self) -> int:
138
+ return len(self.steady)
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class ChangePolicy:
143
+ """What counts as a change, and what is beneath notice."""
144
+
145
+ move_threshold: int = 8 # pixels; below this a box wobble is not a move
146
+ report_moves: bool = True
147
+ volatile_after: int = 3 # comparisons needed before a key can be judged volatile
148
+ volatile_fraction: float = 0.5 # changed in at least this share of them
149
+ window_of: int = 12 # how many recent comparisons the judgement rests on
150
+
151
+
152
+ class Volatility:
153
+ """Which keys change on their own, learned by watching rather than declared.
154
+
155
+ A clock's text changes every comparison; a file listing does not. Nothing here knows what a
156
+ clock is: it knows that key has changed in most of the comparisons it has seen.
157
+ """
158
+
159
+ def __init__(self, policy: ChangePolicy | None = None) -> None:
160
+ self.policy = policy or ChangePolicy()
161
+ self._seen: dict[str, deque[bool]] = defaultdict(lambda: deque(maxlen=self.policy.window_of))
162
+
163
+ def watch(self, before: Snapshot, after: Snapshot) -> "Volatility":
164
+ """Record, for every key present in both, whether its value moved."""
165
+ for key, now in after.items.items():
166
+ was = before.items.get(key)
167
+ if was is None:
168
+ continue
169
+ self._seen[key].append(was.value != now.value or _shifted(was, now, self.policy.move_threshold))
170
+ return self
171
+
172
+ def is_volatile(self, key: str) -> bool:
173
+ seen = self._seen.get(key)
174
+ if not seen or len(seen) < self.policy.volatile_after:
175
+ return False
176
+ return (sum(seen) / len(seen)) >= self.policy.volatile_fraction
177
+
178
+ def rate(self, key: str) -> Score | None:
179
+ seen = self._seen.get(key)
180
+ if not seen:
181
+ return None
182
+ return Score(round(sum(seen) / len(seen), 3), "frequency", f"changed in {sum(seen)} of {len(seen)} comparisons")
183
+
184
+ def volatile_keys(self) -> tuple[str, ...]:
185
+ return tuple(sorted(k for k in self._seen if self.is_volatile(k)))
186
+
187
+
188
+ def snapshot(items: Iterable[Item | Mapping[str, Any]], *, at: datetime | None = None, tag: str = "") -> Snapshot:
189
+ """A snapshot from perceived items; mappings are accepted so a caller can stay loose."""
190
+ built: dict[str, Item] = {}
191
+ for raw in items:
192
+ item = raw if isinstance(raw, Item) else Item(**{k: v for k, v in raw.items() if k in Item.__dataclass_fields__})
193
+ built[item.key] = item
194
+ return Snapshot(at or _now(), built, tag)
195
+
196
+
197
+ def diff(before: Snapshot | None, after: Snapshot, *, policy: ChangePolicy | None = None,
198
+ volatility: Volatility | None = None) -> Changes:
199
+ """Typed differences between two snapshots.
200
+
201
+ Unmatched keys are paired by label and position before being called appeared/disappeared, so a
202
+ control whose identity shifted reads as a move rather than a death and a birth.
203
+ """
204
+ policy = policy or ChangePolicy()
205
+ if before is None:
206
+ return Changes(after.at, after.at, ())
207
+ volatile = (lambda key: volatility.is_volatile(key)) if volatility is not None else (lambda key: False)
208
+ changes: list[Change] = []
209
+ gone = {k: v for k, v in before.items.items() if k not in after.items}
210
+ fresh = {k: v for k, v in after.items.items() if k not in before.items}
211
+ renamed = _pair_up(gone, fresh, policy)
212
+ for key, now in after.items.items():
213
+ was = before.items.get(key) or renamed.get(key)
214
+ if was is None:
215
+ changes.append(Change(WINDOW_OPENED if now.kind == "window" else APPEARED, key, after.at,
216
+ now.label or now.value, now.window, None, now.value or now.label, volatile=volatile(key)))
217
+ continue
218
+ if was.value != now.value:
219
+ changes.append(Change(VALUE, key, after.at, now.label or now.key, now.window, was.value, now.value, volatile=volatile(key)))
220
+ if was.focused != now.focused:
221
+ changes.append(Change(FOCUS, key, after.at, now.label or now.key, now.window,
222
+ "focused" if was.focused else "not focused", "focused" if now.focused else "not focused",
223
+ volatile=volatile(key)))
224
+ if policy.report_moves and _shifted(was, now, policy.move_threshold):
225
+ b, a = was.centre, now.centre
226
+ changes.append(Change(MOVED, key, after.at, now.label or now.key, now.window, str(was.where), str(now.where),
227
+ (a[0] - b[0], a[1] - b[1]) if a and b else None, volatile=volatile(key)))
228
+ matched_back = set(renamed.values())
229
+ for key, was in gone.items():
230
+ if was in matched_back:
231
+ continue
232
+ changes.append(Change(WINDOW_CLOSED if was.kind == "window" else DISAPPEARED, key, after.at,
233
+ was.label or was.value, was.window, was.value or was.label, None, volatile=volatile(key)))
234
+ changes = _mark_occlusions(_pair_replacements(_subsume_parts(_drop_consequent_moves(changes), before, after),
235
+ before, after),
236
+ before, _window_bounds(before, after))
237
+ changes.sort(key=lambda c: (_ORDER.get(c.kind, 9), c.window, c.key))
238
+ return Changes(before.at, after.at, tuple(changes))
239
+
240
+
241
+ def _drop_consequent_moves(changes: list[Change]) -> list[Change]:
242
+ """A thing whose text changed and therefore shifted has not also moved.
243
+
244
+ Text is laid out, so a longer word pushes its own box sideways. Reporting that as a move is how
245
+ a clock ticking over produces two pieces of news instead of one — and the move, unlike the tick,
246
+ is not recognisably volatile, so it survives into the answer as pure noise.
247
+ """
248
+ said = {c.key for c in changes if c.kind in (VALUE, REPLACED)}
249
+ return [c for c in changes if not (c.kind == MOVED and c.key in said)]
250
+
251
+
252
+ def _subsume_parts(changes: list[Change], before: Snapshot, after: Snapshot) -> list[Change]:
253
+ """A window opening is one change, not one per control inside it.
254
+
255
+ Perception reports parts; a mind should notice the whole. If every item of a window is new (the
256
+ region did not exist before), the parts are folded into a single window change carrying how
257
+ many came with it. A window that was already there keeps reporting its parts individually,
258
+ because then the parts are the news.
259
+ """
260
+ had = _by_window(before)
261
+ has = _by_window(after)
262
+ wholes: dict[str, str] = {}
263
+ for window, now_keys in has.items():
264
+ if not window:
265
+ continue
266
+ if not had.get(window) and now_keys:
267
+ wholes[window] = APPEARED
268
+ for window, was_keys in had.items():
269
+ if not window:
270
+ continue
271
+ if not has.get(window) and was_keys:
272
+ wholes[window] = DISAPPEARED
273
+ if not wholes:
274
+ return changes
275
+ # where each new or departing whole is, so parts the structure did not attribute can be placed
276
+ # inside it by geometry — the spatial frame doing part-whole binding the DOM did not
277
+ bounds = {w: _bounds(after if v == APPEARED else before, w) for w, v in wholes.items()}
278
+ kept: list[Change] = []
279
+ folded: dict[str, int] = {}
280
+ for change in changes:
281
+ verdict = wholes.get(change.window)
282
+ if verdict == APPEARED and change.kind in (APPEARED, WINDOW_OPENED):
283
+ folded[change.window] = folded.get(change.window, 0) + 1
284
+ continue
285
+ if verdict == DISAPPEARED and change.kind in (DISAPPEARED, WINDOW_CLOSED):
286
+ folded[change.window] = folded.get(change.window, 0) + 1
287
+ continue
288
+ if not change.window and change.kind in (APPEARED, DISAPPEARED):
289
+ side = after if change.kind == APPEARED else before
290
+ item = side.items.get(change.key)
291
+ inside = _containing(bounds, item, wholes, APPEARED if change.kind == APPEARED else DISAPPEARED)
292
+ if inside is not None:
293
+ folded[inside] = folded.get(inside, 0) + 1
294
+ continue
295
+ kept.append(change)
296
+ for window, count in sorted(folded.items()):
297
+ kind = WINDOW_OPENED if wholes[window] == APPEARED else WINDOW_CLOSED
298
+ at = after.at
299
+ kept.append(Change(kind, f"window:{window}", at, window, window,
300
+ None if kind == WINDOW_OPENED else window, window if kind == WINDOW_OPENED else None,
301
+ brought=count - 1 if count > 1 else 0))
302
+ return kept
303
+
304
+
305
+ def _pair_replacements(changes: list[Change], before: Snapshot, after: Snapshot, slack: int = 6) -> list[Change]:
306
+ """One thing standing where another stood is a substitution, not a death and a birth.
307
+
308
+ A top bar that read "Terminal" and now reads "Text Editor" has not lost one label and gained
309
+ another: the same slot now says something else. Reporting it as two changes is how a diff turns
310
+ one fact into noise.
311
+ """
312
+ gone = [c for c in changes if c.kind == DISAPPEARED]
313
+ fresh = [c for c in changes if c.kind == APPEARED]
314
+ if not gone or not fresh:
315
+ return changes
316
+ used: set[str] = set()
317
+ pairs: dict[str, Change] = {}
318
+ for new_change in fresh:
319
+ now_item = after.items.get(new_change.key)
320
+ if now_item is None or now_item.where is None:
321
+ continue
322
+ for old_change in gone:
323
+ if old_change.key in used:
324
+ continue
325
+ was_item = before.items.get(old_change.key)
326
+ if was_item is None or was_item.where is None or was_item.kind != now_item.kind:
327
+ continue
328
+ # a text slot holding a longer word is wider: position and height identify the slot, width does not
329
+ wx, wy, _, wh = was_item.where
330
+ nx, ny, _, nh = now_item.where
331
+ if abs(wx - nx) <= slack and abs(wy - ny) <= slack and abs(wh - nh) <= slack:
332
+ used.add(old_change.key)
333
+ pairs[new_change.key] = Change(REPLACED, new_change.key, new_change.at, now_item.label or new_change.label,
334
+ now_item.window, old_change.label or old_change.was,
335
+ new_change.label or new_change.now, volatile=new_change.volatile)
336
+ break
337
+ if not pairs:
338
+ return changes
339
+ out = []
340
+ for change in changes:
341
+ if change.kind == DISAPPEARED and change.key in used:
342
+ continue
343
+ out.append(pairs.get(change.key, change) if change.kind == APPEARED else change)
344
+ return out
345
+
346
+
347
+ def _window_bounds(before: Snapshot, after: Snapshot) -> dict[str, tuple[int, int, int, int]]:
348
+ """Where each window that has just appeared now sits."""
349
+ had, has = _by_window(before), _by_window(after)
350
+ opened = [w for w, keys in has.items() if w and keys and not had.get(w)]
351
+ return {w: b for w in opened if (b := _bounds(after, w)) is not None}
352
+
353
+
354
+ def _mark_occlusions(changes: list[Change], before: Snapshot, opened: Mapping[str, tuple[int, int, int, int]]) -> list[Change]:
355
+ """A thing under a window that just opened is covered, not destroyed.
356
+
357
+ Perception cannot tell "closed" from "hidden behind": both are simply absent. Geometry can,
358
+ and getting it wrong is how an agent comes to believe a window was closed when the user merely
359
+ opened another one on top of it.
360
+ """
361
+ if not opened:
362
+ return changes
363
+ out: list[Change] = []
364
+ for change in changes:
365
+ if change.kind in (DISAPPEARED, WINDOW_CLOSED):
366
+ item = before.items.get(change.key) or next((i for i in before.items.values() if i.window == change.window), None)
367
+ box = _bounds(before, change.window) if change.kind == WINDOW_CLOSED else (item.where if item else None)
368
+ coverer = _covered_by(box, opened)
369
+ if coverer is not None:
370
+ out.append(Change(OCCLUDED, change.key, change.at, change.label, change.window,
371
+ was="in view", now=coverer, volatile=change.volatile, brought=change.brought))
372
+ continue
373
+ out.append(change)
374
+ return out
375
+
376
+
377
+ def _covered_by(box: tuple[int, int, int, int] | None, opened: Mapping[str, tuple[int, int, int, int]]) -> str | None:
378
+ """Which newly opened window covers most of a box, if one covers most of it."""
379
+ if box is None:
380
+ return None
381
+ x, y, w, h = box
382
+ area = max(1, w * h)
383
+ for window, (ox, oy, ow, oh) in sorted(opened.items()):
384
+ overlap = max(0, min(x + w, ox + ow) - max(x, ox)) * max(0, min(y + h, oy + oh) - max(y, oy))
385
+ if overlap / area >= 0.6:
386
+ return window
387
+ return None
388
+
389
+
390
+ def _bounds(snap: Snapshot, window: str) -> tuple[int, int, int, int] | None:
391
+ """The rectangle a window's known parts occupy."""
392
+ boxes = [i.where for i in snap.items.values() if i.window == window and i.where is not None]
393
+ if not boxes:
394
+ return None
395
+ left = min(b[0] for b in boxes)
396
+ top = min(b[1] for b in boxes)
397
+ right = max(b[0] + b[2] for b in boxes)
398
+ bottom = max(b[1] + b[3] for b in boxes)
399
+ return (left, top, right - left, bottom - top)
400
+
401
+
402
+ def _containing(bounds: Mapping[str, tuple[int, int, int, int] | None], item: Item | None,
403
+ wholes: Mapping[str, str], want: str, margin: int = 24) -> str | None:
404
+ """Which appearing/departing whole a stray part sits inside, if any."""
405
+ if item is None or item.where is None:
406
+ return None
407
+ centre = item.centre
408
+ if centre is None:
409
+ return None
410
+ for window, box in bounds.items():
411
+ if box is None or wholes.get(window) != want:
412
+ continue
413
+ x, y, w, h = box
414
+ if (x - margin) <= centre[0] <= (x + w + margin) and (y - margin) <= centre[1] <= (y + h + margin):
415
+ return window
416
+ return None
417
+
418
+
419
+ def _by_window(snap: Snapshot) -> dict[str, set[str]]:
420
+ out: dict[str, set[str]] = {}
421
+ for key, item in snap.items.items():
422
+ out.setdefault(item.window, set()).add(key)
423
+ return out
424
+
425
+
426
+ _ORDER = {WINDOW_OPENED: 0, WINDOW_CLOSED: 1, REPLACED: 2, APPEARED: 3, DISAPPEARED: 4, OCCLUDED: 5, VALUE: 6, FOCUS: 7, MOVED: 8}
427
+
428
+
429
+ def _shifted(was: Item, now: Item, threshold: int) -> bool:
430
+ a, b = was.centre, now.centre
431
+ if a is None or b is None:
432
+ return False
433
+ return abs(a[0] - b[0]) + abs(a[1] - b[1]) >= threshold
434
+
435
+
436
+ def _pair_up(gone: Mapping[str, Item], fresh: Mapping[str, Item], policy: ChangePolicy) -> dict[str, Item]:
437
+ """Match disappeared items to appeared ones that are plainly the same thing under a new key."""
438
+ pairs: dict[str, Item] = {}
439
+ taken: set[str] = set()
440
+ for new_key, now in sorted(fresh.items()):
441
+ best, best_cost = None, None
442
+ for old_key, was in sorted(gone.items()):
443
+ if old_key in taken or was.kind != now.kind or was.window != now.window:
444
+ continue
445
+ if was.label != now.label or not was.label:
446
+ continue
447
+ a, b = was.centre, now.centre
448
+ cost = (abs(a[0] - b[0]) + abs(a[1] - b[1])) if a and b else 0
449
+ if best_cost is None or cost < best_cost:
450
+ best, best_cost = old_key, cost
451
+ if best is not None:
452
+ taken.add(best)
453
+ pairs[new_key] = gone[best]
454
+ return pairs
455
+
456
+
457
+ @dataclass
458
+ class Watcher:
459
+ """Snapshots over time, so "what changed since X" can be asked of any earlier moment."""
460
+
461
+ policy: ChangePolicy = field(default_factory=ChangePolicy)
462
+ keep: int = 40
463
+ volatility: Volatility = field(init=False)
464
+ history: list[Snapshot] = field(default_factory=list)
465
+
466
+ def __post_init__(self) -> None:
467
+ self.volatility = Volatility(self.policy)
468
+
469
+ def see(self, items: Iterable[Item | Mapping[str, Any]], *, tag: str = "", at: datetime | None = None) -> Changes:
470
+ """Take a snapshot, learn what is volatile, and return what changed since the last one."""
471
+ snap = snapshot(items, at=at, tag=tag)
472
+ previous = self.history[-1] if self.history else None
473
+ if previous is not None:
474
+ self.volatility.watch(previous, snap)
475
+ changes = diff(previous, snap, policy=self.policy, volatility=self.volatility)
476
+ self.history.append(snap)
477
+ del self.history[: max(0, len(self.history) - self.keep)]
478
+ return changes
479
+
480
+ def since(self, tag: str) -> Changes | None:
481
+ """What changed between the snapshot with that tag and the latest one."""
482
+ marked = next((s for s in reversed(self.history) if s.tag == tag), None)
483
+ if marked is None or not self.history:
484
+ return None
485
+ return diff(marked, self.history[-1], policy=self.policy, volatility=self.volatility)
486
+
487
+ def since_first(self, tag: str) -> Changes | None:
488
+ """What changed since the FIRST snapshot carrying that tag — a turn boundary, not the latest frame."""
489
+ marked = next((s for s in self.history if s.tag == tag), None)
490
+ if marked is None or not self.history:
491
+ return None
492
+ return diff(marked, self.history[-1], policy=self.policy, volatility=self.volatility)
493
+
494
+ def latest(self) -> Snapshot | None:
495
+ return self.history[-1] if self.history else None
496
+
497
+ def tagged(self, tag: str) -> Snapshot | None:
498
+ return next((s for s in reversed(self.history) if s.tag == tag), None)
499
+
500
+
501
+ # ------------------------------------------------------------------ from claims
502
+
503
+
504
+ PERCEPT_PREDICATES = ("label", "reads", "value", "shows", "checked", "current", "announces")
505
+
506
+
507
+ def attribute_windows(items: Sequence[Item], *, margin: int = 8) -> list[Item]:
508
+ """Give window-less items the window whose area they sit in.
509
+
510
+ The scene graph attributes an item to a window only when the DOM says so, and it does not say
511
+ so for window chrome: a title bar, a Close button and a scrollbar all come through with no
512
+ window at all. Geometry knows better. Without this, anything that asks a question *about a
513
+ window* — what closed, what was covered — can only see the minority of items the DOM labelled,
514
+ which is how a closed window leaves most of its contents behind as still-existing objects.
515
+ """
516
+ bounds: dict[str, tuple[int, int, int, int]] = {}
517
+ for window in {i.window for i in items if i.window}:
518
+ boxes = [i.where for i in items if i.window == window and i.where is not None]
519
+ if not boxes:
520
+ continue
521
+ left, top = min(b[0] for b in boxes), min(b[1] for b in boxes)
522
+ right, bottom = max(b[0] + b[2] for b in boxes), max(b[1] + b[3] for b in boxes)
523
+ bounds[window] = (left, top, right - left, bottom - top)
524
+ if not bounds:
525
+ return list(items)
526
+ out: list[Item] = []
527
+ for item in items:
528
+ if item.window or item.where is None or item.centre is None:
529
+ out.append(item)
530
+ continue
531
+ cx, cy = item.centre
532
+ inside = [(w * h, name) for name, (x, y, w, h) in bounds.items()
533
+ if (x - margin) <= cx <= (x + w + margin) and (y - margin) <= cy <= (y + h + margin)]
534
+ # the smallest containing window: a dialog inside a window belongs to the dialog
535
+ out.append(replace(item, window=min(inside)[1]) if inside else item)
536
+ return out
537
+
538
+
539
+ def items_from_claims(records: Sequence[Any], entity_of: Any) -> list[Item]:
540
+ """Build snapshot items from perceptual claims (the vocabulary ``parse``-to-scene-graph emits).
541
+
542
+ One item per perceived subject: its label, whatever value it carries, where it is, and which
543
+ window it belongs to. Callers with a different vocabulary can build ``Item``s themselves.
544
+ """
545
+ by_subject: dict[str, dict[str, Any]] = {}
546
+ for rec in records:
547
+ claim = rec.claim
548
+ if claim.predicate not in PERCEPT_PREDICATES:
549
+ continue
550
+ row = by_subject.setdefault(claim.subject.id, {"key": claim.subject.id})
551
+ entity = entity_of(claim.subject)
552
+ box = getattr(entity, "box", None)
553
+ if box is not None and "where" not in row:
554
+ row["where"] = tuple(int(v) for v in box)
555
+ section = getattr(entity, "section", None)
556
+ if section and "window" not in row:
557
+ row["window"] = str(section)
558
+ role = getattr(entity, "role", None)
559
+ row["kind"] = "text" if claim.predicate in ("reads", "announces") else ("window" if role == "window" else "control")
560
+ if claim.predicate == "label":
561
+ row["label"] = str(claim.object)
562
+ elif claim.predicate == "current":
563
+ row["focused"] = bool(claim.object)
564
+ else:
565
+ row["value"] = str(getattr(claim.object, "value", claim.object))
566
+ return [Item(**{k: v for k, v in row.items() if k in Item.__dataclass_fields__}) for row in by_subject.values()]