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
@@ -0,0 +1,253 @@
1
+ """Whether an induced artifact may be adopted, and the controls that say what it means.
2
+
3
+ A fit is not a finding. Both source repos record the same lesson from opposite
4
+ directions: ``typed-crystallization-networks`` found a mined abstraction helping
5
+ exactly as much as a *wrong* abstraction of the same size (research §44), and
6
+ ``symbolic-ai-models`` states it as a rule — "same-cardinality-but-meaningless is
7
+ this repo's most productive instrument … it corrected four separate published
8
+ results" (``symbolic_ai_lean/gate.py``). So an artifact is measured against four
9
+ controls, in the shape that file pre-registered:
10
+
11
+ ``held_out``
12
+ Accuracy on cases the inducer never saw. A rule admitted on its training fit
13
+ alone is the failure mode TCN's §65 records.
14
+ ``random``
15
+ The same artifact with its decisions shuffled among the same labels: a
16
+ same-cardinality, meaningless competitor. Beating it is the minimum.
17
+ ``shifted``
18
+ The artifact applied to the *wrong question* — cases relabelled from a
19
+ different pool. Its accuracy must fall to the random level; if it does not,
20
+ the artifact is reading something other than the question.
21
+ ``renamed``
22
+ Every symbol consistently renamed. The verdicts must be **identical**. An
23
+ artifact that moves under renaming is reading vocabulary, which is how
24
+ ``ensemble-001``'s headline result died (0.467 → 0.038).
25
+
26
+ Adoption also requires a **simpler competitor** to lose: a one-condition rule, or
27
+ the majority label. Nothing is adopted silently, and nothing is rejected silently
28
+ either — :class:`Verification` records the numbers and the reason.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import random
34
+ from dataclasses import dataclass, field
35
+ from typing import Any, Callable, Mapping, Sequence
36
+
37
+ from ..outcomes import Verdict
38
+ from .induce import DecisionList, Rule, decision_list
39
+ from .literals import Case, Literal, rename_facts, rename_map
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Verification:
44
+ """What the controls said, and whether that is enough to adopt."""
45
+
46
+ held_out: float
47
+ train: float
48
+ random: float
49
+ shifted: float
50
+ renamed_identical: bool
51
+ floor: float # the best simple competitor (majority label, or a one-condition rule)
52
+ reasons: tuple[str, ...] = ()
53
+
54
+ @property
55
+ def verdict(self) -> Verdict:
56
+ return Verdict("holds" if not self.reasons else "fails", self.reasons)
57
+
58
+ @property
59
+ def adopted(self) -> bool:
60
+ return not self.reasons
61
+
62
+ def report(self) -> str:
63
+ rows = [
64
+ f"held-out {self.held_out:.3f}",
65
+ f"train {self.train:.3f}",
66
+ f"floor {self.floor:.3f} (majority or one condition)",
67
+ f"random {self.random:.3f} (same labels, shuffled)",
68
+ f"shifted {self.shifted:.3f} (wrong question; should fall to random)",
69
+ f"renamed {'identical' if self.renamed_identical else 'CHANGED — reading vocabulary'}",
70
+ f"verdict {'adopt' if self.adopted else 'reject: ' + '; '.join(self.reasons)}",
71
+ ]
72
+ return "\n".join(rows)
73
+
74
+
75
+ def verify_decision_list(
76
+ artifact: DecisionList,
77
+ *,
78
+ train: Sequence[Case],
79
+ held_out: Sequence[Case],
80
+ literals: Sequence[Literal],
81
+ shifted: Sequence[Case] = (),
82
+ margin: float = 0.02,
83
+ seed: int = 0,
84
+ ) -> Verification:
85
+ """Run the four controls plus the simpler-competitor floor over an induced list."""
86
+ reasons: list[str] = []
87
+ train_acc = artifact.accuracy(train)
88
+ held = artifact.accuracy(held_out)
89
+
90
+ # floor: the majority label, and the best single-condition rule
91
+ labels = [label for _, label in train]
92
+ majority = max(set(labels), key=labels.count) if labels else None
93
+ floor = sum(label == majority for _, label in held_out) / max(1, len(held_out))
94
+ # the "one-line hand rule" competitor: a single condition plus a default. TCN's
95
+ # findings record learned priors being matched by exactly this.
96
+ one = decision_list(train, literals, max_conditions=1, mdl=False, max_rules=1)
97
+ floor = max(floor, one.accuracy(held_out))
98
+
99
+ # random: the same rules with their labels shuffled among the same multiset. One
100
+ # shuffle can come back as the identity, so this is the mean over several — the
101
+ # control is an *expected* accuracy, not a single draw.
102
+ rng = random.Random(seed)
103
+ original = [r.label for r in artifact.rules]
104
+ draws: list[float] = []
105
+ for _ in range(20 if len(original) > 1 else 0):
106
+ shuffled = list(original)
107
+ rng.shuffle(shuffled)
108
+ if shuffled == original:
109
+ continue
110
+ scrambled = DecisionList([Rule(r.conditions, label, r.support, r.correct)
111
+ for r, label in zip(artifact.rules, shuffled)], artifact.default)
112
+ draws.append(scrambled.accuracy(held_out))
113
+ random_acc = sum(draws) / len(draws) if draws else floor
114
+
115
+ # shifted: the right artifact, the wrong question
116
+ shifted_acc = artifact.accuracy(shifted) if shifted else random_acc
117
+
118
+ # renamed: the *same* artifact, run on consistently renamed cases. Renaming the
119
+ # artifact too would hide exactly the failure this control exists to catch — an
120
+ # artifact that is reading vocabulary rather than structure.
121
+ mapping = rename_map(list(train) + list(held_out))
122
+ identical = all(artifact.predict(rename_facts(facts, mapping)) == artifact.predict(facts)
123
+ for facts, _ in held_out)
124
+
125
+ if held < floor + margin:
126
+ reasons.append(f"held-out {held:.3f} does not beat the simple floor {floor:.3f}")
127
+ if held < random_acc + margin:
128
+ reasons.append(f"held-out {held:.3f} does not beat the same-size random control {random_acc:.3f}")
129
+ if shifted and shifted_acc > random_acc + 0.1:
130
+ reasons.append(f"scores {shifted_acc:.3f} on the wrong question: it is not reading the question")
131
+ if not identical:
132
+ reasons.append("verdicts change under renaming: it is reading vocabulary, not structure")
133
+ return Verification(held, train_acc, random_acc, shifted_acc, identical, floor, tuple(reasons))
134
+
135
+
136
+ # ------------------------------------------------------------ concept adoption
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class Concept:
141
+ """A proposed predicate: a name, a definition over existing claims, and its evidence."""
142
+
143
+ name: str
144
+ definition: tuple[Literal, ...]
145
+ support: int
146
+ functional: bool = False
147
+
148
+ def holds(self, facts: Any) -> bool:
149
+ return all(c.holds(facts) for c in self.definition)
150
+
151
+ def __repr__(self) -> str:
152
+ return f"{self.name} ⇔ " + " ∧ ".join(repr(c) for c in self.definition)
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class ConceptCheck:
157
+ """Whether a proposed concept may join the vocabulary."""
158
+
159
+ concept: Concept
160
+ covers: int
161
+ round_trips: bool
162
+ renamed_identical: bool
163
+ conflicts: tuple[str, ...]
164
+ reasons: tuple[str, ...]
165
+
166
+ @property
167
+ def adopted(self) -> bool:
168
+ return not self.reasons
169
+
170
+
171
+ def check_concept(concept: Concept, *, positives: Sequence[frozenset], negatives: Sequence[frozenset],
172
+ functional_values: Callable[[frozenset], Any] | None = None,
173
+ min_support: int = 3) -> ConceptCheck:
174
+ """A concept is adopted only if it separates, round-trips, and survives renaming.
175
+
176
+ * it must hold of the positives and not of the negatives (it has content);
177
+ * **round trip**: re-describing a case with the concept must not lose what the
178
+ concept was defined from, so the original claims can still be derived;
179
+ * renaming symbols must not change which cases it covers;
180
+ * a functional predicate must not give one subject two values.
181
+ """
182
+ reasons: list[str] = []
183
+ covers = sum(concept.holds(facts) for facts in positives)
184
+ leaks = sum(concept.holds(facts) for facts in negatives)
185
+ if covers < min_support:
186
+ reasons.append(f"covers only {covers} cases (needs {min_support})")
187
+ if leaks:
188
+ reasons.append(f"also holds of {leaks} negative cases: it does not separate them")
189
+
190
+ # round trip: the definition's own predicates must still be present in the case
191
+ round_trips = all(all(c.holds(facts) for c in concept.definition) for facts in positives if concept.holds(facts))
192
+ if not round_trips:
193
+ reasons.append("re-describing loses the claims it was defined from")
194
+
195
+ mapping = rename_map([(facts, None) for facts in list(positives) + list(negatives)])
196
+ renamed_positives = [rename_facts(facts, mapping) for facts in positives]
197
+ identical = [concept.holds(f) for f in positives] == [concept.holds(f) for f in renamed_positives]
198
+ if not identical:
199
+ reasons.append("coverage changes under renaming")
200
+
201
+ conflicts: list[str] = []
202
+ if concept.functional and functional_values is not None:
203
+ seen: dict[Any, Any] = {}
204
+ for facts in positives:
205
+ if not concept.holds(facts):
206
+ continue
207
+ key = tuple(sorted(facts, key=repr))
208
+ value = functional_values(facts)
209
+ if key in seen and seen[key] != value:
210
+ conflicts.append(f"two values for one subject: {seen[key]!r} and {value!r}")
211
+ seen[key] = value
212
+ if conflicts:
213
+ reasons.append("contradiction on a functional predicate")
214
+ return ConceptCheck(concept, covers, round_trips, identical, tuple(conflicts), tuple(reasons))
215
+
216
+
217
+ def propose_concepts(cases: Sequence[Case], literals: Sequence[Literal], *, label: Any,
218
+ max_conditions: int = 2, top: int = 3) -> list[Concept]:
219
+ """Candidate definitions for "what these cases have in common", purest first.
220
+
221
+ Grouping is by *behaviour* — which cases a definition covers — not by syntax, so
222
+ two spellings of one concept collapse into a single candidate. TCN's research
223
+ §46 found one real concept arriving as 8–12 syntactically different copies.
224
+ """
225
+ positives = [facts for facts, y in cases if y == label]
226
+ negatives = [facts for facts, y in cases if y != label]
227
+ if not positives:
228
+ return []
229
+ scored: dict[tuple, tuple[float, Concept]] = {}
230
+ for size in range(1, max_conditions + 1):
231
+ for combo in _combinations(literals, size):
232
+ covered = tuple(i for i, facts in enumerate(positives) if all(c.holds(facts) for c in combo))
233
+ if not covered:
234
+ continue
235
+ leaks = sum(all(c.holds(facts) for c in combo) for facts in negatives)
236
+ purity = len(covered) / (len(covered) + leaks)
237
+ concept = Concept(f"{label}_like", tuple(combo), len(covered))
238
+ best = scored.get(covered)
239
+ cost = sum(c.cost() for c in combo)
240
+ if best is None or (purity, -cost) > (best[0], -sum(c.cost() for c in best[1].definition)):
241
+ scored[covered] = (purity, concept)
242
+ ranked = sorted(scored.values(), key=lambda row: (-row[0], -row[1].support))
243
+ return [concept for _, concept in ranked[:top]]
244
+
245
+
246
+ def _combinations(items: Sequence[Literal], size: int) -> Any:
247
+ if size == 1:
248
+ for item in items:
249
+ yield (item,)
250
+ return
251
+ for i, first in enumerate(items):
252
+ for rest in _combinations(items[i + 1:], size - 1):
253
+ yield (first, *rest)
tensorcode/memory.py ADDED
@@ -0,0 +1,303 @@
1
+ """Memory as several stores with different dynamics over one claim substrate.
2
+
3
+ working the awareness buffer: small, fast to decay, what thinking runs over
4
+ episodic time-indexed traces of what happened, encoded by salience, decaying
5
+ semantic generalizations, including what someone simply told us
6
+ spatial the same claims reached by where they are (``frames.Frames``)
7
+ procedural skills, recalled by how well a cue fits them
8
+ associative recall by cue similarity, not by exact pattern match
9
+
10
+ They are not separate databases. An episode is an entity with claims pointing at the claims
11
+ it contains, a generalization is a claim in the semantic scope, and forgetting is
12
+ ``Store.forget``. What differs is the dynamics: episodic memory decays and consolidates,
13
+ semantic memory does not, and working memory holds only what is aware.
14
+
15
+ Forgetting is real and measured. Nothing is evicted while something still rests on it, and
16
+ told facts and generalizations are protected, so what goes is stale perception.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import time as _time
22
+ from dataclasses import dataclass, field
23
+ from datetime import datetime, timedelta, timezone
24
+ from typing import Any, Iterable, Sequence
25
+
26
+ from .awareness import Awareness, AwarenessPolicy
27
+ from .cognition import Thought
28
+ from .context import shingle_similarity
29
+ from .outcomes import Score
30
+ from .records import Claim, ClaimRecord, Evidence, Ref, Store
31
+
32
+ SEMANTIC = Ref("scope:semantic")
33
+ EPISODIC = Ref("scope:episodic")
34
+
35
+
36
+ def _now() -> datetime:
37
+ return datetime.now(timezone.utc)
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class MemoryPolicy:
42
+ """The dynamics: how much is held, how fast it fades, what is never dropped."""
43
+
44
+ working_budget: int = 64
45
+ episodic_capacity: int = 400 # episodes kept before the least salient are dropped
46
+ half_life: timedelta = timedelta(minutes=30) # perceptual claims older than this may go
47
+ min_salience: float = 0.15 # below this a stale claim is eligible to be forgotten
48
+ consolidate_after: int = 3 # how many episodes must agree before it becomes semantic
49
+ protect_scopes: tuple[Ref | None, ...] = (SEMANTIC,)
50
+ protect_predicates: frozenset[str] = frozenset({"said", "told", "name"})
51
+ # Testimony is not perception, and the half-life above is a perceptual one. A fact someone told
52
+ # you does not become doubtful because you have not looked at it lately, and it cannot be
53
+ # protected by predicate name: the predicate of a told fact is whatever word the teller used
54
+ # ("my cat is Mackerel" files `cat`), so a list of protected spellings can never contain it.
55
+ # Provenance can: claims whose evidence comes from an utterance are held on those grounds.
56
+ #
57
+ # Either party's utterance. Protecting only what the *user* said looks right and is the same
58
+ # mistake one level down: the record of having said something oneself is an utterance too, and
59
+ # dropping it leaves an assistant that still knows your name and has forgotten that it already
60
+ # told you it does. This list is a convention about source spellings, not the principle — a
61
+ # caller whose utterances are named differently has to extend it.
62
+ protect_sources: tuple[str, ...] = ("utterance:", "person:", "user:", "reply:", "said:")
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class TurnReport:
67
+ """What one turn of memory dynamics did, so the dynamics can be watched rather than trusted."""
68
+
69
+ episode: "Episode | None" = None
70
+ consolidated: int = 0
71
+ forgotten: "ForgetReport | None" = None
72
+ ms: float = 0.0
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class Episode:
77
+ """One remembered happening: when, how much it mattered, and what it was made of."""
78
+
79
+ ref: Ref
80
+ at: datetime
81
+ salience: float
82
+ summary: str
83
+ claims: tuple[str, ...]
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class Recollection:
88
+ """What recall returned, and why each item came back."""
89
+
90
+ episodes: tuple[tuple[Episode, Score], ...] = ()
91
+ claims: tuple[tuple[ClaimRecord, Score], ...] = ()
92
+ cue: str = ""
93
+
94
+ def why(self) -> list[str]:
95
+ lines = [f"cue {self.cue!r}"]
96
+ lines += [f" episode {e.ref} ({e.summary!r}) similarity {s.value:.2f}" for e, s in self.episodes]
97
+ lines += [f" claim {r.claim.subject} {r.claim.predicate} {getattr(r.claim.object, 'value', r.claim.object)!r} similarity {s.value:.2f}"
98
+ for r, s in self.claims]
99
+ return lines
100
+
101
+
102
+ @dataclass
103
+ class ForgetReport:
104
+ """What forgetting actually did, so it can be measured rather than assumed."""
105
+
106
+ claims_forgotten: int = 0
107
+ episodes_dropped: int = 0
108
+ kept_because_depended_on: int = 0
109
+ kept_because_protected: int = 0
110
+ kept_because_testimony: int = 0 # someone said so: not perception, so not on a perceptual clock
111
+ ms: float = 0.0
112
+
113
+
114
+ class Memory:
115
+ """The several memories of one mind."""
116
+
117
+ def __init__(self, mind: Store, policy: MemoryPolicy | None = None, *,
118
+ awareness: Awareness | None = None, frames: Any = None) -> None:
119
+ self.mind = mind
120
+ self.policy = policy or MemoryPolicy()
121
+ self.frames = frames
122
+ self.working = awareness or Awareness(mind, AwarenessPolicy(budget=self.policy.working_budget),
123
+ extra_links=frames.links if frames is not None else None)
124
+ self._episodes: dict[Ref, Episode] = {}
125
+ self._counter = 0
126
+ self._turns = 0
127
+
128
+ # ---------------------------------------------------------------- episodic
129
+
130
+ def encode(self, what: Thought | Sequence[ClaimRecord], *, summary: str = "", salience: float | None = None,
131
+ at: datetime | None = None) -> Episode:
132
+ """Lay down an episode. Salience defaults to how aware its claims were."""
133
+ records = list(what.added) if isinstance(what, Thought) else list(what)
134
+ when = at or _now()
135
+ self._counter += 1
136
+ ref = Ref(f"episode:{self._counter}")
137
+ if salience is None:
138
+ saliences = [self.working.salience(r.id) for r in records]
139
+ salience = max(saliences) if saliences else 0.2
140
+ text = summary or "; ".join(_describe(r) for r in records[:4])
141
+ episode = Episode(ref, when, round(float(salience), 4), text, tuple(sorted(r.id for r in records)))
142
+ self._episodes[ref] = episode
143
+ source = Ref("memory:encode")
144
+ edits = [Claim(ref, "at", when, scope=EPISODIC), Claim(ref, "salience", episode.salience, scope=EPISODIC),
145
+ Claim(ref, "summary", episode.summary, scope=EPISODIC)]
146
+ edits += [Claim(ref, "contains", cid, scope=EPISODIC) for cid in episode.claims]
147
+ for claim in edits:
148
+ self.mind.tell(claim, Evidence(source, when, method="encode-episode"))
149
+ return episode
150
+
151
+ def episodes(self, *, since: datetime | None = None, min_salience: float = 0.0) -> list[Episode]:
152
+ rows = [e for e in self._episodes.values() if e.salience >= min_salience and (since is None or e.at >= since)]
153
+ return sorted(rows, key=lambda e: (e.at, e.ref.id))
154
+
155
+ def recall(self, cue: str, k: int = 3, *, min_similarity: float = 0.05) -> Recollection:
156
+ """Associative recall: episodes and claims whose text is nearest the cue."""
157
+ scored_e = []
158
+ for episode in self._episodes.values():
159
+ score = shingle_similarity(cue, f"{episode.summary}", n=2)
160
+ if score.value >= min_similarity:
161
+ scored_e.append((episode, score))
162
+ scored_e.sort(key=lambda pair: (-pair[1].value, -pair[0].salience, pair[0].ref.id))
163
+ scored_c = []
164
+ for rec in self.mind.claims():
165
+ if rec.claim.scope == EPISODIC:
166
+ continue
167
+ score = shingle_similarity(cue, _describe(rec), n=2)
168
+ if score.value >= min_similarity:
169
+ scored_c.append((rec, score))
170
+ scored_c.sort(key=lambda pair: (-pair[1].value, pair[0].id))
171
+ return Recollection(tuple(scored_e[:k]), tuple(scored_c[:k]), cue)
172
+
173
+ def consolidate(self) -> list[Claim]:
174
+ """What several episodes agree on becomes semantic: a generalization citing its episodes."""
175
+ counts: dict[tuple[Ref, str, str], list[Ref]] = {}
176
+ for episode in self.episodes():
177
+ for cid in episode.claims:
178
+ rec = self.mind._claims.get(cid)
179
+ if rec is None or rec.retracted or rec.claim.scope == EPISODIC:
180
+ continue
181
+ key = (rec.claim.subject, rec.claim.predicate, repr(rec.claim.object))
182
+ counts.setdefault(key, []).append(episode.ref)
183
+ made = []
184
+ for (subject, predicate, _), refs in sorted(counts.items(), key=lambda kv: (kv[0][0].id, kv[0][1], kv[0][2])):
185
+ if len(refs) < self.policy.consolidate_after:
186
+ continue
187
+ source_claim = next(r for r in self.mind.claims(subject=subject, predicate=predicate))
188
+ claim = Claim(subject, predicate, source_claim.claim.object, scope=SEMANTIC)
189
+ if self.mind.claims(subject=subject, predicate=predicate, scope=SEMANTIC):
190
+ continue
191
+ self.mind.tell(claim, Evidence(Ref("memory:consolidate"), _now(), method="consolidate",
192
+ confidence=Score(min(1.0, len(refs) / (self.policy.consolidate_after * 2)), "uncalibrated"),
193
+ derived_from=tuple(sorted(dict.fromkeys(cid for r in refs for cid in self._episodes[r].claims
194
+ if self.mind._claims.get(cid) and
195
+ self.mind._claims[cid].claim.subject == subject and
196
+ self.mind._claims[cid].claim.predicate == predicate)))))
197
+ made.append(claim)
198
+ return made
199
+
200
+ # ---------------------------------------------------------------- semantic
201
+
202
+ def told(self, subject: Ref, predicate: str, object: Any, *, by: str = "user", at: datetime | None = None) -> ClaimRecord:
203
+ """Someone simply said so. Semantic, protected from forgetting, and provably hearsay."""
204
+ claim = Claim(subject, predicate, object, scope=SEMANTIC)
205
+ return self.mind.tell(claim, Evidence(Ref(f"said:{by}"), at or _now(), method="told"))
206
+
207
+ def semantic(self, subject: Ref | None = None, predicate: str | None = None) -> list[ClaimRecord]:
208
+ return self.mind.claims(subject=subject, predicate=predicate, scope=SEMANTIC)
209
+
210
+ # ----------------------------------------------------------------- spatial
211
+
212
+ def here(self, **where: Any) -> list[ClaimRecord]:
213
+ """What is at a place (see ``frames.Frames.spatial``)."""
214
+ if self.frames is None:
215
+ return []
216
+ return self.frames.spatial(**where)
217
+
218
+ # -------------------------------------------------------------- procedural
219
+
220
+ def skills(self, cue: str, procedures: Iterable[Any], k: int = 3) -> list[tuple[Any, Score]]:
221
+ """Rank procedures by how well a cue fits their name and what they are for."""
222
+ scored = []
223
+ for proc in procedures:
224
+ text = " ".join(str(getattr(proc, attr, "") or "") for attr in ("id", "act", "why", "summary")).replace("_", " ")
225
+ scored.append((proc, shingle_similarity(cue, text, n=2)))
226
+ scored.sort(key=lambda pair: (-pair[1].value, str(getattr(pair[0], "id", ""))))
227
+ return scored[:k]
228
+
229
+ # ------------------------------------------------------------- forgetting
230
+
231
+ def turn(self, records: Sequence[ClaimRecord], *, summary: str = "", consolidate_every: int = 5,
232
+ forget_every: int = 5, keep_scopes: tuple[Ref | None, ...] = (), now: datetime | None = None) -> TurnReport:
233
+ """One conversation turn's worth of dynamics: encode, and now and then consolidate and forget.
234
+
235
+ Encoding every turn and consolidating every turn are different costs: laying down an episode
236
+ is O(what just happened), while consolidation and forgetting sweep everything held. Doing the
237
+ sweeps on a stride is what keeps per-turn latency flat as a conversation grows long.
238
+
239
+ The caller decides what belongs in an episode. Raw perception does not: a retina's worth of
240
+ claims per turn would bury the few that record what actually happened, and the point of an
241
+ episode is to be re-findable later.
242
+ """
243
+ t0 = _time.perf_counter()
244
+ self._turns += 1
245
+ episode = self.encode(records, summary=summary, at=now) if records else None
246
+ consolidated = len(self.consolidate()) if self._turns % max(1, consolidate_every) == 0 else 0
247
+ forgotten = (self.forget_stale(now=now, keep_scopes=keep_scopes)
248
+ if self._turns % max(1, forget_every) == 0 else None)
249
+ return TurnReport(episode, consolidated, forgotten, (_time.perf_counter() - t0) * 1e3)
250
+
251
+ def forget_stale(self, *, now: datetime | None = None, keep_scopes: tuple[Ref | None, ...] = ()) -> ForgetReport:
252
+ """Drop stale, unsalient perception. Nothing that supports something else, nothing protected."""
253
+ t0 = _time.perf_counter()
254
+ when = now or _now()
255
+ report = ForgetReport()
256
+ protected_scopes = set(self.policy.protect_scopes) | set(keep_scopes) | {EPISODIC}
257
+ aware = self.working.aware_ids()
258
+ doomed = []
259
+ for cid, rec in sorted(self.mind._claims.items()):
260
+ if rec.retracted:
261
+ continue
262
+ if rec.claim.scope in protected_scopes or rec.claim.predicate in self.policy.protect_predicates:
263
+ report.kept_because_protected += 1
264
+ continue
265
+ if any(e.source.id.startswith(prefix) for e in rec.evidence for prefix in self.policy.protect_sources):
266
+ report.kept_because_testimony += 1
267
+ continue
268
+ newest = max((e.observed_at for e in rec.evidence), default=when)
269
+ age = when - newest
270
+ salience = self.working.salience(cid) if cid in aware else 0.0
271
+ if age < self.policy.half_life or salience >= self.policy.min_salience:
272
+ continue
273
+ if self.mind._dependents.get(cid):
274
+ report.kept_because_depended_on += 1
275
+ continue
276
+ doomed.append(cid)
277
+ self.mind.forget(doomed)
278
+ report.claims_forgotten = len(doomed)
279
+ for episode in self.episodes():
280
+ if len(self._episodes) <= self.policy.episodic_capacity:
281
+ break
282
+ if episode.salience < self.policy.min_salience:
283
+ self._episodes.pop(episode.ref, None)
284
+ self.mind.forget([r.id for r in self.mind.claims(subject=episode.ref)])
285
+ report.episodes_dropped += 1
286
+ report.ms = (_time.perf_counter() - t0) * 1e3
287
+ return report
288
+
289
+ # --------------------------------------------------------------- cycle
290
+
291
+ def attend(self, seeds: Sequence[Any], *, fade: bool = True) -> Awareness:
292
+ """One cycle of working memory: fade what was aware, seed from what just arrived, spread."""
293
+ if fade:
294
+ self.working.fade()
295
+ self.working.seed(list(seeds))
296
+ self.working.spread()
297
+ return self.working
298
+
299
+
300
+ def _describe(rec: ClaimRecord) -> str:
301
+ c = rec.claim
302
+ obj = getattr(c.object, "value", c.object)
303
+ return f"{c.subject.id.split(':', 1)[-1]} {c.predicate} {obj}"