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/permanence.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Object permanence: a thing that leaves view is occluded, not destroyed.
|
|
2
|
+
|
|
3
|
+
Snapshot scopes retract what is no longer perceived, which is right for *beliefs about now* and
|
|
4
|
+
wrong for *objects*. A window scrolled behind another still exists; a file still exists when the
|
|
5
|
+
terminal is cleared. Without permanence an agent cannot say "the Files window is still open, I
|
|
6
|
+
just can't see it", and identity does not survive occlusion.
|
|
7
|
+
|
|
8
|
+
The trap is that naive permanence re-introduces the stale-belief bug that retraction was there to
|
|
9
|
+
prevent. So an object file keeps two different things apart:
|
|
10
|
+
|
|
11
|
+
exists the object is known to exist (survives leaving view)
|
|
12
|
+
present it was perceived in the latest observation (does not)
|
|
13
|
+
|
|
14
|
+
Those two are independent, which is the point: three states, not two. In view; out of view but
|
|
15
|
+
believed to exist (occluded, scrolled away, behind another window); and *gone* — positively known
|
|
16
|
+
not to exist any more, because the window it lived in was seen to close. Only evidence of
|
|
17
|
+
destruction moves an object to the third state; simply not seeing it never does.
|
|
18
|
+
|
|
19
|
+
and every attribute is dated. Asking for an attribute of an absent object gets the value, when it
|
|
20
|
+
was last seen, and ``stale=True`` — never a bare assertion about the present. ``assertable``
|
|
21
|
+
exists so a caller can refuse to act on a stale attribute at all.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import datetime, timedelta, timezone
|
|
28
|
+
from typing import Any, Iterable, Mapping
|
|
29
|
+
|
|
30
|
+
from .change import Item, Snapshot
|
|
31
|
+
from .records import Claim, Evidence, Ref, Store
|
|
32
|
+
|
|
33
|
+
OBJECTS = Ref("scope:objects")
|
|
34
|
+
|
|
35
|
+
# what an object file writes to the store that can change from one look to the next
|
|
36
|
+
_CHANGING = frozenset({"last_seen", "in_view", "state", "exists"})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _now() -> datetime:
|
|
40
|
+
return datetime.now(timezone.utc)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Attribute:
|
|
45
|
+
"""A value, when it was seen, and whether that is old news."""
|
|
46
|
+
|
|
47
|
+
name: str
|
|
48
|
+
value: Any
|
|
49
|
+
seen_at: datetime
|
|
50
|
+
stale: bool
|
|
51
|
+
|
|
52
|
+
def describe(self) -> str:
|
|
53
|
+
when = self.seen_at.isoformat(timespec="seconds")
|
|
54
|
+
return f"{self.name}={self.value!r}" + (f" (as of {when}, not visible now)" if self.stale else "")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ObjectFile:
|
|
59
|
+
"""What is known about one thing, across every time it has been seen."""
|
|
60
|
+
|
|
61
|
+
ref: Ref
|
|
62
|
+
key: str
|
|
63
|
+
kind: str = "control"
|
|
64
|
+
label: str = ""
|
|
65
|
+
window: str = ""
|
|
66
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
67
|
+
seen_at: dict[str, datetime] = field(default_factory=dict)
|
|
68
|
+
first_seen: datetime = field(default_factory=_now)
|
|
69
|
+
last_seen: datetime = field(default_factory=_now)
|
|
70
|
+
times_seen: int = 1
|
|
71
|
+
present: bool = True
|
|
72
|
+
exists: bool = True # positively known to be gone only when its container was seen to close
|
|
73
|
+
gone_at: datetime | None = None
|
|
74
|
+
gone_because: str = ""
|
|
75
|
+
returns: int = 0 # how many times it came back after going away
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def state(self) -> str:
|
|
79
|
+
"""in_view | out_of_view | gone — the distinction a snapshot scope cannot make."""
|
|
80
|
+
if not self.exists:
|
|
81
|
+
return "gone"
|
|
82
|
+
return "in_view" if self.present else "out_of_view"
|
|
83
|
+
|
|
84
|
+
def attribute(self, name: str) -> Attribute | None:
|
|
85
|
+
if name not in self.attributes:
|
|
86
|
+
return None
|
|
87
|
+
return Attribute(name, self.attributes[name], self.seen_at.get(name, self.last_seen), not self.present)
|
|
88
|
+
|
|
89
|
+
def assertable(self, name: str, *, within: timedelta | None = None, now: datetime | None = None) -> bool:
|
|
90
|
+
"""May a caller state this attribute as current? Only if seen now, or fresh enough to risk."""
|
|
91
|
+
if name not in self.attributes:
|
|
92
|
+
return False
|
|
93
|
+
if not self.exists:
|
|
94
|
+
return False # an attribute of a destroyed object is never current
|
|
95
|
+
if self.present:
|
|
96
|
+
return True
|
|
97
|
+
if within is None:
|
|
98
|
+
return False
|
|
99
|
+
return ((now or _now()) - self.seen_at.get(name, self.last_seen)) <= within
|
|
100
|
+
|
|
101
|
+
def describe(self) -> str:
|
|
102
|
+
if not self.exists:
|
|
103
|
+
state = f"gone since {(self.gone_at or self.last_seen).isoformat(timespec='seconds')}"
|
|
104
|
+
state += f" ({self.gone_because})" if self.gone_because else ""
|
|
105
|
+
elif self.present:
|
|
106
|
+
state = "in view"
|
|
107
|
+
else:
|
|
108
|
+
state = f"not in view since {self.last_seen.isoformat(timespec='seconds')}"
|
|
109
|
+
return f"{self.label or self.key} ({self.kind}{', ' + self.window if self.window else ''}) — {state}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class ObservationReport:
|
|
114
|
+
"""What one observation did to the registry, so permanence can be measured."""
|
|
115
|
+
|
|
116
|
+
seen: int = 0
|
|
117
|
+
new: int = 0
|
|
118
|
+
rematched: int = 0 # same object under a different key
|
|
119
|
+
returned: int = 0 # was absent, is present again
|
|
120
|
+
absent: int = 0
|
|
121
|
+
identities_kept: int = 0 # objects that kept their file across this observation
|
|
122
|
+
wrong_about_gone: int = 0 # we had written something off, and there it was
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Objects:
|
|
126
|
+
"""A registry of object files, updated by observation.
|
|
127
|
+
|
|
128
|
+
Identity is by key when the key is stable, and by (kind, window, name) when it is not — the
|
|
129
|
+
scene-graph keys carry an occurrence index, so a list losing an earlier row renames every row
|
|
130
|
+
after it. Matching on the name first is what keeps a file attached to its object.
|
|
131
|
+
|
|
132
|
+
What counts as the name depends on what kind of thing it is, and getting this wrong is how a
|
|
133
|
+
measurement of permanence comes back empty. A control is named by its label: its value changes
|
|
134
|
+
while it stays the same control. A *line of read-only text* has no label and no life apart from
|
|
135
|
+
its content — the line "report.pdf" in a terminal **is** that content, and its key is only its
|
|
136
|
+
position in a scrolling region. Identify such an item by its value, or a file follows the slot
|
|
137
|
+
instead of the object, and a line that scrolls away looks like a line whose text changed.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(self, *, forget_absent_after: timedelta | None = None) -> None:
|
|
141
|
+
# keyed by the file's own id, never by a screen key: screen keys are recycled, and a
|
|
142
|
+
# registry keyed by them loses one object every time another takes over its slot
|
|
143
|
+
self.files: dict[str, ObjectFile] = {}
|
|
144
|
+
self._by_key: dict[str, str] = {}
|
|
145
|
+
self._by_identity: dict[tuple[str, str, str], str] = {}
|
|
146
|
+
self.forget_absent_after = forget_absent_after
|
|
147
|
+
self._counter = 0
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------- observing
|
|
150
|
+
|
|
151
|
+
def observe(self, snap: Snapshot) -> ObservationReport:
|
|
152
|
+
"""Update from one snapshot: what is here, what is merely not visible, what is new."""
|
|
153
|
+
seen: set[str] = set()
|
|
154
|
+
new = rematched = returned = wrong_about_gone = 0
|
|
155
|
+
for key, item in snap.items.items():
|
|
156
|
+
file = self._match(item, key)
|
|
157
|
+
if file is None:
|
|
158
|
+
file = self._create(item, snap.at)
|
|
159
|
+
new += 1
|
|
160
|
+
else:
|
|
161
|
+
if file.key != key:
|
|
162
|
+
rematched += 1
|
|
163
|
+
self._rekey(file, key)
|
|
164
|
+
if not file.present:
|
|
165
|
+
returned += 1
|
|
166
|
+
file.returns += 1
|
|
167
|
+
if not file.exists: # seeing it is stronger evidence than our belief that it was gone
|
|
168
|
+
file.exists, file.gone_at, file.gone_because = True, None, ""
|
|
169
|
+
wrong_about_gone += 1
|
|
170
|
+
file.times_seen += 1
|
|
171
|
+
file.present = True
|
|
172
|
+
file.last_seen = snap.at
|
|
173
|
+
file.label = item.label or file.label
|
|
174
|
+
file.window = item.window or file.window
|
|
175
|
+
self._by_key[item.key] = file.ref.id
|
|
176
|
+
self._by_identity[(file.kind, file.window, _name_of(item))] = file.ref.id
|
|
177
|
+
for name, value in _attributes(item).items():
|
|
178
|
+
if file.attributes.get(name) != value or name not in file.seen_at:
|
|
179
|
+
file.attributes[name] = value
|
|
180
|
+
file.seen_at[name] = snap.at
|
|
181
|
+
seen.add(file.ref.id)
|
|
182
|
+
absent = 0
|
|
183
|
+
for fid, file in list(self.files.items()):
|
|
184
|
+
if fid in seen:
|
|
185
|
+
continue
|
|
186
|
+
if file.present:
|
|
187
|
+
file.present = False
|
|
188
|
+
if file.exists:
|
|
189
|
+
absent += 1 # gone objects are not "absent": we are not waiting for them to come back
|
|
190
|
+
if self.forget_absent_after is not None and (snap.at - file.last_seen) > self.forget_absent_after:
|
|
191
|
+
self._drop(file)
|
|
192
|
+
return ObservationReport(len(snap.items), new, rematched, returned, absent,
|
|
193
|
+
identities_kept=len(snap.items) - new, wrong_about_gone=wrong_about_gone)
|
|
194
|
+
|
|
195
|
+
def closed(self, window: str, *, at: datetime | None = None, why: str = "its window closed") -> int:
|
|
196
|
+
"""A window was *seen* to close: what lived in it is gone, not merely out of sight.
|
|
197
|
+
|
|
198
|
+
This is the only way an object stops existing. Absence never implies it, because absence is
|
|
199
|
+
what occlusion looks like; a close event is evidence of destruction and nothing else is.
|
|
200
|
+
"""
|
|
201
|
+
when = at or _now()
|
|
202
|
+
marked = 0
|
|
203
|
+
for file in self.files.values():
|
|
204
|
+
if file.window != window or not file.exists:
|
|
205
|
+
continue
|
|
206
|
+
file.exists, file.present = False, False
|
|
207
|
+
file.gone_at, file.gone_because = when, why
|
|
208
|
+
marked += 1
|
|
209
|
+
return marked
|
|
210
|
+
|
|
211
|
+
def gone(self) -> list[ObjectFile]:
|
|
212
|
+
return sorted((f for f in self.files.values() if not f.exists), key=lambda f: f.key)
|
|
213
|
+
|
|
214
|
+
# ------------------------------------------------------------- reading
|
|
215
|
+
|
|
216
|
+
def get(self, key: str) -> ObjectFile | None:
|
|
217
|
+
"""The file for a screen key, or for a file id."""
|
|
218
|
+
fid = self._by_key.get(key)
|
|
219
|
+
return self.files.get(fid) if fid else self.files.get(key)
|
|
220
|
+
|
|
221
|
+
def find(self, label: str, *, kind: str | None = None, window: str | None = None) -> list[ObjectFile]:
|
|
222
|
+
rows = [f for f in self.files.values() if f.label == label
|
|
223
|
+
and (kind is None or f.kind == kind) and (window is None or f.window == window)]
|
|
224
|
+
return sorted(rows, key=lambda f: (not f.present, f.key))
|
|
225
|
+
|
|
226
|
+
def present(self) -> list[ObjectFile]:
|
|
227
|
+
return sorted((f for f in self.files.values() if f.present), key=lambda f: f.key)
|
|
228
|
+
|
|
229
|
+
def absent(self) -> list[ObjectFile]:
|
|
230
|
+
"""Out of view but believed to exist — not the same set as ``gone``."""
|
|
231
|
+
return sorted((f for f in self.files.values() if not f.present and f.exists), key=lambda f: f.key)
|
|
232
|
+
|
|
233
|
+
def known(self) -> list[ObjectFile]:
|
|
234
|
+
return sorted(self.files.values(), key=lambda f: f.key)
|
|
235
|
+
|
|
236
|
+
def windows(self, *, including_absent: bool = True) -> list[str]:
|
|
237
|
+
rows = self.files.values() if including_absent else self.present()
|
|
238
|
+
return sorted({f.window for f in rows if f.window})
|
|
239
|
+
|
|
240
|
+
# ------------------------------------------------------------- claims
|
|
241
|
+
|
|
242
|
+
def remember(self, mind: Store, *, source: str = "obs:objects", at: datetime | None = None) -> int:
|
|
243
|
+
"""Write existence and identity into a non-snapshot scope, so they survive retraction.
|
|
244
|
+
|
|
245
|
+
Only existence and identity go here. Attributes stay on the file, dated, because writing
|
|
246
|
+
them as plain claims is exactly how a stale belief would get asserted as current.
|
|
247
|
+
|
|
248
|
+
The handful that do change — whether it is in view, when it was last seen — are *replaced*,
|
|
249
|
+
not appended. Appending looks harmless and is not: a claim per object per turn is a leak
|
|
250
|
+
with a scope name, and on a desktop of a few hundred objects it outgrows perception itself
|
|
251
|
+
within a few dozen turns.
|
|
252
|
+
"""
|
|
253
|
+
when = at or _now()
|
|
254
|
+
written = 0
|
|
255
|
+
for file in self.known():
|
|
256
|
+
for predicate, value in (("exists", file.exists), ("is_a", file.kind), ("label", file.label),
|
|
257
|
+
("last_seen", file.last_seen), ("in_view", file.present),
|
|
258
|
+
("state", file.state)):
|
|
259
|
+
if value == "" or value is None:
|
|
260
|
+
continue
|
|
261
|
+
live = [r for r in mind.claims(file.ref, predicate, scope=OBJECTS) if not r.retracted]
|
|
262
|
+
if any(r.claim.object == value for r in live):
|
|
263
|
+
continue
|
|
264
|
+
if predicate in _CHANGING and live: # supersede: one current value, no history kept
|
|
265
|
+
mind.forget([r.id for r in live])
|
|
266
|
+
mind.tell(Claim(file.ref, predicate, value, scope=OBJECTS),
|
|
267
|
+
Evidence(Ref(source), when, method="object-permanence"))
|
|
268
|
+
written += 1
|
|
269
|
+
return written
|
|
270
|
+
|
|
271
|
+
# ------------------------------------------------------------- internals
|
|
272
|
+
|
|
273
|
+
def _create(self, item: Item, at: datetime) -> ObjectFile:
|
|
274
|
+
self._counter += 1
|
|
275
|
+
file = ObjectFile(Ref(f"object:{self._counter}"), item.key, item.kind, item.label, item.window,
|
|
276
|
+
first_seen=at, last_seen=at)
|
|
277
|
+
self.files[file.ref.id] = file
|
|
278
|
+
self._by_key[item.key] = file.ref.id
|
|
279
|
+
self._by_identity[(item.kind, item.window, _name_of(item))] = file.ref.id
|
|
280
|
+
return file
|
|
281
|
+
|
|
282
|
+
def _match(self, item: Item, key: str) -> ObjectFile | None:
|
|
283
|
+
"""The file this item belongs to, if any.
|
|
284
|
+
|
|
285
|
+
Name before key, and never a key whose file names something else: in a scrolling region the
|
|
286
|
+
keys are recycled, so trusting the key first hands one object's file to the next occupant of
|
|
287
|
+
its slot and quietly overwrites what was known about it.
|
|
288
|
+
"""
|
|
289
|
+
named = self._by_name(item)
|
|
290
|
+
if named is not None:
|
|
291
|
+
return named
|
|
292
|
+
held = self.files.get(self._by_key.get(key, ""))
|
|
293
|
+
if held is None:
|
|
294
|
+
return None
|
|
295
|
+
name, held_name = _name_of(item), _identity_name(held)
|
|
296
|
+
if name and held_name and name != held_name:
|
|
297
|
+
return None
|
|
298
|
+
return held
|
|
299
|
+
|
|
300
|
+
def _by_name(self, item: Item) -> ObjectFile | None:
|
|
301
|
+
name = _name_of(item)
|
|
302
|
+
if not name:
|
|
303
|
+
return None
|
|
304
|
+
fid = self._by_identity.get((item.kind, item.window, name))
|
|
305
|
+
return self.files.get(fid) if fid else None
|
|
306
|
+
|
|
307
|
+
def _rekey(self, file: ObjectFile, key: str) -> None:
|
|
308
|
+
"""The same object under a new screen key. Only the index moves; the file stays put."""
|
|
309
|
+
if self._by_key.get(file.key) == file.ref.id:
|
|
310
|
+
self._by_key.pop(file.key, None)
|
|
311
|
+
file.key = key
|
|
312
|
+
self._by_key[key] = file.ref.id
|
|
313
|
+
|
|
314
|
+
def _drop(self, file: ObjectFile) -> None:
|
|
315
|
+
self.files.pop(file.ref.id, None)
|
|
316
|
+
if self._by_key.get(file.key) == file.ref.id:
|
|
317
|
+
self._by_key.pop(file.key, None)
|
|
318
|
+
if self._by_identity.get((file.kind, file.window, _identity_name(file))) == file.ref.id:
|
|
319
|
+
self._by_identity.pop((file.kind, file.window, _identity_name(file)), None)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _name_of(item: Item) -> str:
|
|
323
|
+
"""What identifies this item across observations (see ``Objects``)."""
|
|
324
|
+
if item.label:
|
|
325
|
+
return item.label
|
|
326
|
+
return item.value if item.kind == "text" else ""
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _identity_name(file: ObjectFile) -> str:
|
|
330
|
+
if file.label:
|
|
331
|
+
return file.label
|
|
332
|
+
return str(file.attributes.get("value", "")) if file.kind == "text" else ""
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _attributes(item: Item) -> Mapping[str, Any]:
|
|
336
|
+
out: dict[str, Any] = {}
|
|
337
|
+
if item.value != "":
|
|
338
|
+
out["value"] = item.value
|
|
339
|
+
if item.where is not None:
|
|
340
|
+
out["where"] = item.where
|
|
341
|
+
out["focused"] = item.focused
|
|
342
|
+
return out
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def still_there(objects: Objects, label: str) -> str:
|
|
346
|
+
"""A sentence an agent can say honestly about something not in view."""
|
|
347
|
+
files = objects.find(label)
|
|
348
|
+
if not files:
|
|
349
|
+
return f"I have no record of {label}."
|
|
350
|
+
file = files[0]
|
|
351
|
+
if file.present:
|
|
352
|
+
return f"{label} is on screen now."
|
|
353
|
+
if not file.exists:
|
|
354
|
+
return (f"{label} is gone — {file.gone_because} at "
|
|
355
|
+
f"{(file.gone_at or file.last_seen).isoformat(timespec='seconds')}.")
|
|
356
|
+
return (f"{label} was there when I last saw it "
|
|
357
|
+
f"({file.last_seen.isoformat(timespec='seconds')}); I can't see it now, so I can't say it still is.")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def describe_attribute(objects: Objects, label: str, name: str) -> str:
|
|
361
|
+
"""Report an attribute without pretending an old reading is a current one."""
|
|
362
|
+
files = objects.find(label)
|
|
363
|
+
if not files:
|
|
364
|
+
return f"I have no record of {label}."
|
|
365
|
+
attribute = files[0].attribute(name)
|
|
366
|
+
if attribute is None:
|
|
367
|
+
return f"I never noted the {name} of {label}."
|
|
368
|
+
return f"{label}: {attribute.describe()}"
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def known_objects(snapshots: Iterable[Snapshot]) -> Objects:
|
|
372
|
+
"""Replay snapshots into a fresh registry (used by measurements and tests)."""
|
|
373
|
+
objects = Objects()
|
|
374
|
+
for snap in snapshots:
|
|
375
|
+
objects.observe(snap)
|
|
376
|
+
return objects
|
tensorcode/priming.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Action priming: procedures warmed by what is aware, firing when activation crosses.
|
|
2
|
+
|
|
3
|
+
Dispatch asks "which procedure handles this act?" and gets one answer. Priming asks nothing:
|
|
4
|
+
every procedure whose cues appear in the aware set gains activation, several are partly
|
|
5
|
+
warm at once, and one fires when it crosses its threshold. Lookup falls out as the special
|
|
6
|
+
case where a single cue (the parsed act) matches.
|
|
7
|
+
|
|
8
|
+
The synfire part is the chain. A procedure may declare ordered stages, and a stage only
|
|
9
|
+
accumulates once the stage before it has fired within a window of cycles; a gap lets the
|
|
10
|
+
chain cool and fall back. So "the terminal is open, then a command was typed, then the
|
|
11
|
+
prompt returned" is a sequence the priming can require, rather than three conditions a
|
|
12
|
+
guard happens to check together.
|
|
13
|
+
|
|
14
|
+
Nothing here executes: ``ready()`` reports what is warm enough, and ``why()`` says which
|
|
15
|
+
claims warmed it, so a caller keeps the decision.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any, Iterable, Sequence
|
|
22
|
+
|
|
23
|
+
from .records import ClaimRecord, Ref
|
|
24
|
+
|
|
25
|
+
_ANY = object()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Cue:
|
|
30
|
+
"""A claim pattern that warms a procedure, and how much it contributes."""
|
|
31
|
+
|
|
32
|
+
predicate: str | None = None
|
|
33
|
+
subject: Ref | None = None
|
|
34
|
+
object: Any = _ANY
|
|
35
|
+
weight: float = 1.0
|
|
36
|
+
|
|
37
|
+
def matches(self, rec: ClaimRecord) -> bool:
|
|
38
|
+
c = rec.claim
|
|
39
|
+
return ((self.predicate is None or c.predicate == self.predicate)
|
|
40
|
+
and (self.subject is None or c.subject == self.subject)
|
|
41
|
+
and (self.object is _ANY or c.object == self.object))
|
|
42
|
+
|
|
43
|
+
def describe(self) -> str:
|
|
44
|
+
parts = [f"{self.subject}" if self.subject else "?", self.predicate or "?",
|
|
45
|
+
"?" if self.object is _ANY else repr(self.object)]
|
|
46
|
+
return " ".join(parts)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Primeable:
|
|
51
|
+
"""Something that can be primed: an id, its cues, and optionally an ordered chain."""
|
|
52
|
+
|
|
53
|
+
id: str
|
|
54
|
+
cues: tuple[Cue, ...] = ()
|
|
55
|
+
threshold: float = 0.6
|
|
56
|
+
chain: tuple[tuple[Cue, ...], ...] = () # ordered stages; a stage opens only after the one before
|
|
57
|
+
window: int = 3 # cycles a stage stays open before the chain cools
|
|
58
|
+
payload: Any = None # whatever the caller wants back (a Procedure, a callable, a name)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def staged(self) -> bool:
|
|
62
|
+
return bool(self.chain)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def primeable_from(obj: Any, *, threshold: float = 0.6) -> Primeable:
|
|
66
|
+
"""Read a primeable off an object that may already describe its own cues.
|
|
67
|
+
|
|
68
|
+
Honours ``cues``, ``prime_threshold``, ``chain`` and ``window`` when present. With none
|
|
69
|
+
of them, a procedure that declares ``act`` gets the single cue that reproduces dispatch:
|
|
70
|
+
a claim ``(request, "act", <act>)``. So a body of procedures becomes primeable without
|
|
71
|
+
being rewritten, and declaring cues is how one stops being merely dispatchable.
|
|
72
|
+
"""
|
|
73
|
+
ident = str(getattr(obj, "id", obj))
|
|
74
|
+
cues = tuple(getattr(obj, "cues", ()) or ())
|
|
75
|
+
chain = tuple(tuple(stage) for stage in (getattr(obj, "chain", ()) or ()))
|
|
76
|
+
act = getattr(obj, "act", None)
|
|
77
|
+
if not cues and not chain and act:
|
|
78
|
+
cues = (Cue(predicate="act", object=act),)
|
|
79
|
+
return Primeable(ident, cues, float(getattr(obj, "prime_threshold", threshold)), chain,
|
|
80
|
+
int(getattr(obj, "window", 3)), payload=obj)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Priming:
|
|
85
|
+
"""Activation over a set of primeables, driven by the aware set."""
|
|
86
|
+
|
|
87
|
+
primeables: list[Primeable] = field(default_factory=list)
|
|
88
|
+
decay: float = 0.5
|
|
89
|
+
floor: float = 0.02
|
|
90
|
+
activation: dict[str, float] = field(default_factory=dict)
|
|
91
|
+
stage: dict[str, int] = field(default_factory=dict)
|
|
92
|
+
_last_stage_cycle: dict[str, int] = field(default_factory=dict)
|
|
93
|
+
_matched: dict[str, list[tuple[str, str, float]]] = field(default_factory=dict) # id -> (cue, claim id, weight)
|
|
94
|
+
cycle: int = 0
|
|
95
|
+
|
|
96
|
+
def add(self, primeable: Primeable) -> None:
|
|
97
|
+
self.primeables.append(primeable)
|
|
98
|
+
|
|
99
|
+
def observe(self, aware: Any, *, saliences: dict[str, float] | None = None) -> None:
|
|
100
|
+
"""One cycle: warm what the aware set matches, cool what it does not."""
|
|
101
|
+
records, salience_of = _records_and_salience(aware, saliences)
|
|
102
|
+
self.cycle += 1
|
|
103
|
+
for p in self.primeables:
|
|
104
|
+
before = self.activation.get(p.id, 0.0) * self.decay
|
|
105
|
+
matched: list[tuple[str, str, float]] = []
|
|
106
|
+
if p.staged:
|
|
107
|
+
stage_index = self.stage.get(p.id, 0)
|
|
108
|
+
gained = 0.0
|
|
109
|
+
if stage_index < len(p.chain):
|
|
110
|
+
for cue in p.chain[stage_index]:
|
|
111
|
+
for rec in records:
|
|
112
|
+
if cue.matches(rec):
|
|
113
|
+
gained += cue.weight * (salience_of(rec.id) or 1e-3)
|
|
114
|
+
matched.append((cue.describe(), rec.id, cue.weight))
|
|
115
|
+
if gained > 0:
|
|
116
|
+
self.stage[p.id] = stage_index + 1
|
|
117
|
+
self._last_stage_cycle[p.id] = self.cycle
|
|
118
|
+
elif self.cycle - self._last_stage_cycle.get(p.id, self.cycle) > p.window:
|
|
119
|
+
self.stage[p.id] = 0 # the chain cooled: back to the beginning
|
|
120
|
+
before *= 0.0
|
|
121
|
+
self.activation[p.id] = min(1.5, before + gained)
|
|
122
|
+
else:
|
|
123
|
+
gained = 0.0
|
|
124
|
+
for cue in p.cues:
|
|
125
|
+
for rec in records:
|
|
126
|
+
if cue.matches(rec):
|
|
127
|
+
gained += cue.weight * (salience_of(rec.id) or 1e-3)
|
|
128
|
+
matched.append((cue.describe(), rec.id, cue.weight))
|
|
129
|
+
self.activation[p.id] = min(1.5, before + gained)
|
|
130
|
+
if self.activation[p.id] < self.floor:
|
|
131
|
+
self.activation.pop(p.id, None)
|
|
132
|
+
if matched:
|
|
133
|
+
self._matched[p.id] = matched
|
|
134
|
+
|
|
135
|
+
def partial(self) -> dict[str, float]:
|
|
136
|
+
"""Everything warm, whether or not it is ready — several procedures are usually partly on."""
|
|
137
|
+
return dict(sorted(self.activation.items(), key=lambda kv: (-kv[1], kv[0])))
|
|
138
|
+
|
|
139
|
+
def ready(self) -> list[tuple[Primeable, float]]:
|
|
140
|
+
"""What has crossed its threshold (and finished its chain), strongest first."""
|
|
141
|
+
out = []
|
|
142
|
+
for p in self.primeables:
|
|
143
|
+
level = self.activation.get(p.id, 0.0)
|
|
144
|
+
if level < p.threshold:
|
|
145
|
+
continue
|
|
146
|
+
if p.staged and self.stage.get(p.id, 0) < len(p.chain):
|
|
147
|
+
continue
|
|
148
|
+
out.append((p, level))
|
|
149
|
+
return sorted(out, key=lambda pair: (-pair[1], pair[0].id))
|
|
150
|
+
|
|
151
|
+
def why(self, primeable_id: str) -> list[str]:
|
|
152
|
+
level = self.activation.get(primeable_id, 0.0)
|
|
153
|
+
p = next((q for q in self.primeables if q.id == primeable_id), None)
|
|
154
|
+
if p is None:
|
|
155
|
+
return [f"{primeable_id} is not primed here"]
|
|
156
|
+
lines = [f"{primeable_id} at {level:.3f} (threshold {p.threshold:.2f})"]
|
|
157
|
+
if p.staged:
|
|
158
|
+
lines.append(f" chain stage {self.stage.get(primeable_id, 0)} of {len(p.chain)}")
|
|
159
|
+
for cue, claim_id, weight in self._matched.get(primeable_id, []):
|
|
160
|
+
lines.append(f" {cue} matched {claim_id} (+{weight:.2f})")
|
|
161
|
+
return lines
|
|
162
|
+
|
|
163
|
+
def reset(self, primeable_id: str) -> None:
|
|
164
|
+
"""After firing, or after the situation changed: this one starts cold."""
|
|
165
|
+
self.activation.pop(primeable_id, None)
|
|
166
|
+
self.stage.pop(primeable_id, None)
|
|
167
|
+
self._last_stage_cycle.pop(primeable_id, None)
|
|
168
|
+
self._matched.pop(primeable_id, None)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def prime(primeables: Sequence[Any], aware: Any, *, threshold: float = 0.6) -> Priming:
|
|
172
|
+
"""Build priming over a set of procedure-like objects and observe one cycle."""
|
|
173
|
+
p = Priming([q if isinstance(q, Primeable) else primeable_from(q, threshold=threshold) for q in primeables])
|
|
174
|
+
p.observe(aware)
|
|
175
|
+
return p
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _records_and_salience(aware: Any, saliences: dict[str, float] | None) -> tuple[list[ClaimRecord], Any]:
|
|
179
|
+
"""Read the aware set and its saliences once; a cue match must not recompute them."""
|
|
180
|
+
if hasattr(aware, "aware") and hasattr(aware, "salience"): # an Awareness
|
|
181
|
+
records = aware.aware()
|
|
182
|
+
table = {rec.id: max(aware.salience(rec.id), 1e-3) for rec in records}
|
|
183
|
+
return records, table.get
|
|
184
|
+
records = list(aware.claims() if hasattr(aware, "claims") else aware)
|
|
185
|
+
table = saliences or {}
|
|
186
|
+
return records, lambda cid: table.get(cid, 1.0)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def cues_for_sequence(*stages: Iterable[Cue]) -> tuple[tuple[Cue, ...], ...]:
|
|
190
|
+
"""Spell a chain: ``cues_for_sequence([Cue(...)], [Cue(...)])`` in the order they must occur."""
|
|
191
|
+
return tuple(tuple(stage) for stage in stages)
|
tensorcode/py.typed
ADDED
|
File without changes
|