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,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