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/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]
|