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/awareness.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Awareness: a bounded, salience-weighted active set nucleated from the claim graph.
|
|
2
|
+
|
|
3
|
+
Percepts and the current utterance *seed* awareness; activation then spreads along links
|
|
4
|
+
that already exist in the store — a shared subject, a claim about the object of another,
|
|
5
|
+
a premise or a derivation, a shared source, and whatever extra adjacency a caller supplies
|
|
6
|
+
(spatial neighbours from ``frames.py``, for instance). Spreading is bounded by a budget and
|
|
7
|
+
a floor, so a mind with ten thousand claims still thinks over dozens.
|
|
8
|
+
|
|
9
|
+
That bound is also the practical point: ``think`` over ``Awareness.view()`` only sees the
|
|
10
|
+
aware set, so a rule whose patterns would otherwise scan all of memory costs what the
|
|
11
|
+
aware set costs, not what memory costs.
|
|
12
|
+
|
|
13
|
+
Nothing here is heuristic in the sense of being unexplainable: activation is the best
|
|
14
|
+
multiplicative path from a seed, ``why`` returns that path, and iteration order is sorted,
|
|
15
|
+
so two runs of the same mind give the same aware set.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import heapq
|
|
21
|
+
import weakref
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Callable, Iterable, Mapping, Sequence
|
|
24
|
+
|
|
25
|
+
from .records import ClaimRecord, Ref, Store
|
|
26
|
+
|
|
27
|
+
#: claims by source, per store and revision — shared, because building it is O(memory)
|
|
28
|
+
_SOURCE_INDEX: "weakref.WeakKeyDictionary[Any, tuple[int, dict[Ref, set[str]]]]" = weakref.WeakKeyDictionary()
|
|
29
|
+
|
|
30
|
+
#: how much activation survives each kind of hop, before ``decay``
|
|
31
|
+
LINK_WEIGHTS: Mapping[str, float] = {
|
|
32
|
+
"subject": 1.0, # another claim about the same subject
|
|
33
|
+
"object": 0.8, # a claim about the thing this one points at
|
|
34
|
+
"mention": 0.7, # a claim that points at this one's subject
|
|
35
|
+
"premise": 0.9, # what this claim was derived from
|
|
36
|
+
"dependent": 0.9, # what was derived from this claim
|
|
37
|
+
"source": 0.4, # read in the same observation
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class AwarenessPolicy:
|
|
43
|
+
"""What a caller chooses: how far awareness spreads and how much of it it can hold."""
|
|
44
|
+
|
|
45
|
+
budget: int = 64 # at most this many claims are aware at once
|
|
46
|
+
decay: float = 0.6 # activation multiplier per hop
|
|
47
|
+
floor: float = 0.05 # below this a claim is not aware at all
|
|
48
|
+
max_hops: int = 3
|
|
49
|
+
weights: Mapping[str, float] = field(default_factory=lambda: dict(LINK_WEIGHTS))
|
|
50
|
+
fade: float = 0.5 # what carries over when a cycle ends
|
|
51
|
+
fan_out: int = 32 # neighbours considered per link kind, so a huge observation cannot dominate
|
|
52
|
+
max_group: int = 256 # a link shared by more claims than this says nothing specific; ignore it
|
|
53
|
+
|
|
54
|
+
def weight(self, kind: str) -> float:
|
|
55
|
+
return self.weights.get(kind, 0.5)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Activation:
|
|
60
|
+
"""Why a claim is aware: how strongly, and by which path from which seed."""
|
|
61
|
+
|
|
62
|
+
claim_id: str
|
|
63
|
+
strength: float
|
|
64
|
+
seed: str
|
|
65
|
+
path: tuple[tuple[str, str], ...] = () # (link kind, claim id) hops from the seed, in order
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def hops(self) -> int:
|
|
69
|
+
return len(self.path)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Awareness:
|
|
73
|
+
"""The active set over one mind. Seed it, spread, then think over ``view()``."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, mind: Store, policy: AwarenessPolicy | None = None,
|
|
76
|
+
extra_links: Callable[[str, Store], Iterable[tuple[str, str, float]]] | None = None) -> None:
|
|
77
|
+
self.mind = mind
|
|
78
|
+
self.policy = policy or AwarenessPolicy()
|
|
79
|
+
self.extra_links = extra_links # claim id -> (neighbour id, link kind, weight)
|
|
80
|
+
self._act: dict[str, Activation] = {}
|
|
81
|
+
self._pending: dict[str, Activation] = {} # seeds not yet spread
|
|
82
|
+
self._source_index: dict[Ref, set[str]] = {}
|
|
83
|
+
self._indexed_at = -1
|
|
84
|
+
self._generation = 0 # bumped whenever activation changes, so ``aware`` can cache
|
|
85
|
+
self._aware_cache: list[ClaimRecord] | None = None
|
|
86
|
+
self._aware_ids_cache: set[str] = set()
|
|
87
|
+
self._aware_key: tuple[int, int] | None = None
|
|
88
|
+
|
|
89
|
+
# -- seeding
|
|
90
|
+
|
|
91
|
+
def seed(self, what: Iterable[Any] | Any, strength: float = 1.0, *, label: str | None = None) -> list[str]:
|
|
92
|
+
"""Seed from claim ids, ``ClaimRecord``s, ``Claim``s, or a ``Ref`` (all its claims)."""
|
|
93
|
+
ids: list[str] = []
|
|
94
|
+
for item in what if isinstance(what, (list, tuple, set, frozenset)) else [what]:
|
|
95
|
+
if isinstance(item, Ref):
|
|
96
|
+
ids += [r.id for r in self.mind.claims(subject=item)] + [r.id for r in self.mind.claims(object=item)]
|
|
97
|
+
elif isinstance(item, ClaimRecord):
|
|
98
|
+
ids.append(item.id)
|
|
99
|
+
elif isinstance(item, str):
|
|
100
|
+
ids.append(item)
|
|
101
|
+
elif hasattr(item, "id"):
|
|
102
|
+
ids.append(item.id)
|
|
103
|
+
else:
|
|
104
|
+
raise TypeError(f"cannot seed awareness from {type(item).__qualname__}")
|
|
105
|
+
seeded = []
|
|
106
|
+
for cid in sorted(dict.fromkeys(ids)):
|
|
107
|
+
if cid not in self.mind._claims or self.mind._claims[cid].retracted:
|
|
108
|
+
continue
|
|
109
|
+
a = Activation(cid, strength, label or cid)
|
|
110
|
+
if self._act.get(cid, Activation(cid, -1.0, "")).strength < strength:
|
|
111
|
+
self._act[cid] = a
|
|
112
|
+
self._pending[cid] = a
|
|
113
|
+
self._generation += 1
|
|
114
|
+
seeded.append(cid)
|
|
115
|
+
return seeded
|
|
116
|
+
|
|
117
|
+
# -- spreading
|
|
118
|
+
|
|
119
|
+
def spread(self) -> list[Activation]:
|
|
120
|
+
"""Grow the active set from its seeds. Returns what became newly aware, strongest first."""
|
|
121
|
+
p = self.policy
|
|
122
|
+
heap: list[tuple[float, str, Activation]] = [(-a.strength, cid, a) for cid, a in sorted(self._pending.items())]
|
|
123
|
+
heapq.heapify(heap)
|
|
124
|
+
self._pending.clear()
|
|
125
|
+
newly: list[Activation] = []
|
|
126
|
+
while heap:
|
|
127
|
+
neg, cid, act = heapq.heappop(heap)
|
|
128
|
+
if self._act.get(cid) is not act and -neg < self._act.get(cid, act).strength:
|
|
129
|
+
continue # a stronger path to this claim was already taken
|
|
130
|
+
if act.hops >= p.max_hops or len(self._act) >= p.budget * 4:
|
|
131
|
+
continue # stop growing the frontier; the budget trims the result anyway
|
|
132
|
+
for nid, kind, weight in self._links(cid):
|
|
133
|
+
strength = act.strength * p.decay * weight
|
|
134
|
+
if strength < p.floor:
|
|
135
|
+
continue
|
|
136
|
+
known = self._act.get(nid)
|
|
137
|
+
if known is not None and known.strength >= strength:
|
|
138
|
+
continue
|
|
139
|
+
nxt = Activation(nid, strength, act.seed, act.path + ((kind, nid),))
|
|
140
|
+
self._act[nid] = nxt
|
|
141
|
+
self._generation += 1
|
|
142
|
+
newly.append(nxt)
|
|
143
|
+
heapq.heappush(heap, (-strength, nid, nxt))
|
|
144
|
+
return sorted(newly, key=lambda a: (-a.strength, a.claim_id))
|
|
145
|
+
|
|
146
|
+
def _links(self, claim_id: str) -> list[tuple[str, str, float]]:
|
|
147
|
+
rec = self.mind._claims.get(claim_id)
|
|
148
|
+
if rec is None or rec.retracted:
|
|
149
|
+
return []
|
|
150
|
+
c, w, fan = rec.claim, self.policy.weight, self.policy.fan_out
|
|
151
|
+
out: list[tuple[str, str, float]] = []
|
|
152
|
+
|
|
153
|
+
def take(ids: Any, kind: str) -> None:
|
|
154
|
+
"""Up to ``fan_out`` neighbours of one kind, chosen by id so the choice is stable.
|
|
155
|
+
|
|
156
|
+
A group bigger than ``max_group`` is skipped rather than sampled: "read in the
|
|
157
|
+
same observation as three thousand other things" is not evidence of relatedness,
|
|
158
|
+
and scanning it would make nucleation cost grow with memory.
|
|
159
|
+
"""
|
|
160
|
+
if not ids or len(ids) > self.policy.max_group:
|
|
161
|
+
return
|
|
162
|
+
for other in heapq.nsmallest(fan, ids):
|
|
163
|
+
if other != claim_id:
|
|
164
|
+
out.append((other, kind, w(kind)))
|
|
165
|
+
|
|
166
|
+
take(self.mind._by_subject.get(c.subject, ()), "subject")
|
|
167
|
+
if isinstance(c.object, Ref):
|
|
168
|
+
take(self.mind._by_subject.get(c.object, ()), "object")
|
|
169
|
+
take(self.mind._by_object.get(c.subject, ()), "mention")
|
|
170
|
+
for e in rec.evidence:
|
|
171
|
+
for premise in e.derived_from:
|
|
172
|
+
out.append((premise, "premise", w("premise")))
|
|
173
|
+
take(self.mind._dependents.get(claim_id, ()), "dependent")
|
|
174
|
+
for source in sorted({e.source for e in rec.evidence}):
|
|
175
|
+
take(self._sources().get(source, ()), "source")
|
|
176
|
+
if self.extra_links is not None:
|
|
177
|
+
out += [(nid, kind, weight) for nid, kind, weight in self.extra_links(claim_id, self.mind)]
|
|
178
|
+
live = [(nid, kind, weight) for nid, kind, weight in out
|
|
179
|
+
if nid in self.mind._claims and not self.mind._claims[nid].retracted]
|
|
180
|
+
return list(dict.fromkeys(live))
|
|
181
|
+
|
|
182
|
+
def _sources(self) -> dict[Ref, set[str]]:
|
|
183
|
+
"""Claims by the observation they were read in.
|
|
184
|
+
|
|
185
|
+
Cached per store and revision: building it is O(memory), so a fresh awareness each
|
|
186
|
+
cycle must not pay for it again. Without this, nucleation is O(memory) rather than
|
|
187
|
+
O(aware set), which is the whole thing awareness is for.
|
|
188
|
+
"""
|
|
189
|
+
cached = _SOURCE_INDEX.get(self.mind)
|
|
190
|
+
if cached is not None and cached[0] == self.mind.revision:
|
|
191
|
+
return cached[1]
|
|
192
|
+
index: dict[Ref, set[str]] = {}
|
|
193
|
+
for cid, rec in self.mind._claims.items():
|
|
194
|
+
if rec.retracted:
|
|
195
|
+
continue
|
|
196
|
+
for e in rec.evidence:
|
|
197
|
+
index.setdefault(e.source, set()).add(cid)
|
|
198
|
+
_SOURCE_INDEX[self.mind] = (self.mind.revision, index)
|
|
199
|
+
return index
|
|
200
|
+
|
|
201
|
+
# -- reading
|
|
202
|
+
|
|
203
|
+
def aware(self) -> list[ClaimRecord]:
|
|
204
|
+
"""The active set, strongest first, trimmed to the budget. Retracted claims fall out."""
|
|
205
|
+
key = (self.mind.revision, self._generation)
|
|
206
|
+
if self._aware_cache is not None and self._aware_key == key:
|
|
207
|
+
return self._aware_cache
|
|
208
|
+
live = [(a, self.mind._claims[a.claim_id]) for a in self._act.values()
|
|
209
|
+
if a.claim_id in self.mind._claims and not self.mind._claims[a.claim_id].retracted]
|
|
210
|
+
live.sort(key=lambda pair: (-pair[0].strength, pair[0].claim_id))
|
|
211
|
+
self._aware_cache = [rec for _, rec in live[: self.policy.budget]]
|
|
212
|
+
self._aware_ids_cache = {rec.id for rec in self._aware_cache}
|
|
213
|
+
self._aware_key = key
|
|
214
|
+
return self._aware_cache
|
|
215
|
+
|
|
216
|
+
def aware_ids(self) -> set[str]:
|
|
217
|
+
self.aware()
|
|
218
|
+
return self._aware_ids_cache
|
|
219
|
+
|
|
220
|
+
def salience(self, claim: Any) -> float:
|
|
221
|
+
cid = claim if isinstance(claim, str) else claim.id
|
|
222
|
+
a = self._act.get(cid)
|
|
223
|
+
return a.strength if a is not None and cid in self.aware_ids() else 0.0
|
|
224
|
+
|
|
225
|
+
def why(self, claim: Any) -> list[str]:
|
|
226
|
+
"""The path that made this claim aware, as lines, or a note that it is not."""
|
|
227
|
+
cid = claim if isinstance(claim, str) else claim.id
|
|
228
|
+
a = self._act.get(cid)
|
|
229
|
+
if a is None or cid not in self.aware_ids():
|
|
230
|
+
return [f"{cid} is not aware"]
|
|
231
|
+
lines = [f"{self._describe(a.seed)} (seed, {a.strength:.3f} at the end of the path)" if not a.path
|
|
232
|
+
else f"{self._describe(a.seed)} (seed)"]
|
|
233
|
+
for kind, nid in a.path:
|
|
234
|
+
lines.append(f" --{kind}--> {self._describe(nid)}")
|
|
235
|
+
if a.path:
|
|
236
|
+
lines.append(f" = {a.strength:.3f}")
|
|
237
|
+
return lines
|
|
238
|
+
|
|
239
|
+
def _describe(self, cid: str) -> str:
|
|
240
|
+
rec = self.mind._claims.get(cid)
|
|
241
|
+
if rec is None:
|
|
242
|
+
return cid
|
|
243
|
+
c = rec.claim
|
|
244
|
+
obj = getattr(c.object, "value", c.object)
|
|
245
|
+
return f"{c.subject} {c.predicate} {obj!r}"
|
|
246
|
+
|
|
247
|
+
# -- between cycles
|
|
248
|
+
|
|
249
|
+
def fade(self, factor: float | None = None) -> list[str]:
|
|
250
|
+
"""End of a cycle: activation decays, and what falls under the floor stops being aware."""
|
|
251
|
+
f = self.policy.fade if factor is None else factor
|
|
252
|
+
dropped = []
|
|
253
|
+
for cid, a in sorted(self._act.items()):
|
|
254
|
+
strength = a.strength * f
|
|
255
|
+
if strength < self.policy.floor or cid not in self.mind._claims or self.mind._claims[cid].retracted:
|
|
256
|
+
dropped.append(cid)
|
|
257
|
+
else:
|
|
258
|
+
self._act[cid] = Activation(cid, strength, a.seed, a.path)
|
|
259
|
+
self._generation += 1
|
|
260
|
+
for cid in dropped:
|
|
261
|
+
self._act.pop(cid, None)
|
|
262
|
+
self._pending.pop(cid, None)
|
|
263
|
+
self._generation += 1
|
|
264
|
+
return dropped
|
|
265
|
+
|
|
266
|
+
def clear(self) -> None:
|
|
267
|
+
self._act.clear()
|
|
268
|
+
self._pending.clear()
|
|
269
|
+
self._generation += 1
|
|
270
|
+
|
|
271
|
+
def view(self) -> AwareView:
|
|
272
|
+
"""A store-like view restricted to the aware set, for ``think`` and intention code."""
|
|
273
|
+
return AwareView(self.mind, self.aware_ids())
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
_ANY = object()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class AwareView:
|
|
280
|
+
"""A read-only look at a mind through awareness.
|
|
281
|
+
|
|
282
|
+
Queries walk the aware set itself rather than filtering the store's answer, so a rule
|
|
283
|
+
whose pattern would otherwise scan all of memory costs what awareness costs. That is
|
|
284
|
+
the point of bounding it; delegating to ``Store.claims`` would keep the scan.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
def __init__(self, mind: Store, ids: set[str]) -> None:
|
|
288
|
+
self._mind, self._ids = mind, ids
|
|
289
|
+
|
|
290
|
+
def __getattr__(self, name: str) -> Any:
|
|
291
|
+
return getattr(self._mind, name)
|
|
292
|
+
|
|
293
|
+
def _records(self) -> list[ClaimRecord]:
|
|
294
|
+
live = (self._mind._claims.get(cid) for cid in self._ids)
|
|
295
|
+
return [r for r in live if r is not None and not r.retracted]
|
|
296
|
+
|
|
297
|
+
def claims(self, subject: Ref | None = None, predicate: str | None = None, object: Any = _ANY, *,
|
|
298
|
+
at: Any = None, scope: Any = _ANY, include_retracted: bool = False) -> list[ClaimRecord]:
|
|
299
|
+
out = []
|
|
300
|
+
for rec in self._records():
|
|
301
|
+
c = rec.claim
|
|
302
|
+
if subject is not None and c.subject != subject:
|
|
303
|
+
continue
|
|
304
|
+
if predicate is not None and c.predicate != predicate:
|
|
305
|
+
continue
|
|
306
|
+
if object is not _ANY and c.object != object:
|
|
307
|
+
continue
|
|
308
|
+
if scope is not _ANY and c.scope != scope:
|
|
309
|
+
continue
|
|
310
|
+
if at is not None and not c.valid.contains(at):
|
|
311
|
+
continue
|
|
312
|
+
out.append(rec)
|
|
313
|
+
return sorted(out, key=lambda r: r.id)
|
|
314
|
+
|
|
315
|
+
def match(self, *patterns: Any, at: Any = None, with_support: bool = False) -> list:
|
|
316
|
+
from .records import Var
|
|
317
|
+
|
|
318
|
+
results: list[tuple[dict[str, Any], tuple[str, ...]]] = [({}, ())]
|
|
319
|
+
for s, p, o in patterns:
|
|
320
|
+
nxt = []
|
|
321
|
+
for binding, support in results:
|
|
322
|
+
s_b = binding.get(s.name, s) if isinstance(s, Var) else s
|
|
323
|
+
o_b = binding.get(o.name, o) if isinstance(o, Var) else o
|
|
324
|
+
for rec in self.claims(subject=None if isinstance(s_b, Var) else s_b, predicate=p,
|
|
325
|
+
object=_ANY if isinstance(o_b, Var) else o_b, at=at):
|
|
326
|
+
b = dict(binding)
|
|
327
|
+
if isinstance(s_b, Var):
|
|
328
|
+
b[s_b.name] = rec.claim.subject
|
|
329
|
+
if isinstance(o_b, Var):
|
|
330
|
+
if o_b.name in b and b[o_b.name] != rec.claim.object:
|
|
331
|
+
continue
|
|
332
|
+
b[o_b.name] = rec.claim.object
|
|
333
|
+
nxt.append((b, support + (rec.id,)))
|
|
334
|
+
results = nxt
|
|
335
|
+
return results if with_support else [b for b, _ in results]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def nucleate(mind: Store, seeds: Sequence[Any], policy: AwarenessPolicy | None = None,
|
|
339
|
+
extra_links: Callable[[str, Store], Iterable[tuple[str, str, float]]] | None = None) -> Awareness:
|
|
340
|
+
"""Seed and spread in one call: the common shape at the top of a cycle."""
|
|
341
|
+
a = Awareness(mind, policy, extra_links)
|
|
342
|
+
a.seed(list(seeds))
|
|
343
|
+
a.spread()
|
|
344
|
+
return a
|
|
File without changes
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Small deterministic implementations with no dependencies beyond the standard library.
|
|
2
|
+
|
|
3
|
+
These are rules and classic algorithms. They are labeled as such in traces;
|
|
4
|
+
none of them is a learned model.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import enum
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Callable, Mapping, Sequence
|
|
15
|
+
|
|
16
|
+
from ..outcomes import Score, Unknown
|
|
17
|
+
from ..runtime import Output, Profile, Request, Traits
|
|
18
|
+
|
|
19
|
+
RULES = Traits(locality="in_process", egress=False, deterministic=True)
|
|
20
|
+
IN_PROCESS = Profile(source="declared: in-process code, no metered spend", usd_per_call=0.0)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class KeywordClassifier:
|
|
25
|
+
"""Regex rules per label. Answers only when exactly one label's rules match."""
|
|
26
|
+
|
|
27
|
+
labels: type[enum.Enum]
|
|
28
|
+
rules: Mapping[Any, Sequence[str]]
|
|
29
|
+
name: str = "keyword-rules"
|
|
30
|
+
version: str = "1"
|
|
31
|
+
op: str = "classify"
|
|
32
|
+
traits: Traits = RULES
|
|
33
|
+
profile: Profile = IN_PROCESS
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
self._compiled = {label: [re.compile(p, re.I) for p in pats] for label, pats in self.rules.items()}
|
|
37
|
+
|
|
38
|
+
def accepts(self, request: Request) -> bool:
|
|
39
|
+
return request.op == "classify" and request.target is self.labels and isinstance(request.subject, str)
|
|
40
|
+
|
|
41
|
+
def run(self, requests: Sequence[Request]) -> list[Output]:
|
|
42
|
+
outs = []
|
|
43
|
+
for r in requests:
|
|
44
|
+
hits = [label for label, pats in self._compiled.items() if any(p.search(r.subject) for p in pats)]
|
|
45
|
+
if len(hits) == 1:
|
|
46
|
+
outs.append(Output(hits[0]))
|
|
47
|
+
elif not hits:
|
|
48
|
+
outs.append(Output(Unknown("no_rule_matched")))
|
|
49
|
+
else:
|
|
50
|
+
outs.append(Output(Unknown("rules_disagree", candidates=tuple((h, Score(1.0, "vote_share")) for h in hits))))
|
|
51
|
+
return outs
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class UtilityChooser:
|
|
56
|
+
"""Argmax of ``Objective.utility``. Abstains on ties within ``margin`` or when utility is undefined."""
|
|
57
|
+
|
|
58
|
+
margin: float = 0.0
|
|
59
|
+
name: str = "utility-argmax"
|
|
60
|
+
version: str = "1"
|
|
61
|
+
op: str = "choose"
|
|
62
|
+
traits: Traits = RULES
|
|
63
|
+
profile: Profile = IN_PROCESS
|
|
64
|
+
|
|
65
|
+
def accepts(self, request: Request) -> bool:
|
|
66
|
+
return request.op == "choose" and getattr(request.target, "utility", None) is not None
|
|
67
|
+
|
|
68
|
+
def run(self, requests: Sequence[Request]) -> list[Output]:
|
|
69
|
+
outs = []
|
|
70
|
+
for r in requests:
|
|
71
|
+
given = r.params.get("given")
|
|
72
|
+
scored = sorted(((o, r.target.utility(o, given)) for o in r.subject), key=lambda p: p[1], reverse=True)
|
|
73
|
+
cands = tuple((o, Score(u, "utility")) for o, u in scored)
|
|
74
|
+
if len(scored) > 1 and scored[0][1] - scored[1][1] <= self.margin:
|
|
75
|
+
outs.append(Output(Unknown("tie_within_margin", f"top utilities {scored[0][1]:.3g} vs {scored[1][1]:.3g}", cands)))
|
|
76
|
+
else:
|
|
77
|
+
outs.append(Output(scored[0][0], Score(scored[0][1], "utility")))
|
|
78
|
+
return outs
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_WORD = re.compile(r"[a-z0-9]+")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _terms(text: str) -> list[str]:
|
|
85
|
+
return _WORD.findall(text.lower())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class BM25Ranker:
|
|
90
|
+
"""Okapi BM25 over each candidate's text. Scores are relevance, not probabilities."""
|
|
91
|
+
|
|
92
|
+
text_of: Callable[[Any], str] = str
|
|
93
|
+
k1: float = 1.2
|
|
94
|
+
b: float = 0.75
|
|
95
|
+
name: str = "bm25"
|
|
96
|
+
version: str = "1"
|
|
97
|
+
op: str = "rank"
|
|
98
|
+
traits: Traits = RULES
|
|
99
|
+
profile: Profile = IN_PROCESS
|
|
100
|
+
|
|
101
|
+
def accepts(self, request: Request) -> bool:
|
|
102
|
+
return request.op == "rank" and isinstance(request.subject, str)
|
|
103
|
+
|
|
104
|
+
def run(self, requests: Sequence[Request]) -> list[Output]:
|
|
105
|
+
return [Output(self._rank(r.subject, r.params["candidates"])) for r in requests]
|
|
106
|
+
|
|
107
|
+
def _rank(self, query: str, candidates: Sequence[Any]) -> list[tuple[Any, Score]]:
|
|
108
|
+
docs = [Counter(_terms(self.text_of(c))) for c in candidates]
|
|
109
|
+
n = len(docs)
|
|
110
|
+
if n == 0:
|
|
111
|
+
return []
|
|
112
|
+
avg = sum(sum(d.values()) for d in docs) / n or 1.0
|
|
113
|
+
df = Counter(t for d in docs for t in d)
|
|
114
|
+
q = _terms(query)
|
|
115
|
+
scored = []
|
|
116
|
+
for cand, d in zip(candidates, docs):
|
|
117
|
+
length = sum(d.values())
|
|
118
|
+
s = 0.0
|
|
119
|
+
for t in q:
|
|
120
|
+
if t in d:
|
|
121
|
+
idf = math.log(1 + (n - df[t] + 0.5) / (df[t] + 0.5))
|
|
122
|
+
s += idf * d[t] * (self.k1 + 1) / (d[t] + self.k1 * (1 - self.b + self.b * length / avg))
|
|
123
|
+
scored.append((cand, Score(s, "relevance")))
|
|
124
|
+
return sorted(scored, key=lambda p: p[1].value, reverse=True)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class StoreFactCheck:
|
|
129
|
+
"""Checks a ``Claim`` against the claims recorded in a ``Store``. No inference beyond the records.
|
|
130
|
+
|
|
131
|
+
holds: every live claim overlapping the proposition's interval agrees, and at least one exists
|
|
132
|
+
fails: the predicate is functional and every overlapping claim asserts a different object
|
|
133
|
+
unknown: no overlapping claims, or they disagree with each other
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
store: Any
|
|
137
|
+
name: str = "store-fact-check"
|
|
138
|
+
version: str = "1"
|
|
139
|
+
op: str = "check"
|
|
140
|
+
traits: Traits = RULES
|
|
141
|
+
profile: Profile = IN_PROCESS
|
|
142
|
+
|
|
143
|
+
def accepts(self, request: Request) -> bool:
|
|
144
|
+
from ..records import Claim
|
|
145
|
+
|
|
146
|
+
return request.op == "check" and isinstance(request.subject, Claim)
|
|
147
|
+
|
|
148
|
+
def run(self, requests: Sequence[Request]) -> list[Output]:
|
|
149
|
+
from ..outcomes import Verdict
|
|
150
|
+
|
|
151
|
+
outs = []
|
|
152
|
+
for r in requests:
|
|
153
|
+
p = r.subject
|
|
154
|
+
overlapping = [
|
|
155
|
+
rec for rec in self.store.claims(p.subject, p.predicate, scope=p.scope) if rec.claim.valid.overlap(p.valid) is not None
|
|
156
|
+
]
|
|
157
|
+
objects = {rec.claim.object for rec in overlapping}
|
|
158
|
+
cited = tuple(e.source for rec in overlapping for e in rec.evidence)
|
|
159
|
+
if not overlapping:
|
|
160
|
+
outs.append(Output(Verdict("unknown", ("no recorded claim covers the interval",))))
|
|
161
|
+
elif objects == {p.object}:
|
|
162
|
+
outs.append(Output(Verdict("holds", (f"{len(overlapping)} agreeing claim(s)",), cited)))
|
|
163
|
+
elif len(objects) == 1 and p.predicate in self.store.functional:
|
|
164
|
+
outs.append(Output(Verdict("fails", (f"recorded {objects.pop()!r} for a functional predicate",), cited)))
|
|
165
|
+
else:
|
|
166
|
+
outs.append(Output(Verdict("unknown", (f"contested: {sorted(map(str, objects))}",), cited)))
|
|
167
|
+
return outs
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""A general instruction-tuned model running locally through ``transformers``.
|
|
2
|
+
|
|
3
|
+
Requires the ``local-model`` extra (torch, transformers). Greedy decoding, so
|
|
4
|
+
outputs are deterministic for a fixed model, prompt version, and software stack.
|
|
5
|
+
Outputs that are not exactly one allowed label are treated as abstentions,
|
|
6
|
+
never coerced.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import enum
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Sequence
|
|
14
|
+
|
|
15
|
+
import torch
|
|
16
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
17
|
+
|
|
18
|
+
from ..outcomes import Unknown
|
|
19
|
+
from ..runtime import Output, Profile, Request, Traits
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LocalChatModel:
|
|
23
|
+
def __init__(self, model_id: str, *, device: str = "cuda", dtype: torch.dtype = torch.bfloat16, template_kwargs: dict | None = None) -> None:
|
|
24
|
+
self.model_id = model_id
|
|
25
|
+
self.template_kwargs = template_kwargs or {}
|
|
26
|
+
self.tokenizer = AutoTokenizer.from_pretrained(model_id, padding_side="left")
|
|
27
|
+
if self.tokenizer.pad_token is None:
|
|
28
|
+
self.tokenizer.pad_token = self.tokenizer.eos_token
|
|
29
|
+
self.model = AutoModelForCausalLM.from_pretrained(model_id, dtype=dtype).to(device).eval()
|
|
30
|
+
self.device = device
|
|
31
|
+
|
|
32
|
+
@torch.inference_mode()
|
|
33
|
+
def complete(self, prompts: Sequence[tuple[str, str]], *, max_new_tokens: int = 16) -> list[str]:
|
|
34
|
+
texts = [
|
|
35
|
+
self.tokenizer.apply_chat_template(
|
|
36
|
+
[{"role": "system", "content": system}, {"role": "user", "content": user}],
|
|
37
|
+
tokenize=False,
|
|
38
|
+
add_generation_prompt=True,
|
|
39
|
+
**self.template_kwargs,
|
|
40
|
+
)
|
|
41
|
+
for system, user in prompts
|
|
42
|
+
]
|
|
43
|
+
batch = self.tokenizer(texts, return_tensors="pt", padding=True).to(self.device)
|
|
44
|
+
out = self.model.generate(**batch, max_new_tokens=max_new_tokens, do_sample=False, pad_token_id=self.tokenizer.pad_token_id)
|
|
45
|
+
return [self.tokenizer.decode(row[batch["input_ids"].shape[1] :], skip_special_tokens=True).strip() for row in out]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
PROMPT_VERSION = "classify-v1"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class ChatClassifier:
|
|
53
|
+
llm: LocalChatModel
|
|
54
|
+
labels: type[enum.Enum]
|
|
55
|
+
batch_size: int = 16
|
|
56
|
+
op: str = "classify"
|
|
57
|
+
traits: Traits = Traits(locality="in_process", egress=False, deterministic=True, requires=frozenset({"cuda"}))
|
|
58
|
+
profile: Profile = field(default_factory=lambda: Profile(source="declared: in-process, no metered spend; latency unmeasured", usd_per_call=0.0))
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def name(self) -> str:
|
|
62
|
+
return f"chat:{self.llm.model_id.split('/')[-1]}"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def version(self) -> str:
|
|
66
|
+
return PROMPT_VERSION
|
|
67
|
+
|
|
68
|
+
def accepts(self, request: Request) -> bool:
|
|
69
|
+
return request.op == "classify" and request.target is self.labels and isinstance(request.subject, str)
|
|
70
|
+
|
|
71
|
+
def run(self, requests: Sequence[Request]) -> list[Output]:
|
|
72
|
+
allowed = {m.value: m for m in self.labels}
|
|
73
|
+
system = (
|
|
74
|
+
"You label customer-support messages for a card and banking app. "
|
|
75
|
+
"Reply with exactly one label from the list, copied verbatim, and nothing else. "
|
|
76
|
+
"If none of the labels fits, reply: unknown\n\nLabels:\n" + "\n".join(allowed)
|
|
77
|
+
)
|
|
78
|
+
outs: list[Output] = []
|
|
79
|
+
for i in range(0, len(requests), self.batch_size):
|
|
80
|
+
chunk = requests[i : i + self.batch_size]
|
|
81
|
+
for reply in self.llm.complete([(system, f"Message: {r.subject}\nLabel:") for r in chunk]):
|
|
82
|
+
label = reply.strip().strip("`'\".").split()[0] if reply.strip() else ""
|
|
83
|
+
if label in allowed:
|
|
84
|
+
outs.append(Output(allowed[label]))
|
|
85
|
+
elif label.lower() == "unknown":
|
|
86
|
+
outs.append(Output(Unknown("model_declined")))
|
|
87
|
+
else:
|
|
88
|
+
outs.append(Output(Unknown("unparseable_output", reply[:80])))
|
|
89
|
+
return outs
|