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/control.py ADDED
@@ -0,0 +1,291 @@
1
+ """Control and volition: what to pursue, what to set aside, and when to stop trying.
2
+
3
+ A mind that can only run the oldest request to completion has no control, only a queue. Three
4
+ things are missing from a queue, and each is a claim here rather than a Python variable:
5
+
6
+ * **a task set with suspended goals.** Interrupting a goal should not destroy it. Everything a
7
+ half-finished goal needs is already in the store (its frame, its position, its bindings), so
8
+ suspension is a *state change*, not a save: :func:`suspend` marks it and :func:`resume` marks
9
+ it back, and the work already done is still there. Dropping a goal throws that away; the only
10
+ reason to drop is that nobody will ever want it again.
11
+ * **arbitration among live goals.** A static priority number cannot express that one goal is
12
+ nearly finished, that another has been waiting since three turns ago, or that switching away
13
+ from the one in progress costs something. :func:`arbitrate` scores goals on value, urgency
14
+ (which *ages*, so nothing starves) and cost-to-go, with hysteresis for the goal in hand, and
15
+ records why it chose — so a choice can be argued with.
16
+ * **effort allocation.** A retry cap is a constant standing in for a judgement: is another
17
+ attempt worth its cost? :func:`should_try_again` makes that judgement from the frequencies a
18
+ :class:`~tensorcode.expectation.Predictor` actually observed, and refuses to invent a number
19
+ when it has too few — an unmeasured prior is stated with its basis, never smuggled in as 3.
20
+
21
+ What this module will not do: guess. A goal with no declared value is worth
22
+ :attr:`Stance.value_default` and says so; an effort decision with no evidence reports the prior
23
+ it used; an attempt that *may already have taken effect* stops, because the cost of repeating an
24
+ effect is not comparable to the cost of one more try.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, field
30
+ from datetime import datetime, timezone
31
+ from typing import Iterable, Mapping, Sequence
32
+
33
+ from .expectation import Predictor
34
+ from .outcomes import Score, Unknown, Verdict
35
+ from .records import Claim, Evidence, Patch, Ref, Retract, Store, Tell
36
+
37
+ #: a goal is in exactly one of these; the first two are live
38
+ ACTIVE, SUSPENDED, DONE, FAILED, ABANDONED = "active", "suspended", "done", "failed", "abandoned"
39
+ LIVE = (ACTIVE, SUSPENDED)
40
+
41
+
42
+ def _now() -> datetime:
43
+ return datetime.now(timezone.utc)
44
+
45
+
46
+ def _source(source: str) -> Ref:
47
+ """A source name as a Ref. Callers name themselves ("arbitrate"); refs need a kind."""
48
+ return Ref(source if ":" in source else f"decision:{source}")
49
+
50
+
51
+ def _set(mind: Store, subject: Ref, predicate: str, value: object, source: str) -> None:
52
+ """Replace a subject's single current value, keeping the retraction visible."""
53
+ old = [r for r in mind.claims(subject, predicate)]
54
+ if len(old) == 1 and old[0].claim.object == value:
55
+ return
56
+ edits: list = [Retract(r.id, "superseded") for r in old]
57
+ edits.append(Tell(Claim(subject, predicate, value), (Evidence(_source(source), _now(), method="control"),)))
58
+ commit = mind.apply(Patch(tuple(edits), mind.revision))
59
+ mind.forget(commit.retracted)
60
+
61
+
62
+ def _one(mind: Store, subject: Ref, predicate: str, default: object = None) -> object:
63
+ found = [r.claim.object for r in mind.claims(subject, predicate)]
64
+ return found[0] if found else default
65
+
66
+
67
+ # ------------------------------------------------------------------ the task set
68
+
69
+
70
+ def declare(mind: Store, goal: Ref, *, what: str, source: str, value: float | None = None,
71
+ cost_to_go: float | None = None, deadline: float | None = None) -> None:
72
+ """Enter a goal in the task set. ``what`` is how it will be described when arbitrating."""
73
+ _set(mind, goal, "goal:what", what, source)
74
+ _set(mind, goal, "goal:state", ACTIVE, source)
75
+ _set(mind, goal, "goal:since", 0.0, source)
76
+ if value is not None:
77
+ _set(mind, goal, "goal:value", float(value), source)
78
+ if cost_to_go is not None:
79
+ _set(mind, goal, "goal:cost_to_go", float(cost_to_go), source)
80
+ if deadline is not None:
81
+ _set(mind, goal, "goal:deadline", float(deadline), source)
82
+
83
+
84
+ def weigh(mind: Store, goal: Ref, *, source: str, what: str | None = None, value: float | None = None,
85
+ urgency: float | None = None, cost_to_go: float | None = None, deadline: float | None = None) -> None:
86
+ """Give a goal the weights arbitration needs, without claiming its lifecycle.
87
+
88
+ A mind that already tracks whether a task is running (as a request status, a plan state,
89
+ anything) should use this rather than :func:`declare`: two sources of truth for one goal's
90
+ state is how a suspended task ends up both asleep and running.
91
+ """
92
+ for predicate, given in (("goal:what", what), ("goal:value", value), ("goal:urgency", urgency),
93
+ ("goal:cost_to_go", cost_to_go), ("goal:deadline", deadline)):
94
+ if given is not None:
95
+ _set(mind, goal, predicate, given if predicate == "goal:what" else float(given), source)
96
+
97
+
98
+ def waiting_since(mind: Store, goal: Ref, at: float, reason: str, *, source: str) -> None:
99
+ """Record that a goal has been waiting, and why — the input to aging, without owning state."""
100
+ _set(mind, goal, "goal:suspended_because", reason, source)
101
+ _set(mind, goal, "goal:suspended_at", float(at), source)
102
+
103
+
104
+ def suspend(mind: Store, goal: Ref, reason: str, *, source: str, at: float = 0.0) -> None:
105
+ """Set a goal aside without losing it. Its progress stays exactly where it was."""
106
+ _set(mind, goal, "goal:state", SUSPENDED, source)
107
+ waiting_since(mind, goal, at, reason, source=source)
108
+
109
+
110
+ def resume(mind: Store, goal: Ref, *, source: str) -> None:
111
+ _set(mind, goal, "goal:state", ACTIVE, source)
112
+ _set(mind, goal, "goal:resumed", True, source)
113
+
114
+
115
+ def settle(mind: Store, goal: Ref, state: str, *, source: str) -> None:
116
+ """Finish a goal: done, failed, or abandoned (the only state that discards progress)."""
117
+ if state not in (DONE, FAILED, ABANDONED):
118
+ raise ValueError(f"not an ending state: {state!r}")
119
+ _set(mind, goal, "goal:state", state, source)
120
+
121
+
122
+ def state_of(mind: Store, goal: Ref) -> str:
123
+ return str(_one(mind, goal, "goal:state", ACTIVE))
124
+
125
+
126
+ def task_set(mind: Store, *, state: str | tuple[str, ...] = LIVE) -> list[Ref]:
127
+ """Goals in the given state(s), oldest declaration first."""
128
+ want = (state,) if isinstance(state, str) else tuple(state)
129
+ return [r.claim.subject for r in mind.claims(predicate="goal:state") if r.claim.object in want]
130
+
131
+
132
+ def progress_kept(mind: Store, goal: Ref, predicates: Iterable[str]) -> dict:
133
+ """What a suspended goal still holds — the evidence that resuming is not restarting."""
134
+ return {p: _one(mind, goal, p) for p in predicates if _one(mind, goal, p) is not None}
135
+
136
+
137
+ # ------------------------------------------------------------------ arbitration
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class Stance:
142
+ """How this mind weighs its goals against each other.
143
+
144
+ ``stickiness`` is hysteresis: the goal in hand keeps an advantage, so a near-tie does not
145
+ make the mind thrash between two goals and finish neither. ``aging`` is what stops a
146
+ cheap-first rule from starving an expensive goal forever.
147
+ """
148
+
149
+ stickiness: float = 0.35
150
+ aging: float = 0.05
151
+ cost_weight: float = 0.15
152
+ value_default: float = 1.0
153
+ urgency_default: float = 0.0
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class Choice:
158
+ """Which goal to pursue, what it scored, and what it beat."""
159
+
160
+ goal: Ref | None
161
+ why: str
162
+ score: float = 0.0
163
+ alternatives: tuple[tuple[Ref, float], ...] = ()
164
+
165
+ def describe(self) -> str:
166
+ if self.goal is None:
167
+ return f"nothing to pursue: {self.why}"
168
+ others = ", ".join(f"{g.id}@{s:.2f}" for g, s in self.alternatives)
169
+ return f"{self.goal.id}@{self.score:.2f} — {self.why}" + (f" (over {others})" if others else "")
170
+
171
+
172
+ def urgency(mind: Store, goal: Ref, *, stance: Stance, now: float = 0.0) -> float:
173
+ """How pressing this goal is: what was declared, plus what waiting has added.
174
+
175
+ Aging is the honest part. Without it, ordering by cost finishes short goals first and a long
176
+ goal can wait forever; with it, a goal's urgency rises for as long as it is passed over, so
177
+ the worst case is bounded by how long you are willing to let something wait.
178
+ """
179
+ base = float(_one(mind, goal, "goal:urgency", stance.urgency_default))
180
+ waited = max(0.0, now - float(_one(mind, goal, "goal:suspended_at", now)))
181
+ deadline = _one(mind, goal, "goal:deadline")
182
+ pressure = 0.0
183
+ if isinstance(deadline, (int, float)) and deadline > now:
184
+ pressure = 1.0 / max(1e-6, float(deadline) - now)
185
+ return base + stance.aging * waited + pressure
186
+
187
+
188
+ def score_goal(mind: Store, goal: Ref, *, stance: Stance, now: float = 0.0, current: Ref | None = None) -> float:
189
+ value = float(_one(mind, goal, "goal:value", stance.value_default))
190
+ cost = float(_one(mind, goal, "goal:cost_to_go", 0.0))
191
+ score = value + urgency(mind, goal, stance=stance, now=now) - stance.cost_weight * cost
192
+ if current is not None and goal == current:
193
+ score += stance.stickiness
194
+ return score
195
+
196
+
197
+ def arbitrate(mind: Store, *, stance: Stance = Stance(), now: float = 0.0, current: Ref | None = None,
198
+ among: Sequence[Ref] | None = None, source: str = "arbitrate") -> Choice:
199
+ """Choose the goal to pursue now, and record the choice with its reasons."""
200
+ goals = list(among) if among is not None else task_set(mind, state=ACTIVE)
201
+ if not goals:
202
+ return Choice(None, "the task set holds no active goal")
203
+ scored = sorted(((score_goal(mind, g, stance=stance, now=now, current=current), g) for g in goals), key=lambda p: (-p[0], p[1].id))
204
+ best_score, best = scored[0]
205
+ parts = [f"value {float(_one(mind, best, 'goal:value', stance.value_default)):.2f}"]
206
+ if (u := urgency(mind, best, stance=stance, now=now)):
207
+ parts.append(f"urgency {u:.2f}")
208
+ if (c := float(_one(mind, best, "goal:cost_to_go", 0.0))):
209
+ parts.append(f"cost {c:.0f}")
210
+ if current is not None and best == current:
211
+ parts.append("in progress")
212
+ why = ", ".join(parts)
213
+ _set(mind, best, "goal:chosen_because", why, source)
214
+ return Choice(best, why, best_score, tuple((g, s) for s, g in scored[1:]))
215
+
216
+
217
+ # ------------------------------------------------------------------ effort
218
+
219
+
220
+ #: how an attempt turned out, for deciding whether to make another
221
+ SUCCEEDED, TRANSIENT, AMBIGUOUS, REFUSED = "succeeded", "transient", "ambiguous", "refused"
222
+
223
+
224
+ @dataclass(frozen=True)
225
+ class Effort:
226
+ """What a goal is worth against what another attempt costs.
227
+
228
+ ``irreversible_cost`` is not a large number standing in for caution: an attempt whose effect
229
+ *may already have happened* is a different kind of act, because repeating it can double the
230
+ effect. Such an attempt is refused outright rather than priced.
231
+ """
232
+
233
+ value: float = 1.0
234
+ cost: float = 0.1
235
+ prior: Score | None = None # used, and named, when there is too little evidence to measure
236
+ hard_cap: int = 12 # a bound on pathology, not the policy
237
+ irreversible_cost: float = float("inf")
238
+
239
+
240
+ def should_try_again(history: Sequence[str], *, effort: Effort = Effort(), predictor: Predictor | None = None,
241
+ cue: str = "attempt", aspect: str = "outcome") -> Verdict:
242
+ """Is one more attempt worth making, given how the previous ones went?
243
+
244
+ The verdict ``holds`` to try again, ``fails`` to stop, and carries the reasoning: an
245
+ expectation of success, where it came from, and what it was weighed against.
246
+ """
247
+ attempts = len(history)
248
+ if any(h == SUCCEEDED for h in history):
249
+ return Verdict("fails", ("already succeeded; another attempt would repeat the effect",))
250
+ if history and history[-1] == AMBIGUOUS:
251
+ return Verdict("fails", ("the last attempt may already have taken effect; repeating it could double it",))
252
+ if any(h == REFUSED for h in history):
253
+ return Verdict("fails", ("the attempt was refused, so trying again unchanged will be refused too",))
254
+ if attempts >= effort.hard_cap:
255
+ return Verdict("fails", (f"{attempts} attempts reached the hard cap {effort.hard_cap}",))
256
+ # the question is "will the next attempt succeed", so what is predicted is that one aspect.
257
+ # Predicting the *most likely outcome* instead would read a 60% chance of a transient failure
258
+ # as a 40% chance of success, which is only true when there are two outcomes; here there are
259
+ # three (succeeded, transient, ambiguous), so the binary aspect is what is recorded and read.
260
+ measured = predictor.predict(cue, f"{aspect}:succeeded") if predictor is not None else Unknown("no_predictor", "nothing observed")
261
+ if isinstance(measured, Unknown):
262
+ if effort.prior is None:
263
+ return Verdict("unknown", (f"no measured chance of success ({measured.reason}) and no prior was stated",))
264
+ p, basis = effort.prior.value, f"stated prior ({effort.prior.basis or 'unnamed'})"
265
+ else:
266
+ value, score = measured
267
+ p = score.value if value is True else max(0.0, 1.0 - score.value)
268
+ basis = f"measured {score.basis}"
269
+ worth = p * effort.value
270
+ if worth > effort.cost:
271
+ return Verdict("holds", (f"chance of success {p:.2f} ({basis}) × value {effort.value:.2f} = {worth:.2f} > cost {effort.cost:.2f}",))
272
+ return Verdict("fails", (f"chance of success {p:.2f} ({basis}) × value {effort.value:.2f} = {worth:.2f} ≤ cost {effort.cost:.2f}",))
273
+
274
+
275
+ def observe_attempt(predictor: Predictor, outcome: str, *, cue: str = "attempt", aspect: str = "outcome") -> None:
276
+ """Record how an attempt went, so the next decision is made on frequencies rather than a constant.
277
+
278
+ Two things are recorded: the outcome itself, which is what a person reading the record wants,
279
+ and whether it succeeded, which is what :func:`should_try_again` asks about.
280
+ """
281
+ predictor.observe(cue, aspect, outcome)
282
+ predictor.observe(cue, f"{aspect}:succeeded", outcome == SUCCEEDED)
283
+
284
+
285
+ __all__ = [
286
+ "ACTIVE", "SUSPENDED", "DONE", "FAILED", "ABANDONED", "LIVE",
287
+ "SUCCEEDED", "TRANSIENT", "AMBIGUOUS", "REFUSED",
288
+ "Stance", "Choice", "Effort",
289
+ "declare", "suspend", "resume", "settle", "state_of", "task_set", "progress_kept",
290
+ "urgency", "score_goal", "arbitrate", "should_try_again", "observe_attempt",
291
+ ]
tensorcode/cues.py ADDED
@@ -0,0 +1,192 @@
1
+ """Recall by cue, when the cue is not worded the way the memory was.
2
+
3
+ ``Memory.recall`` compares the cue with a rendered claim as bags of word n-grams. That works when
4
+ the wording matches and falls off a cliff when it does not: "what is my cat" against
5
+ ``person:user cat 'Mackerel'`` shares a word, while "what did I name the animal" shares none, and
6
+ the claim that answers it scores zero.
7
+
8
+ The fix here is deliberately *not* a learned vector space. A sibling measurement found embeddings
9
+ losing to plain token overlap on this repository's own recall tasks (0.005 vs 0.106 within a cycle,
10
+ 0.741 vs 0.944 within a subject), so the interesting question is not "can a model do better" but
11
+ "what structure is token overlap throwing away". Three things, it turns out:
12
+
13
+ **Roles.** A claim is not a sentence, it is a subject, a predicate and an object. A cue word that
14
+ matches the predicate is worth far more than one matching some substring of the object, and a bag
15
+ of n-grams cannot tell the difference.
16
+
17
+ **Morphology.** "notes" and "note", "reading" and "read" are the same cue. Suffix stripping is
18
+ crude and costs nothing.
19
+
20
+ **The mind's own links.** Synonymy does not have to come from a hand-written list (which would only
21
+ encode the answers to whatever test one was running) or a trained model. It can come from what the
22
+ agent already believes: if the store holds ``fluffy is_a cat``, then a cue saying "cat" reaches a
23
+ claim about Fluffy in one hop. Expansion is discounted per hop, so a direct hit always wins.
24
+
25
+ What this cannot do is bridge words the agent has never seen related. That is the honest ceiling,
26
+ and it is measured rather than argued: see ``eval/temporal_perception/paraphrase_recall.py``.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import re
32
+ from dataclasses import dataclass, field
33
+ from typing import Any, Iterable, Sequence
34
+
35
+ from .outcomes import Score
36
+ from .records import ClaimRecord, Ref, Store
37
+
38
+ STOP = frozenset("""a an the my our your his her their its is are was were be been am do does did
39
+ what which who whom whose when where why how that this these those of in on at to for from with
40
+ and or not no you i me we they it there here again please tell told say said know remember about
41
+ had has have can could would should will shall may might must thing things stuff""".split())
42
+
43
+ # claims whose object names the same thing as their subject: the hops worth taking
44
+ LINKING = ("is_a", "label", "same_as", "means", "kind_of", "in", "part_of", "called", "aka")
45
+
46
+
47
+ def words(text: str) -> list[str]:
48
+ return [w for w in re.split(r"[^\w']+", str(text).lower()) if w]
49
+
50
+
51
+ def lemma(word: str) -> str:
52
+ """Enough morphology to make "notes" and "note" the same cue. Crude on purpose."""
53
+ for suffix, keep in (("ies", "y"), ("sses", "ss"), ("ches", "ch"), ("shes", "sh"), ("xes", "x"),
54
+ ("ing", ""), ("ed", ""), ("s", "")):
55
+ if len(word) > len(suffix) + 2 and word.endswith(suffix):
56
+ return word[: -len(suffix)] + keep
57
+ return word
58
+
59
+
60
+ def content(text: str) -> set[str]:
61
+ """The cue-bearing lemmas of a phrase: no stop words, no punctuation, no inflection.
62
+
63
+ Adjacent words are also joined, because a compound is written both ways and a predicate is one
64
+ token: "time zone" has to reach ``timezone``, and "notes folder" has to reach itself.
65
+ """
66
+ kept = [lemma(w) for w in words(text) if w not in STOP and len(w) > 1]
67
+ joined = {a + b for a, b in zip(kept, kept[1:])}
68
+ return (set(kept) | joined) - {""}
69
+
70
+
71
+ def _ref_words(value: Any) -> set[str]:
72
+ """A ref carries words too: ``person:user`` is about a user, ``ui:Files/button/Open#1`` about Files."""
73
+ text = value.id if isinstance(value, Ref) else str(value)
74
+ return content(re.sub(r"[:/#]", " ", text))
75
+
76
+
77
+ FIRST_PERSON = frozenset({"my", "mine", "our", "ours", "i", "me", "myself", "we", "us"})
78
+ SELF_WORDS = frozenset({"user", "person", "me", "self", "you"})
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class RoleWeights:
83
+ """How much a match in each role is worth. Chosen on the training half only."""
84
+
85
+ predicate: float = 3.0
86
+ object: float = 2.0
87
+ subject: float = 1.0
88
+ hop_discount: float = 0.45 # one link away is worth less than half a direct hit
89
+ first_person: float = 1.5 # "my deadline" is a question about the asker, not about a label
90
+ off_topic_penalty: float = 0.6 # ... and a claim about something else answers it less well
91
+
92
+
93
+ @dataclass
94
+ class Hit:
95
+ record: ClaimRecord
96
+ score: Score
97
+ matched: tuple[str, ...] = ()
98
+ via: tuple[str, ...] = () # cue words reached through the store's own links
99
+
100
+ def why(self) -> str:
101
+ reason = f"matched {', '.join(self.matched) or 'nothing'}"
102
+ return reason + (f" (via {', '.join(self.via)})" if self.via else "")
103
+
104
+
105
+ class Cues:
106
+ """A structural index over claims: role-wise lemma overlap, widened by the store's own links.
107
+
108
+ Built once per query batch rather than maintained: the cost is one pass over the claims, and a
109
+ live store changes under any index that tries to be clever about incremental updates.
110
+ """
111
+
112
+ def __init__(self, mind: Store, *, weights: RoleWeights | None = None,
113
+ skip_scopes: Sequence[Ref | None] = (), skip_predicates: Iterable[str] = ()) -> None:
114
+ self.mind = mind
115
+ self.weights = weights or RoleWeights()
116
+ self.skip_scopes = tuple(skip_scopes)
117
+ self.skip_predicates = frozenset(skip_predicates)
118
+ self._synonyms: dict[str, set[str]] = {}
119
+ self._build_links()
120
+
121
+ def _build_links(self) -> None:
122
+ """Words the store itself says name the same thing, in one hop."""
123
+ for rec in self.mind.claims():
124
+ if rec.retracted or rec.claim.predicate not in LINKING:
125
+ continue
126
+ left, right = _ref_words(rec.claim.subject), _ref_words(rec.claim.object)
127
+ for a in left:
128
+ self._synonyms.setdefault(a, set()).update(right - {a})
129
+ for b in right:
130
+ self._synonyms.setdefault(b, set()).update(left - {b})
131
+
132
+ def expand(self, cue: set[str]) -> dict[str, str]:
133
+ """Cue lemmas one hop out, each remembering which cue word it came from."""
134
+ out: dict[str, str] = {}
135
+ for word in cue:
136
+ for near in self._synonyms.get(word, ()): # noqa: B007 - small sets
137
+ if near not in cue:
138
+ out.setdefault(near, word)
139
+ return out
140
+
141
+ def find(self, cue: str, k: int = 3, *, min_score: float = 0.05) -> list[Hit]:
142
+ direct = content(cue)
143
+ if not direct:
144
+ return []
145
+ # "what is my deadline" constrains the subject as surely as it names the predicate, and the
146
+ # stop-word list throws that away. Without it, a cue about "my time zone" is answered by a
147
+ # column header reading "Time", because a heading is a perfectly good match for one word.
148
+ about_me = bool(FIRST_PERSON & set(words(cue)))
149
+ indirect = self.expand(direct)
150
+ w = self.weights
151
+ best = w.predicate + w.object + w.subject
152
+ hits: list[Hit] = []
153
+ for rec in self.mind.claims():
154
+ if rec.retracted or rec.claim.scope in self.skip_scopes or rec.claim.predicate in self.skip_predicates:
155
+ continue
156
+ roles = ((w.predicate, content(rec.claim.predicate)),
157
+ (w.object, _ref_words(rec.claim.object)),
158
+ (w.subject, _ref_words(rec.claim.subject)))
159
+ total, matched, via = 0.0, set(), set()
160
+ for weight, bag in roles:
161
+ overlap = bag & direct
162
+ if overlap:
163
+ total += weight
164
+ matched |= overlap
165
+ continue
166
+ reached = bag & set(indirect)
167
+ if reached:
168
+ total += weight * w.hop_discount
169
+ matched |= reached
170
+ via |= {indirect[r] for r in reached}
171
+ if total <= 0:
172
+ continue
173
+ if about_me:
174
+ mine = bool(_ref_words(rec.claim.subject) & SELF_WORDS)
175
+ total += w.first_person if mine else -w.off_topic_penalty
176
+ # a claim that answers more of the cue outranks one that happens to match its commonest word
177
+ coverage = len(matched & (direct | set(indirect))) / len(direct)
178
+ value = (total / best) * (0.5 + 0.5 * min(1.0, coverage))
179
+ if value >= min_score:
180
+ hits.append(Hit(rec, Score(round(min(1.0, value), 4), "similarity"),
181
+ tuple(sorted(matched)), tuple(sorted(via))))
182
+ hits.sort(key=lambda h: (-h.score.value, h.record.id))
183
+ return hits[:k]
184
+
185
+
186
+ def exact_find(mind: Store, cue: str, k: int = 3) -> list[Hit]:
187
+ """The baseline that has no tolerance at all: the cue must contain the predicate verbatim."""
188
+ low = f" {cue.lower()} "
189
+ hits = [Hit(rec, Score(1.0, "similarity"), (rec.claim.predicate,))
190
+ for rec in mind.claims()
191
+ if not rec.retracted and rec.claim.predicate and f" {rec.claim.predicate.lower()} " in low]
192
+ return hits[:k]