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,558 @@
1
+ """An Earley chart over a feature grammar, then a Viterbi cover for robustness.
2
+
3
+ Two stages, because they answer different questions.
4
+
5
+ *The chart* finds every constituent the grammar licenses over every span,
6
+ unifying features as it goes and assembling meaning from each production's
7
+ semantic spec. Earley (not CKY) so the grammar can stay readable: no
8
+ binarisation and left recursion is allowed. Constituents are found at every
9
+ position, not only from the sentence start, because the cover needs fragments.
10
+
11
+ *The cover* decides what the whole utterance means when the grammar does not
12
+ span it. An utterance is a sequence of top-level constituents and skipped
13
+ tokens, and skipped tokens are charged at a **fitted background unigram** rather
14
+ than a hand-chosen skip penalty, so a partial reading and a full reading are
15
+ comparable on one scale. That device is taken from ``symbolic-ai-models``
16
+ (``symbolic_ai_parsers/parsers/cky_001``), where a constant penalty was noted to
17
+ be a free parameter sitting under every parser at once.
18
+
19
+ What is not recoverable is reported rather than guessed: skipped tokens survive
20
+ on the reading, and genuine ambiguity survives as several equal-scoring readings.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ import time
27
+ from dataclasses import dataclass, field
28
+ from functools import cached_property
29
+ from typing import Any, Iterable, Mapping, Sequence
30
+
31
+ from ..outcomes import Score
32
+ from .features import Bindings, FVar, ground, unify
33
+ from .grammar import ABSENT, Cat, Grammar, Terminal, build_sem
34
+ from .semantics import _key_of
35
+
36
+ #: quoted spans, paths, numbers, words (with internal ' - _ . @ +), then punctuation
37
+ _TOKEN = re.compile(
38
+ # a single-quoted span may contain a contraction: 'don't forget' is one literal,
39
+ # so an interior apostrophe is allowed when a letter follows it
40
+ r"""'(?:[^']|'(?=[A-Za-z]))*'|"[^"]*"|`[^`]*`|“[^”]*”|‘[^’]*’"""
41
+ r"""|~(?:/[^\s,;:!?]*)?|/[\w.@+\-/]+|\.\.\.|\d+(?:\.\d+)?|[\w][\w'’.\-_@+]*|[^\s\w]"""
42
+ )
43
+ QUOTES = {"'": "'", '"': '"', "`": "`", "“": "”", "‘": "’"}
44
+
45
+
46
+ #: Clitics are separate words: "what's in it" is "what" + "'s", and a grammar that
47
+ #: cannot see the auxiliary cannot read the question.
48
+ CLITICS = ("n't", "'s", "'re", "'ll", "'ve", "'m", "'d")
49
+
50
+
51
+ def tokenize(text: str) -> list[str]:
52
+ """Words, numbers, paths and quoted spans; a quoted span stays one token."""
53
+ out: list[str] = []
54
+ for token in _TOKEN.findall(text.strip()):
55
+ if not token.strip():
56
+ continue
57
+ if not is_quoted(token) and "/" not in token:
58
+ # A word may contain a dot ("hi.txt", "3.14") but may not *end* with one:
59
+ # that dot is the end of the sentence. Keeping it attached made "food." a
60
+ # token no lexicon and no open-class pattern could match, so the last word
61
+ # of every sentence entered as an unknown name — invisible here, because a
62
+ # guessed name absorbs anything, and expensive in a caller that counts
63
+ # unknown words.
64
+ stops = ""
65
+ while len(token) > 1 and token.endswith("."):
66
+ token, stops = token[:-1], stops + "."
67
+ if stops:
68
+ out.extend(_split_clitic(token))
69
+ out.extend(stops)
70
+ continue
71
+ for clitic in CLITICS:
72
+ if len(token) > len(clitic) and token.lower().endswith(clitic):
73
+ out.extend([token[: -len(clitic)], token[-len(clitic):]])
74
+ break
75
+ else:
76
+ out.append(token)
77
+ continue
78
+ out.append(token)
79
+ return out
80
+
81
+
82
+ def _split_clitic(token: str) -> list[str]:
83
+ for clitic in CLITICS:
84
+ if len(token) > len(clitic) and token.lower().endswith(clitic):
85
+ return [token[: -len(clitic)], token[-len(clitic):]]
86
+ return [token]
87
+
88
+
89
+ def unquote(token: str) -> str:
90
+ if len(token) >= 2 and token[0] in QUOTES and token[-1] == QUOTES[token[0]]:
91
+ return token[1:-1]
92
+ return token
93
+
94
+
95
+ def is_quoted(token: str) -> bool:
96
+ return len(token) >= 2 and token[0] in QUOTES and token[-1] == QUOTES[token[0]]
97
+
98
+
99
+ # ----------------------------------------------------------------- chart nodes
100
+
101
+
102
+ class Node:
103
+ """A completed constituent: what it is, where it is, and what it means.
104
+
105
+ A plain slotted class, not a dataclass: the chart consults ``key`` and ``sem_key``
106
+ thousands of times per parse, so both are computed once here rather than through a
107
+ ``cached_property`` descriptor (which was 572 ms of a 3.1 s run) or, as before,
108
+ through ``repr``.
109
+ """
110
+
111
+ __slots__ = ("cat", "start", "end", "sem", "feats", "weight", "rule", "children", "words", "sem_key", "key",
112
+ "serial")
113
+
114
+ def __init__(self, cat: str, start: int, end: int, sem: Any, feats: tuple[tuple[str, Any], ...] = (),
115
+ weight: float = 0.0, rule: str = "", children: tuple["Node", ...] = (),
116
+ words: tuple[str, ...] = ()) -> None:
117
+ self.cat, self.start, self.end, self.sem = cat, start, end, sem
118
+ self.feats, self.weight, self.rule = feats, weight, rule
119
+ self.children, self.words = children, words
120
+ self.sem_key = _key_of(sem)
121
+ self.key = (cat, start, end, self.sem_key)
122
+ self.serial = 0 # set by the chart, in creation order, for deterministic ties
123
+
124
+ def __hash__(self) -> int:
125
+ return hash(self.key)
126
+
127
+ def __eq__(self, other: Any) -> bool:
128
+ return isinstance(other, Node) and self.key == other.key
129
+
130
+ def __repr__(self) -> str:
131
+ return f"Node({self.cat}[{self.start}:{self.end}] {self.rule})"
132
+
133
+ def features(self) -> dict[str, Any]:
134
+ return dict(self.feats)
135
+
136
+ def leaves(self) -> Iterable["Node"]:
137
+ if not self.children:
138
+ yield self
139
+ for child in self.children:
140
+ yield from child.leaves()
141
+
142
+ def guesses(self) -> list[tuple[str, str]]:
143
+ """(token, category) for every word that entered on an open-class guess."""
144
+ return [(leaf.words[0], leaf.cat) for leaf in self.leaves()
145
+ if leaf.words and dict(leaf.feats).get("guessed")]
146
+
147
+ def tree(self, indent: int = 0) -> str:
148
+ pad = " " * indent
149
+ rows = [f"{pad}{self.cat}[{self.start}:{self.end}] {self.rule}".rstrip()]
150
+ rows += [c.tree(indent + 1) for c in self.children]
151
+ return "\n".join(rows)
152
+
153
+
154
+ class _Item:
155
+ """A dotted rule with its bindings and the daughters matched so far."""
156
+
157
+ __slots__ = ("prod", "dot", "start", "bindings", "children", "weight", "symbol", "done", "key")
158
+
159
+ def __init__(self, prod: Any, dot: int, start: int, bindings: tuple[tuple[str, Any], ...],
160
+ children: tuple[Node, ...], weight: float) -> None:
161
+ self.prod, self.dot, self.start = prod, dot, start
162
+ self.bindings, self.children, self.weight = bindings, children, weight
163
+ self.done = dot >= len(prod.rhs)
164
+ self.symbol = None if self.done else prod.rhs[dot]
165
+ self.key = (prod.name, dot, start, bindings, tuple(c.key for c in children))
166
+
167
+ def next_symbol(self) -> Any:
168
+ return self.symbol
169
+
170
+
171
+ def _freeze(bindings: Bindings) -> tuple[tuple[str, Any], ...]:
172
+ return tuple(sorted(bindings.items(), key=lambda kv: kv[0]))
173
+
174
+
175
+ class Chart:
176
+ """Every constituent the grammar licenses, indexed by span and by start.
177
+
178
+ Distinct meanings over one span are kept (that is real ambiguity), capped, and
179
+ looked up by content key — a dict, not a scan with string comparisons.
180
+ """
181
+
182
+ def __init__(self, cap: int = 8) -> None:
183
+ self.cap = cap
184
+ self.by_span: dict[tuple[int, int, str], dict[Any, Node]] = {}
185
+ self.by_start: dict[tuple[int, str], list[Node]] = {}
186
+ self._serial = 0
187
+
188
+ def add(self, node: Node) -> bool:
189
+ self._serial += 1
190
+ node.serial = self._serial
191
+ span = self.by_span.setdefault((node.start, node.end, node.cat), {})
192
+ key = node.sem_key
193
+ seen = span.get(key)
194
+ if seen is not None:
195
+ if node.weight <= seen.weight:
196
+ return False
197
+ span[key] = node
198
+ self._replace(node, seen)
199
+ return True
200
+ if len(span) >= self.cap:
201
+ # lowest weight, and among equal weights the oldest — the same node the
202
+ # previous linear scan dropped, so capping a span still yields the same
203
+ # surviving set rather than one that depends on dict ordering
204
+ worst_key = min(span, key=lambda k: (span[k].weight, span[k].serial))
205
+ worst = span[worst_key]
206
+ if worst.weight >= node.weight:
207
+ return False
208
+ del span[worst_key]
209
+ span[key] = node
210
+ self._replace(node, worst)
211
+ return True
212
+ span[key] = node
213
+ self.by_start.setdefault((node.start, node.cat), []).append(node)
214
+ return True
215
+
216
+ def _replace(self, node: Node, dropped: Node) -> None:
217
+ row = self.by_start.setdefault((node.start, node.cat), [])
218
+ for i, other in enumerate(row):
219
+ if other is dropped:
220
+ row[i] = node
221
+ return
222
+ row.append(node)
223
+
224
+ def spanning(self, start: int, end: int, cat: str) -> list[Node]:
225
+ return list(self.by_span.get((start, end, cat), {}).values())
226
+
227
+ def starting(self, start: int, cats: Iterable[str]) -> list[Node]:
228
+ out: list[Node] = []
229
+ for cat in sorted(set(cats)):
230
+ out.extend(self.by_start.get((start, cat), ()))
231
+ return out
232
+
233
+ def size(self) -> int:
234
+ return sum(len(v) for v in self.by_span.values())
235
+
236
+
237
+ def build_chart(grammar: Grammar, tokens: Sequence[str], *, cap: int = 8, max_tokens: int = 40,
238
+ max_items: int = 20000) -> Chart:
239
+ """Earley recognition with feature unification and semantic assembly."""
240
+ n = min(len(tokens), max_tokens)
241
+ barriers = {i for i, t in enumerate(tokens[:n]) if t.lower() in grammar.barriers}
242
+ chart = Chart(cap)
243
+ columns: list[dict[tuple, _Item]] = [dict() for _ in range(n + 1)]
244
+ agendas: list[list[_Item]] = [[] for _ in range(n + 1)]
245
+ # items in a column indexed by the category they are waiting for. Completion used
246
+ # to walk the whole start column and let ``_advance`` reject the mismatches, which
247
+ # is O(items in the column) for every node the chart finds; a clause of any length
248
+ # spends nearly all of it saying no.
249
+ waiting: list[dict[str, list[tuple]]] = [dict() for _ in range(n + 1)]
250
+
251
+ def push(column: int, item: _Item) -> None:
252
+ seen = columns[column].get(item.key)
253
+ if seen is None or item.weight > seen.weight:
254
+ columns[column][item.key] = item
255
+ agendas[column].append(item)
256
+ if seen is None and not item.done and type(item.symbol) is Cat:
257
+ # the key, not the item: a later push may replace this item with a
258
+ # better-weighted one under the same key, and completion wants that one
259
+ waiting[column].setdefault(item.symbol.name, []).append(item.key)
260
+
261
+ # lexical nodes are pre-terminals: productions never spell words out
262
+ for i in range(n):
263
+ for entry in grammar.entries_for(tokens[i]):
264
+ sem = entry.sem if entry.sem is not None else tokens[i]
265
+ chart.add(Node(entry.cat, i, i + 1, sem, _freeze(dict(entry.features)), entry.weight,
266
+ f"lex:{entry.word}", (), (tokens[i],)))
267
+
268
+ # A constituent may begin at any position, because the cover needs fragments — but
269
+ # only one whose first symbol can actually start there. Seeding every production at
270
+ # every position was ~90 items per column, nearly all of them dead on arrival.
271
+ startable = _startable(grammar, tokens, chart, n)
272
+
273
+ for i in range(n + 1):
274
+ for prod in startable[i]:
275
+ push(i, _Item(prod, 0, i, (), (), prod.weight))
276
+ processed: set[tuple] = set()
277
+ while agendas[i] and len(processed) < max_items:
278
+ item = agendas[i].pop()
279
+ if item.key in processed:
280
+ continue
281
+ processed.add(item.key)
282
+ bindings: Bindings = dict(item.bindings)
283
+
284
+ if item.done:
285
+ feats = ground(item.prod.lhs.features, bindings)
286
+ try:
287
+ sem = build_sem(item.prod.sem, [c.sem for c in item.children], [c.words for c in item.children], [c.features() for c in item.children])
288
+ except (IndexError, TypeError, KeyError):
289
+ continue
290
+ if any(item.start <= b < i for b in barriers) and i - item.start > 1:
291
+ continue # no constituent spans a barrier word
292
+ node = Node(item.prod.lhs.name, item.start, i, sem, _freeze(feats), item.weight, item.prod.name,
293
+ item.children, tuple(w for c in item.children for w in c.words))
294
+ if chart.add(node) and item.start < i:
295
+ for key in waiting[item.start].get(node.cat, ()):
296
+ current = columns[item.start].get(key)
297
+ if current is None:
298
+ continue
299
+ advanced = _advance(current, node)
300
+ if advanced is not None:
301
+ push(i, advanced)
302
+ continue
303
+
304
+ symbol = item.symbol
305
+ if isinstance(symbol, Terminal):
306
+ if i < n and tokens[i].lower() == symbol.word.lower():
307
+ leaf = Node(f'"{symbol.word}"', i, i + 1, symbol.word, (), 0.0, "terminal", (), (tokens[i],))
308
+ push(i + 1, _Item(item.prod, item.dot + 1, item.start, item.bindings, item.children + (leaf,), item.weight))
309
+ continue
310
+
311
+ assert isinstance(symbol, Cat)
312
+ wanted = ground(symbol.features, bindings)
313
+ for prod in grammar.by_lhs(symbol.name):
314
+ # Predict with *empty* bindings. Carrying the parent's constraints in
315
+ # (or freshening variables per prediction) gives two items the same
316
+ # dotted rule with different binding sets, which defeats Earley's
317
+ # dedupe and makes a left-recursive production loop forever. The
318
+ # parent's constraint is still enforced, in `_advance` on completion.
319
+ if unify(wanted, prod.lhs.features, {}) is None:
320
+ continue
321
+ push(i, _Item(prod, 0, i, (), (), prod.weight))
322
+ for node in chart.starting(i, [symbol.name]):
323
+ advanced = _advance(item, node)
324
+ if advanced is not None:
325
+ push(node.end, advanced)
326
+ return chart
327
+
328
+
329
+ def _startable(grammar: Grammar, tokens: Sequence[str], chart: Chart, n: int) -> list[list[Any]]:
330
+ """Productions whose first symbol can begin at each position.
331
+
332
+ A production has no empty right-hand side (``production`` refuses one), so it can
333
+ only start where its first symbol can. The set of categories available at a
334
+ position is the lexical ones there, closed under "a production whose first symbol
335
+ is available makes its own category available".
336
+ """
337
+ # the grammar's own order, not a set's: iterating `categories()` made seeding — and
338
+ # therefore which of two equal-scoring readings won — depend on set ordering
339
+ all_prods = list(grammar.productions)
340
+ out: list[list[Any]] = []
341
+ for i in range(n + 1):
342
+ available = {cat for (start, cat) in chart.by_start if start == i}
343
+ word = tokens[i].lower() if i < n else None
344
+ changed = True
345
+ while changed:
346
+ changed = False
347
+ for prod in all_prods:
348
+ if prod.lhs.name in available:
349
+ continue
350
+ first = prod.rhs[0]
351
+ if isinstance(first, Terminal):
352
+ if word is not None and first.word.lower() == word:
353
+ available.add(prod.lhs.name)
354
+ changed = True
355
+ elif first.name in available:
356
+ available.add(prod.lhs.name)
357
+ changed = True
358
+ seeds = []
359
+ for prod in all_prods:
360
+ first = prod.rhs[0]
361
+ if isinstance(first, Terminal):
362
+ if word is not None and first.word.lower() == word:
363
+ seeds.append(prod)
364
+ elif first.name in available:
365
+ seeds.append(prod)
366
+ out.append(seeds)
367
+ return out
368
+
369
+
370
+ def _advance(item: _Item, node: Node) -> _Item | None:
371
+ """Move an item's dot over a completed constituent, if their features agree."""
372
+ symbol = item.symbol
373
+ if not isinstance(symbol, Cat) or symbol.name != node.cat:
374
+ return None
375
+ if not _demands_met(symbol.features, node.features()):
376
+ return None
377
+ if _meaning_carries(symbol.features, node.sem):
378
+ return None
379
+ bindings: Bindings = dict(item.bindings)
380
+ bound = unify(ground(symbol.features, bindings), node.features(), bindings)
381
+ if bound is None:
382
+ return None
383
+ return _Item(item.prod, item.dot + 1, item.start, _freeze(bound), item.children + (node,), item.weight + node.weight)
384
+
385
+
386
+ def _demands_met(wanted: Mapping[str, Any], got: Mapping[str, Any]) -> bool:
387
+ """A literal feature demand must be *present* on the daughter, not merely unrefuted.
388
+
389
+ Unification alone treats an absent feature as compatible, which is right for an
390
+ agreement variable and wrong for subcategorisation: ``V[ditrans=true]`` would then
391
+ match every verb, and "make me a sandwich" parses as a double-object verb whose
392
+ object is "me". Selectional demands are checked here instead.
393
+ """
394
+ for key, value in wanted.items():
395
+ if isinstance(value, FVar):
396
+ continue
397
+ if value is ABSENT: # ``VP[tense=!]``: a modal's complement is a bare infinitive
398
+ if key in got:
399
+ return False
400
+ continue
401
+ if key not in got or got[key] != value:
402
+ return False
403
+ return True
404
+
405
+
406
+ def _meaning_carries(wanted: Mapping[str, Any], sem: Any) -> bool:
407
+ """Whether a forbidden feature sits in the daughter's *meaning*.
408
+
409
+ Tense reaches the frame, not the node: a production lifts it from the verb into
410
+ what the clause says, so a category's feature list never mentions it. A
411
+ prohibition has to look where the feature actually is, or ``VP[tense=!]`` would
412
+ read "should gave food" happily and only generation would know better.
413
+ """
414
+ feats = getattr(getattr(sem, "frame", sem), "features", None)
415
+ if not feats:
416
+ return False
417
+ return any(value is ABSENT and key in feats for key, value in wanted.items())
418
+
419
+
420
+ # ------------------------------------------------------------------ the cover
421
+
422
+
423
+ @dataclass(frozen=True)
424
+ class Reading:
425
+ """One way to read the whole utterance: constituents plus what was skipped."""
426
+
427
+ meanings: tuple[Any, ...]
428
+ nodes: tuple[Node, ...]
429
+ skipped: tuple[tuple[int, str], ...]
430
+ score: float
431
+
432
+ @property
433
+ def complete(self) -> bool:
434
+ return not self.skipped
435
+
436
+ @property
437
+ def guessed(self) -> tuple[tuple[str, str], ...]:
438
+ """Words the lexicon did not have, with the category each was guessed as."""
439
+ return tuple(g for node in self.nodes for g in node.guesses())
440
+
441
+ def confidence(self, tokens: int) -> Score:
442
+ """Lower when more of the utterance rested on guessed words. Uncalibrated."""
443
+ share = len(self.guessed) / max(1, tokens)
444
+ return Score(round(max(0.0, 1.0 - share), 3), "uncalibrated")
445
+
446
+ def describe(self) -> str:
447
+ parts = [m.describe() if hasattr(m, "describe") else repr(m) for m in self.meanings]
448
+ tail = f" (skipped: {' '.join(w for _, w in self.skipped)})" if self.skipped else ""
449
+ return "; ".join(parts) + tail
450
+
451
+
452
+ def cover(grammar: Grammar, tokens: Sequence[str], chart: Chart, *, beam: int = 4,
453
+ starts: Sequence[str] | None = None, clause_cost: float = -0.3) -> list[Reading]:
454
+ """k-best sequences of top-level constituents, skipped tokens priced by the background.
455
+
456
+ ``clause_cost`` charges each top-level constituent, so one clause that spans the
457
+ utterance beats two that merely add up to it — which is what makes "Anem said the
458
+ field failed" one report rather than two independent assertions. Chaining requests
459
+ ("make a folder then list it") still pays it once per clause and wins anyway,
460
+ because there is no single-clause reading to compete with.
461
+ """
462
+ n = len(tokens)
463
+ cats = tuple(starts) if starts is not None else grammar.start
464
+ best: list[list[tuple[float, tuple[tuple[str, Any], ...]]]] = [[] for _ in range(n + 1)]
465
+ best[n] = [(0.0, ())]
466
+ for i in range(n - 1, -1, -1):
467
+ options: list[tuple[float, tuple[tuple[str, Any], ...]]] = []
468
+ skip = grammar.lexicon.bg(tokens[i])
469
+ for score, tail in best[i + 1]:
470
+ options.append((score + skip, (("skip", i),) + tail))
471
+ for node in chart.starting(i, cats):
472
+ if node.end > i:
473
+ for score, tail in best[node.end]:
474
+ options.append((score + node.weight + clause_cost, (("node", node),) + tail))
475
+ options.sort(key=lambda o: (-o[0], _steps_key(o[1])))
476
+ best[i] = options[:beam]
477
+
478
+ readings = []
479
+ for score, steps in best[0]:
480
+ nodes = tuple(s[1] for s in steps if s[0] == "node")
481
+ skipped = tuple((s[1], tokens[s[1]]) for s in steps if s[0] == "skip")
482
+ readings.append(Reading(tuple(node.sem for node in nodes), nodes, skipped, score))
483
+ return _dedupe(readings)
484
+
485
+
486
+ def _steps_key(steps: tuple) -> tuple:
487
+ """A stable order for equal-scoring covers, so a tie always resolves the same way.
488
+
489
+ Serial numbers, not content: totally ordered, cheap, and assigned in the chart's
490
+ (deterministic) creation order. Preferring the cover that filled more roles was
491
+ tried instead and changed nothing on either benchmark, so the cheap rule stands.
492
+ """
493
+ return tuple((0, s[1]) if s[0] == "skip" else (1, s[1].serial) for s in steps)
494
+
495
+
496
+ def _dedupe(readings: Sequence[Reading]) -> list[Reading]:
497
+ out: list[Reading] = []
498
+ seen: set = set()
499
+ for reading in sorted(readings, key=lambda r: (-r.score, tuple(n.serial for n in r.nodes))):
500
+ key = (tuple(_key_of(m) for m in reading.meanings), reading.skipped)
501
+ if key not in seen:
502
+ seen.add(key)
503
+ out.append(reading)
504
+ return out
505
+
506
+
507
+ @dataclass
508
+ class Understanding:
509
+ """The result of reading one utterance: readings best-first, and what it cost."""
510
+
511
+ text: str
512
+ tokens: tuple[str, ...]
513
+ readings: tuple[Reading, ...]
514
+ ms: float = 0.0
515
+ chart: Chart | None = field(default=None, repr=False)
516
+
517
+ @property
518
+ def best(self) -> Reading | None:
519
+ return self.readings[0] if self.readings else None
520
+
521
+ @property
522
+ def ambiguous(self) -> bool:
523
+ """Two readings of the *same* score: a genuine ambiguity, not just a ranking."""
524
+ return len(self.readings) > 1 and abs(self.readings[0].score - self.readings[1].score) < 1e-9
525
+
526
+ @property
527
+ def meanings(self) -> tuple[Any, ...]:
528
+ return self.best.meanings if self.best else ()
529
+
530
+ @property
531
+ def guessed(self) -> tuple[tuple[str, str], ...]:
532
+ """Words read by guess rather than from the lexicon, for the caller to see."""
533
+ return self.best.guessed if self.best else ()
534
+
535
+ @property
536
+ def confidence(self) -> Score:
537
+ return self.best.confidence(len(self.tokens)) if self.best else Score(0.0, "uncalibrated")
538
+
539
+ @property
540
+ def skipped(self) -> tuple[str, ...]:
541
+ return tuple(w for _, w in self.best.skipped) if self.best else self.tokens
542
+
543
+ @property
544
+ def coverage(self) -> float:
545
+ return 1.0 if not self.tokens else 1.0 - len(self.skipped) / len(self.tokens)
546
+
547
+ def describe(self) -> str:
548
+ return self.best.describe() if self.best else "(no reading)"
549
+
550
+
551
+ def understand(grammar: Grammar, text: str, *, beam: int = 4, cap: int = 8,
552
+ starts: Sequence[str] | None = None, clause_cost: float = -0.3) -> Understanding:
553
+ """Parse one utterance: chart, then cover, then readings best-first."""
554
+ t0 = time.perf_counter()
555
+ tokens = tuple(tokenize(text))
556
+ chart = build_chart(grammar, tokens, cap=cap)
557
+ readings = cover(grammar, tokens, chart, beam=beam, starts=starts, clause_cost=clause_cost)
558
+ return Understanding(text, tokens, tuple(readings), (time.perf_counter() - t0) * 1000, chart)
@@ -0,0 +1,132 @@
1
+ """Resolving what "it", "there" and "she" pick out, against a salience list.
2
+
3
+ The rule is ordinary centering: candidates are the entities the conversation has
4
+ touched, most recent first, filtered by what the pronoun agrees with. Two things
5
+ make it honest rather than convenient:
6
+
7
+ * a pronoun with **no** compatible candidate stays unresolved, and
8
+ * a pronoun with **two equally recent** compatible candidates stays unresolved
9
+ with both recorded.
10
+
11
+ Unresolved references then block claim conversion (:func:`semantics.to_claims`
12
+ returns ``Unknown``), so the failure surfaces where a caller must handle it
13
+ rather than as a wrong belief. ``symbolic-ai-models``'s learned reader does the
14
+ same thing with an XOR factor over the claims the antecedents would produce
15
+ (``symbolic_ai_core/reader/learned.py``); this keeps the candidates on the
16
+ entity instead, which is the same refusal in a smaller shape.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+ from typing import Any, Iterable, Mapping, Sequence
23
+
24
+ from ..records import Ref
25
+ from .semantics import Entity, Frame, Question, Request
26
+
27
+ #: Which entity features a pronoun demands of its antecedent.
28
+ AGREEMENT: Mapping[str, Mapping[str, Any]] = {
29
+ "it": {"animate": False},
30
+ "they": {},
31
+ "them": {},
32
+ "he": {"animate": True, "gender": "m"},
33
+ "him": {"animate": True, "gender": "m"},
34
+ "she": {"animate": True, "gender": "f"},
35
+ "her": {"animate": True, "gender": "f"},
36
+ "there": {"place": True},
37
+ }
38
+
39
+
40
+ @dataclass
41
+ class Context:
42
+ """What the conversation has touched, most salient last."""
43
+
44
+ mentions: list[Entity] = field(default_factory=list)
45
+ speaker: Ref | None = None
46
+ addressee: Ref | None = None
47
+ #: the caller's own notion of the current object ("it" after an action)
48
+ focus: Entity | None = None
49
+ limit: int = 12
50
+
51
+ def mention(self, entity: Entity) -> None:
52
+ if entity.kind == "pronoun":
53
+ return
54
+ self.mentions = [m for m in self.mentions if (m.ref, m.text) != (entity.ref, entity.text)][-self.limit:]
55
+ self.mentions.append(entity)
56
+
57
+ def observe(self, meaning: Any) -> None:
58
+ """Record the entities of a reading, so the next utterance can refer back."""
59
+ frame = meaning.frame if isinstance(meaning, (Request, Question)) else meaning
60
+ if isinstance(frame, Frame):
61
+ for entity in frame.entities():
62
+ self.mention(entity)
63
+ elif isinstance(frame, Entity):
64
+ self.mention(frame)
65
+
66
+ def candidates(self, pronoun: Entity) -> list[Entity]:
67
+ wanted = dict(AGREEMENT.get(pronoun.text.lower(), {}))
68
+ ordered = list(reversed(self.mentions))
69
+ if self.focus is not None:
70
+ ordered = [self.focus] + [m for m in ordered if m is not self.focus]
71
+ out = []
72
+ for entity in ordered:
73
+ if all(entity.features.get(k) == v for k, v in wanted.items() if k in entity.features):
74
+ if wanted.get("place") and not entity.features.get("place"):
75
+ continue
76
+ out.append(entity)
77
+ return out
78
+
79
+
80
+ def resolve(meaning: Any, context: Context) -> Any:
81
+ """Replace resolvable pronouns; leave the rest unresolved, with candidates."""
82
+ if isinstance(meaning, Request):
83
+ return Request(resolve(meaning.frame, context))
84
+ if isinstance(meaning, Question):
85
+ return Question(resolve(meaning.frame, context), meaning.asked)
86
+ if isinstance(meaning, tuple):
87
+ return tuple(resolve(m, context) for m in meaning)
88
+ if isinstance(meaning, Entity):
89
+ # resolve what is nested first: "put it in a folder called it" has two
90
+ # pronouns at different depths, and only descending reaches the inner one
91
+ inner = {k: resolve(v, context) if isinstance(v, (Entity, Frame, tuple)) else v
92
+ for k, v in meaning.features.items()}
93
+ if inner != meaning.features:
94
+ meaning = Entity(meaning.kind, meaning.text, inner, meaning.ref, meaning.candidates)
95
+ return _resolve_entity(meaning, context)
96
+ if isinstance(meaning, Frame):
97
+ roles = {k: resolve(v, context) for k, v in meaning.roles.items()}
98
+ features = {k: resolve(v, context) if isinstance(v, (Entity, Frame)) else v for k, v in meaning.features.items()}
99
+ return Frame(meaning.predicate, roles, features)
100
+ return meaning
101
+
102
+
103
+ def _resolve_entity(entity: Entity, context: Context) -> Entity:
104
+ if entity.kind != "pronoun":
105
+ return entity
106
+ word = entity.text.lower()
107
+ if word in ("i", "me", "we") and context.speaker is not None:
108
+ return entity.with_ref(context.speaker)
109
+ if word == "you" and context.addressee is not None:
110
+ return entity.with_ref(context.addressee)
111
+ found = context.candidates(entity)
112
+ if not found:
113
+ return Entity(entity.kind, entity.text, entity.features, None, ())
114
+ if len(found) > 1 and _tied(found[0], found[1]):
115
+ return Entity(entity.kind, entity.text, entity.features, None, tuple(found[:3]))
116
+ best = found[0]
117
+ return Entity("resolved", best.text, {**best.features, "via": entity.text}, best.ref, ())
118
+
119
+
120
+ def _tied(a: Entity, b: Entity) -> bool:
121
+ """Two antecedents are tied when nothing in the discourse separates them."""
122
+ return a.features.get("noun") == b.features.get("noun") and a.ref is None and b.ref is None and a.text != b.text
123
+
124
+
125
+ def unresolved(meaning: Any) -> list[Entity]:
126
+ """Every reference the discourse could not settle, for a caller to ask about."""
127
+ frame = meaning.frame if isinstance(meaning, (Request, Question)) else meaning
128
+ if isinstance(frame, Entity):
129
+ return [] if frame.resolved else [frame]
130
+ if not isinstance(frame, Frame):
131
+ return []
132
+ return [e for e in frame.entities() if not e.resolved]
File without changes