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.
- tensorcode/__init__.py +84 -0
- tensorcode/actions.py +137 -0
- tensorcode/answer_type.py +222 -0
- tensorcode/awareness.py +344 -0
- tensorcode/backends/__init__.py +0 -0
- tensorcode/backends/builtin.py +167 -0
- tensorcode/backends/hf_local.py +89 -0
- tensorcode/backends/linear.py +133 -0
- tensorcode/backends/neural.py +361 -0
- tensorcode/causal.py +262 -0
- tensorcode/change.py +566 -0
- tensorcode/chunking.py +195 -0
- tensorcode/cognition.py +311 -0
- tensorcode/context.py +97 -0
- tensorcode/control.py +291 -0
- tensorcode/cues.py +192 -0
- tensorcode/expectation.py +270 -0
- tensorcode/frames.py +232 -0
- tensorcode/language/__init__.py +36 -0
- tensorcode/language/chart.py +558 -0
- tensorcode/language/discourse.py +132 -0
- tensorcode/language/domains/__init__.py +0 -0
- tensorcode/language/domains/desktop.py +552 -0
- tensorcode/language/english.py +459 -0
- tensorcode/language/features.py +112 -0
- tensorcode/language/generate.py +574 -0
- tensorcode/language/grammar.py +893 -0
- tensorcode/language/semantics.py +349 -0
- tensorcode/learning/__init__.py +30 -0
- tensorcode/learning/certificate.py +148 -0
- tensorcode/learning/induce.py +304 -0
- tensorcode/learning/library.py +217 -0
- tensorcode/learning/literals.py +126 -0
- tensorcode/learning/verify.py +253 -0
- tensorcode/memory.py +303 -0
- tensorcode/metacognition.py +351 -0
- tensorcode/ops.py +207 -0
- tensorcode/outcomes.py +99 -0
- tensorcode/permanence.py +376 -0
- tensorcode/priming.py +191 -0
- tensorcode/py.typed +0 -0
- tensorcode/quantity.py +311 -0
- tensorcode/records.py +728 -0
- tensorcode/relation.py +771 -0
- tensorcode/runtime.py +471 -0
- tensorcode/semantics_bridge.py +308 -0
- tensorcode/social.py +380 -0
- tensorcode/temporal.py +189 -0
- tensorcode/wants.py +185 -0
- tensorcode-0.1.0a1.dist-info/METADATA +196 -0
- tensorcode-0.1.0a1.dist-info/RECORD +53 -0
- tensorcode-0.1.0a1.dist-info/WHEEL +4 -0
- 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()]
|