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