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,893 @@
|
|
|
1
|
+
"""Categories, productions, a lexicon with morphology, and the grammar that holds them.
|
|
2
|
+
|
|
3
|
+
Three design decisions carry most of the weight here.
|
|
4
|
+
|
|
5
|
+
**Semantics are data, not callbacks.** A production says how its mother's meaning
|
|
6
|
+
is built from its daughters' with a small spec (:class:`Head`, :class:`Build`,
|
|
7
|
+
:class:`Merge`, :class:`Attach`, :class:`Ent`, …). A Python callable would be
|
|
8
|
+
easier to write and impossible to run backwards; a spec can be read in reverse,
|
|
9
|
+
which is what lets :mod:`tensorcode.language.generate` realise a frame with the
|
|
10
|
+
*same* grammar that parsed it. Specs nest: anywhere a spec refers to a daughter
|
|
11
|
+
it may instead refer to another spec over the same daughters.
|
|
12
|
+
|
|
13
|
+
**Word order is carried apart from role assignment.** A production's roles name
|
|
14
|
+
daughter positions, so a dialect that moves the verb needs one more production
|
|
15
|
+
over the same head rather than one predicate per word order. (Taken from
|
|
16
|
+
``symbolic-ai-models``'s ``symbolic_ai_parsers/grammar.py``, where slots are kept
|
|
17
|
+
out of the surface sequence for exactly this reason.)
|
|
18
|
+
|
|
19
|
+
**A preposition's meaning is the role it marks.** "in downloads" and "to
|
|
20
|
+
documents" then share one production, and a domain adds a role by adding a word.
|
|
21
|
+
|
|
22
|
+
Grammars are immutable and extensible: ``grammar.extend(...)`` returns a new
|
|
23
|
+
grammar, so a caller can add file names, app names, or a village's drifting
|
|
24
|
+
words without touching the core English.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import math
|
|
30
|
+
import re
|
|
31
|
+
from functools import lru_cache
|
|
32
|
+
from collections import defaultdict
|
|
33
|
+
from dataclasses import dataclass, field, replace
|
|
34
|
+
from typing import Any, Iterable, Mapping, Sequence, Union
|
|
35
|
+
|
|
36
|
+
from .features import Bindings, FVar, ground, merge, rename, resolve, unify
|
|
37
|
+
from .semantics import Entity, Frame, Question, Request
|
|
38
|
+
|
|
39
|
+
# ------------------------------------------------------------------ categories
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Cat:
|
|
44
|
+
"""A syntactic category: a name plus a feature structure."""
|
|
45
|
+
|
|
46
|
+
name: str
|
|
47
|
+
features: Mapping[str, Any] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
def __hash__(self) -> int:
|
|
50
|
+
return hash((self.name, tuple(sorted(self.features.items(), key=repr))))
|
|
51
|
+
|
|
52
|
+
def __str__(self) -> str:
|
|
53
|
+
if not self.features:
|
|
54
|
+
return self.name
|
|
55
|
+
return f"{self.name}[{','.join(f'{k}={v}' for k, v in sorted(self.features.items()))}]"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Terminal:
|
|
60
|
+
"""A literal word in a production (``"did"`` in ``VP -> "did" "not" VP``)."""
|
|
61
|
+
|
|
62
|
+
word: str
|
|
63
|
+
|
|
64
|
+
def __str__(self) -> str:
|
|
65
|
+
return f'"{self.word}"'
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------- semantics
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Head:
|
|
73
|
+
"""The mother's meaning is this daughter's meaning."""
|
|
74
|
+
|
|
75
|
+
index: "SemRef"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Lit:
|
|
80
|
+
"""A constant meaning (an atom, an :class:`Entity`, or a :class:`Frame`)."""
|
|
81
|
+
|
|
82
|
+
value: Any
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class Build:
|
|
87
|
+
"""Construct a frame: predicate from a daughter or a constant, roles from daughters."""
|
|
88
|
+
|
|
89
|
+
predicate: str | None = None
|
|
90
|
+
predicate_from: "SemRef | None" = None
|
|
91
|
+
roles: tuple[tuple[str, "SemRef"], ...] = ()
|
|
92
|
+
features: tuple[tuple[str, Any], ...] = ()
|
|
93
|
+
#: (target feature, daughter, that daughter's *grammatical* feature) — how tense,
|
|
94
|
+
#: aspect, degree and modality reach the meaning from inflection and function words
|
|
95
|
+
lift: tuple[tuple[str, int, str], ...] = ()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class Merge:
|
|
100
|
+
"""Take a daughter's frame and add roles/features to it."""
|
|
101
|
+
|
|
102
|
+
index: "SemRef"
|
|
103
|
+
roles: tuple[tuple[str, "SemRef"], ...] = ()
|
|
104
|
+
features: tuple[tuple[str, Any], ...] = ()
|
|
105
|
+
lift: tuple[tuple[str, int, str], ...] = ()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class Coord:
|
|
110
|
+
"""Coordination: the meaning is the tuple of the named daughters' meanings."""
|
|
111
|
+
|
|
112
|
+
indices: tuple["SemRef", ...]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class Ent:
|
|
117
|
+
"""Build an :class:`Entity` — what a referring expression picks out."""
|
|
118
|
+
|
|
119
|
+
kind: str = "description"
|
|
120
|
+
words_from: tuple[int, ...] = ()
|
|
121
|
+
features: tuple[tuple[str, Any], ...] = ()
|
|
122
|
+
features_from: tuple[tuple[str, "SemRef"], ...] = ()
|
|
123
|
+
lift: tuple[tuple[str, int, str], ...] = ()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class Qualify:
|
|
128
|
+
"""Add features (and, for frames, roles) to a daughter's meaning."""
|
|
129
|
+
|
|
130
|
+
index: "SemRef"
|
|
131
|
+
features: tuple[tuple[str, Any], ...] = ()
|
|
132
|
+
features_from: tuple[tuple[str, "SemRef"], ...] = ()
|
|
133
|
+
roles_from: tuple[tuple[str, "SemRef"], ...] = ()
|
|
134
|
+
extend_text_from: tuple[int, ...] = ()
|
|
135
|
+
lift: tuple[tuple[str, int, str], ...] = ()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class Attach:
|
|
140
|
+
"""Attach a role-marked modifier; the modifier's ``role`` says which slot it fills."""
|
|
141
|
+
|
|
142
|
+
index: "SemRef"
|
|
143
|
+
modifier: "SemRef"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class Locative:
|
|
148
|
+
"""A predication whose content is a role-marked modifier ("is in downloads")."""
|
|
149
|
+
|
|
150
|
+
predicate: str
|
|
151
|
+
modifier: "SemRef"
|
|
152
|
+
theme: "SemRef | None" = None
|
|
153
|
+
theme_role: str = "theme"
|
|
154
|
+
lift: tuple[tuple[str, int, str], ...] = ()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class Ask:
|
|
159
|
+
"""An interrogative reading: a frame plus the role being asked about."""
|
|
160
|
+
|
|
161
|
+
index: "SemRef"
|
|
162
|
+
asked: str = "polarity"
|
|
163
|
+
asked_from: "SemRef | None" = None # a wh-word whose meaning names the queried role
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class Order:
|
|
168
|
+
"""An imperative reading: a request for this daughter's frame."""
|
|
169
|
+
|
|
170
|
+
index: "SemRef"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
Sem = Union[Head, Lit, Build, Merge, Coord, Ent, Qualify, Attach, Locative, Ask, Order]
|
|
174
|
+
SemRef = Union[int, Sem]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def build_sem(sem: Sem, parts: Sequence[Any], words: Sequence[Sequence[str]],
|
|
178
|
+
feats: Sequence[Mapping[str, Any]] = ()) -> Any:
|
|
179
|
+
"""Apply a semantic spec to daughters' meanings. Pure, and therefore reversible.
|
|
180
|
+
|
|
181
|
+
``words[i]`` is daughter *i*'s surface words (how an entity keeps the text it
|
|
182
|
+
was named with) and ``feats[i]`` its grammatical features (how tense, aspect,
|
|
183
|
+
degree and modality reach the meaning).
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def part(ref: SemRef) -> Any:
|
|
187
|
+
return parts[ref] if isinstance(ref, int) else build_sem(ref, parts, words, feats)
|
|
188
|
+
|
|
189
|
+
def lifted(spec: Any) -> dict[str, Any]:
|
|
190
|
+
out: dict[str, Any] = {}
|
|
191
|
+
for target, index, source in getattr(spec, "lift", ()):
|
|
192
|
+
value = dict(feats[index]).get(source) if index < len(feats) else None
|
|
193
|
+
if value is not None:
|
|
194
|
+
out[target] = value
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
def values(spec: Any) -> dict[str, Any]:
|
|
198
|
+
out = {k: v for k, v in getattr(spec, "features", ())}
|
|
199
|
+
for key, ref in getattr(spec, "features_from", ()):
|
|
200
|
+
got = part(ref)
|
|
201
|
+
if got is not None:
|
|
202
|
+
out[key] = got
|
|
203
|
+
out.update(lifted(spec))
|
|
204
|
+
return out
|
|
205
|
+
|
|
206
|
+
if isinstance(sem, Head):
|
|
207
|
+
return part(sem.index)
|
|
208
|
+
if isinstance(sem, Lit):
|
|
209
|
+
return sem.value
|
|
210
|
+
if isinstance(sem, Ent):
|
|
211
|
+
return Entity(sem.kind, " ".join(w for i in sem.words_from for w in words[i]), values(sem))
|
|
212
|
+
if isinstance(sem, Qualify):
|
|
213
|
+
base, extra = part(sem.index), values(sem)
|
|
214
|
+
if isinstance(base, Entity):
|
|
215
|
+
prefix = " ".join(w for i in sem.extend_text_from for w in words[i])
|
|
216
|
+
text = f"{prefix} {base.text}".strip() if prefix else base.text
|
|
217
|
+
return Entity(base.kind, text, {**base.features, **extra}, base.ref, base.candidates)
|
|
218
|
+
if isinstance(base, Frame):
|
|
219
|
+
roles = {role: part(ref) for role, ref in sem.roles_from}
|
|
220
|
+
return Frame(base.predicate, {**base.roles, **roles}, {**base.features, **extra})
|
|
221
|
+
return base
|
|
222
|
+
if isinstance(sem, Attach):
|
|
223
|
+
return _attach(part(sem.index), part(sem.modifier))
|
|
224
|
+
if isinstance(sem, Locative):
|
|
225
|
+
roles = {} if sem.theme is None else {sem.theme_role: part(sem.theme)}
|
|
226
|
+
return _attach(Frame(sem.predicate, roles, lifted(sem)), part(sem.modifier))
|
|
227
|
+
if isinstance(sem, Ask):
|
|
228
|
+
asked = sem.asked
|
|
229
|
+
if sem.asked_from is not None:
|
|
230
|
+
named = part(sem.asked_from)
|
|
231
|
+
if isinstance(named, str):
|
|
232
|
+
asked = named
|
|
233
|
+
return Question(_as_frame(part(sem.index)).added(mood="interrogative"), asked)
|
|
234
|
+
if isinstance(sem, Order):
|
|
235
|
+
return Request(_as_frame(part(sem.index)).added(mood="imperative"))
|
|
236
|
+
if isinstance(sem, Coord):
|
|
237
|
+
flat: list[Any] = []
|
|
238
|
+
for ref in sem.indices:
|
|
239
|
+
value = part(ref)
|
|
240
|
+
flat.extend(value if isinstance(value, tuple) else [value])
|
|
241
|
+
return tuple(flat)
|
|
242
|
+
if isinstance(sem, Build):
|
|
243
|
+
predicate: Any = sem.predicate
|
|
244
|
+
head: Any = None
|
|
245
|
+
if sem.predicate_from is not None:
|
|
246
|
+
head = part(sem.predicate_from)
|
|
247
|
+
predicate = head.predicate if isinstance(head, Frame) else str(head)
|
|
248
|
+
roles = {role: part(ref) for role, ref in sem.roles}
|
|
249
|
+
features = {k: v for k, v in sem.features}
|
|
250
|
+
features.update(lifted(sem))
|
|
251
|
+
if isinstance(head, Frame): # a verb that already carries roles keeps them
|
|
252
|
+
roles = {**head.roles, **roles}
|
|
253
|
+
features = {**head.features, **features}
|
|
254
|
+
return Frame(str(predicate), roles, features)
|
|
255
|
+
if isinstance(sem, Merge):
|
|
256
|
+
base = _as_frame(part(sem.index))
|
|
257
|
+
roles = {role: part(ref) for role, ref in sem.roles}
|
|
258
|
+
features = {k: v for k, v in sem.features}
|
|
259
|
+
features.update(lifted(sem))
|
|
260
|
+
return Frame(base.predicate, {**base.roles, **roles}, {**base.features, **features})
|
|
261
|
+
raise TypeError(f"unknown semantic spec {sem!r}")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _attach(base: Any, modifier: Any) -> Any:
|
|
265
|
+
if not isinstance(modifier, Frame) or "role" not in modifier.roles:
|
|
266
|
+
return base
|
|
267
|
+
role, value = str(modifier.role("role")), modifier.role("value")
|
|
268
|
+
if isinstance(base, Frame):
|
|
269
|
+
return Frame(base.predicate, {**base.roles, role: value}, base.features)
|
|
270
|
+
if isinstance(base, Entity):
|
|
271
|
+
return Entity(base.kind, base.text, {**base.features, role: value}, base.ref, base.candidates)
|
|
272
|
+
return base
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _as_frame(value: Any) -> Frame:
|
|
276
|
+
if isinstance(value, Frame):
|
|
277
|
+
return value
|
|
278
|
+
if isinstance(value, Question):
|
|
279
|
+
return value.frame
|
|
280
|
+
if isinstance(value, Request):
|
|
281
|
+
return value.frame
|
|
282
|
+
if isinstance(value, Entity):
|
|
283
|
+
return Frame("be", {"subject": value})
|
|
284
|
+
return Frame(str(value))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ----------------------------------------------------------------- productions
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@dataclass(frozen=True)
|
|
291
|
+
class Production:
|
|
292
|
+
lhs: Cat
|
|
293
|
+
rhs: tuple[Cat | Terminal, ...]
|
|
294
|
+
sem: Sem = Head(0)
|
|
295
|
+
weight: float = 0.0 # log-scale; higher wins. 0.0 is the neutral default
|
|
296
|
+
name: str = ""
|
|
297
|
+
|
|
298
|
+
def __str__(self) -> str:
|
|
299
|
+
return f"{self.lhs} -> {' '.join(str(r) for r in self.rhs)}"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
_CAT = re.compile(r"^(?P<name>[A-Za-z_][\w]*)(?:\[(?P<feats>[^\]]*)\])?$")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class _Absent:
|
|
306
|
+
"""A feature demand that the daughter must *not* carry, written ``VP[tense=!]``.
|
|
307
|
+
|
|
308
|
+
The mirror of a literal demand: subcategorisation sometimes needs an absence.
|
|
309
|
+
A modal's complement is a bare infinitive, so ``VP -> Modal VP[tense=!]`` is what
|
|
310
|
+
stops "ought gave" — the prohibition lives in the grammar, where both the parser
|
|
311
|
+
and the generator can see it, rather than in a special case in either.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def __repr__(self) -> str:
|
|
315
|
+
return "!"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
ABSENT = _Absent()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _atom(text: str) -> Any:
|
|
322
|
+
text = text.strip()
|
|
323
|
+
if text == "!":
|
|
324
|
+
return ABSENT
|
|
325
|
+
if text.startswith("?"):
|
|
326
|
+
return FVar(text[1:])
|
|
327
|
+
if text in ("true", "false"):
|
|
328
|
+
return text == "true"
|
|
329
|
+
if re.fullmatch(r"-?\d+", text):
|
|
330
|
+
return int(text)
|
|
331
|
+
return text
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def parse_cat(text: str) -> Cat:
|
|
335
|
+
m = _CAT.match(text.strip())
|
|
336
|
+
if not m:
|
|
337
|
+
raise ValueError(f"not a category: {text!r}")
|
|
338
|
+
feats: dict[str, Any] = {}
|
|
339
|
+
for part in (m.group("feats") or "").split(","):
|
|
340
|
+
if part.strip():
|
|
341
|
+
key, _, value = part.partition("=")
|
|
342
|
+
feats[key.strip()] = _atom(value)
|
|
343
|
+
return Cat(m.group("name"), feats)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def production(text: str, sem: Sem = Head(0), *, weight: float = 0.0, name: str = "") -> Production:
|
|
347
|
+
"""``production('S -> NP[number=?n] VP[number=?n]', Merge(1, roles=(("subject", 0),)))``."""
|
|
348
|
+
lhs_text, _, rhs_text = text.partition("->")
|
|
349
|
+
if not rhs_text:
|
|
350
|
+
raise ValueError(f"production needs '->': {text!r}")
|
|
351
|
+
rhs: list[Cat | Terminal] = []
|
|
352
|
+
for token in re.findall(r'"[^"]*"|\S+', rhs_text.strip()):
|
|
353
|
+
rhs.append(Terminal(token[1:-1]) if token.startswith('"') else parse_cat(token))
|
|
354
|
+
if not rhs:
|
|
355
|
+
raise ValueError(f"empty production: {text!r}") # no epsilon: the chart relies on it
|
|
356
|
+
return Production(parse_cat(lhs_text), tuple(rhs), sem, weight, name or text.strip())
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# --------------------------------------------------------------------- lexicon
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@dataclass(frozen=True)
|
|
363
|
+
class Entry:
|
|
364
|
+
"""One reading of one word: its category, its features, and what it means."""
|
|
365
|
+
|
|
366
|
+
word: str
|
|
367
|
+
cat: str
|
|
368
|
+
features: Mapping[str, Any] = field(default_factory=dict)
|
|
369
|
+
sem: Any = None # a Frame, an Entity, an atom, or None to mean "the word itself"
|
|
370
|
+
weight: float = 0.0
|
|
371
|
+
|
|
372
|
+
def __hash__(self) -> int:
|
|
373
|
+
"""A cached hash that builds no strings.
|
|
374
|
+
|
|
375
|
+
This used to be ``repr`` of the meaning, and putting entries in a cache key
|
|
376
|
+
made that 148 ``repr`` calls per sentence — the same mistake, in the same
|
|
377
|
+
shape, as the chart keys in §9. Equality is still the dataclass's own, so a
|
|
378
|
+
key collision between two entries with the same word costs nothing.
|
|
379
|
+
"""
|
|
380
|
+
cached = getattr(self, "_hash", None)
|
|
381
|
+
if cached is None:
|
|
382
|
+
try:
|
|
383
|
+
cached = hash((self.word, self.cat,
|
|
384
|
+
tuple(sorted(self.features.items(), key=lambda kv: kv[0])),
|
|
385
|
+
_sem_key(self.sem)))
|
|
386
|
+
except TypeError: # an unhashable feature value
|
|
387
|
+
cached = hash((self.word, self.cat))
|
|
388
|
+
object.__setattr__(self, "_hash", cached)
|
|
389
|
+
return cached
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
#: Suffix rules, tried against the lexicon's known lemmas, per category: a noun's
|
|
393
|
+
#: ``-s`` is a plural and a verb's is a third person, and conflating them is how a
|
|
394
|
+
#: grammar starts agreeing with the wrong thing. English inflection is knowledge of
|
|
395
|
+
#: the language, so it lives in code; the *words* do not.
|
|
396
|
+
SUFFIX_RULES: tuple[tuple[str, str, Mapping[str, Any], tuple[str, ...]], ...] = (
|
|
397
|
+
("ies", "y", {"number": "plural"}, ("N",)),
|
|
398
|
+
("es", "", {"number": "plural"}, ("N",)),
|
|
399
|
+
("s", "", {"number": "plural"}, ("N",)),
|
|
400
|
+
("ies", "y", {"number": "singular", "person": 3, "tense": "present"}, ("V",)),
|
|
401
|
+
("es", "", {"number": "singular", "person": 3, "tense": "present"}, ("V",)),
|
|
402
|
+
("s", "", {"number": "singular", "person": 3, "tense": "present"}, ("V",)),
|
|
403
|
+
("ing", "", {"aspect": "progressive"}, ("V",)),
|
|
404
|
+
("ing", "e", {"aspect": "progressive"}, ("V",)),
|
|
405
|
+
("ied", "y", {"tense": "past"}, ("V",)),
|
|
406
|
+
("ed", "", {"tense": "past"}, ("V",)),
|
|
407
|
+
("ed", "e", {"tense": "past"}, ("V",)),
|
|
408
|
+
("d", "", {"tense": "past"}, ("V",)),
|
|
409
|
+
("ier", "y", {"degree": "comparative"}, ("Adj",)),
|
|
410
|
+
("er", "", {"degree": "comparative"}, ("Adj",)),
|
|
411
|
+
("iest", "y", {"degree": "superlative"}, ("Adj",)),
|
|
412
|
+
("est", "", {"degree": "superlative"}, ("Adj",)),
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@dataclass(frozen=True)
|
|
417
|
+
class Lexicon:
|
|
418
|
+
entries: Mapping[str, tuple[Entry, ...]] = field(default_factory=dict)
|
|
419
|
+
#: fitted log P(token) for tokens no constituent claims. A *fitted* background
|
|
420
|
+
#: rather than a hand-set skip penalty, so a partial parse and a full parse are
|
|
421
|
+
#: scored on one scale (``symbolic-ai-models``, ``parsers/parsers/cky_001``).
|
|
422
|
+
background: Mapping[str, float] = field(default_factory=dict)
|
|
423
|
+
unseen_background: float = math.log(1e-3)
|
|
424
|
+
|
|
425
|
+
def near(self, token: str) -> list[str]:
|
|
426
|
+
"""Lemmas one edit away from an unknown token (transposition counts as one).
|
|
427
|
+
|
|
428
|
+
Deterministic and cheap: only for tokens of five characters or more, and the
|
|
429
|
+
readings it offers are penalised, so a real word always wins.
|
|
430
|
+
"""
|
|
431
|
+
low = token.lower()
|
|
432
|
+
if len(low) < 5 or low in self.entries:
|
|
433
|
+
return []
|
|
434
|
+
out = []
|
|
435
|
+
for lemma in self.entries:
|
|
436
|
+
if abs(len(lemma) - len(low)) > 1 or not (set(lemma) & set(low[:2])):
|
|
437
|
+
continue
|
|
438
|
+
if _one_edit(low, lemma):
|
|
439
|
+
out.append(lemma)
|
|
440
|
+
return sorted(out)
|
|
441
|
+
|
|
442
|
+
def lookup(self, token: str) -> list[Entry]:
|
|
443
|
+
"""Entries for a token: exact spellings first, then inflected readings."""
|
|
444
|
+
low = token.lower()
|
|
445
|
+
found = list(self.entries.get(low, ()))
|
|
446
|
+
for suffix, restore, feats, cats in SUFFIX_RULES:
|
|
447
|
+
if not low.endswith(suffix) or len(low) <= len(suffix):
|
|
448
|
+
continue
|
|
449
|
+
lemma = low[: -len(suffix)] + restore
|
|
450
|
+
for entry in self.entries.get(lemma, ()):
|
|
451
|
+
if entry.cat not in cats or any(k in entry.features for k in feats):
|
|
452
|
+
continue
|
|
453
|
+
found.append(replace(entry, word=token, features=merge(entry.features, feats), weight=entry.weight - 0.5))
|
|
454
|
+
return found
|
|
455
|
+
|
|
456
|
+
def knows(self, token: str) -> bool:
|
|
457
|
+
return bool(self.lookup(token))
|
|
458
|
+
|
|
459
|
+
def bg(self, token: str) -> float:
|
|
460
|
+
low = token.lower()
|
|
461
|
+
if low in self.background:
|
|
462
|
+
return self.background[low]
|
|
463
|
+
if re.fullmatch(r"-{1,2}\w[\w-]*", low):
|
|
464
|
+
return math.log(0.9) # a command flag is not content: skipping one is free
|
|
465
|
+
return self.unseen_background
|
|
466
|
+
|
|
467
|
+
def extend(self, *entries: Entry, background: Mapping[str, float] | None = None) -> "Lexicon":
|
|
468
|
+
merged = {k: v for k, v in self.entries.items()}
|
|
469
|
+
for entry in entries:
|
|
470
|
+
key = entry.word.lower()
|
|
471
|
+
merged[key] = merged.get(key, ()) + (entry,)
|
|
472
|
+
return Lexicon(merged, {**self.background, **(background or {})}, self.unseen_background)
|
|
473
|
+
|
|
474
|
+
def without(self, *words: str) -> "Lexicon":
|
|
475
|
+
"""Drop words — a dialect that has lost one, or a domain that redefines it."""
|
|
476
|
+
gone = {w.lower() for w in words}
|
|
477
|
+
return Lexicon({k: v for k, v in self.entries.items() if k not in gone}, self.background, self.unseen_background)
|
|
478
|
+
|
|
479
|
+
@classmethod
|
|
480
|
+
def of(cls, spec: Mapping[str, Sequence[Entry]]) -> "Lexicon":
|
|
481
|
+
return cls({word.lower(): tuple(entries) for word, entries in spec.items()})
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _one_edit(a: str, b: str) -> bool:
|
|
485
|
+
"""True when one substitution, insertion, deletion or transposition maps a to b."""
|
|
486
|
+
if a == b:
|
|
487
|
+
return False
|
|
488
|
+
if len(a) == len(b):
|
|
489
|
+
diff = [i for i, (x, y) in enumerate(zip(a, b)) if x != y]
|
|
490
|
+
if len(diff) == 1:
|
|
491
|
+
return True
|
|
492
|
+
return len(diff) == 2 and diff[1] == diff[0] + 1 and a[diff[0]] == b[diff[1]] and a[diff[1]] == b[diff[0]]
|
|
493
|
+
long, short = (a, b) if len(a) > len(b) else (b, a)
|
|
494
|
+
return any(long[:i] + long[i + 1:] == short for i in range(len(long)))
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
#: Nouns and adjectives whose bare form already carries the demand.
|
|
498
|
+
UNMARKED: tuple[tuple[Mapping[str, Any], tuple[str, ...]], ...] = (
|
|
499
|
+
({"number": "singular"}, ("N", "Adj")),
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
AGREEMENT_FEATURES = ("number", "person")
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _unmarked(cat: str, wanted: Mapping[str, Any]) -> bool:
|
|
506
|
+
return any(cat in cats and all(spec.get(k) == v for k, v in wanted.items())
|
|
507
|
+
for spec, cats in UNMARKED)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _agreement_unmarked(entry: "Entry", wanted: Mapping[str, Any]) -> bool:
|
|
511
|
+
"""Whether English marks this agreement with nothing, so the bare form is the answer.
|
|
512
|
+
|
|
513
|
+
It marks subject agreement on exactly one form: the present third singular, which
|
|
514
|
+
the ``-s`` rules above produce. Plural, first or second person, and every past form
|
|
515
|
+
are all the bare word — "they share", "I share", "she shared" — so a demand for
|
|
516
|
+
agreement on any of those is satisfied by the word itself rather than refused. The
|
|
517
|
+
tense comes from the demand when it names one and from the form otherwise, because
|
|
518
|
+
"came" is past whether or not the caller said so.
|
|
519
|
+
"""
|
|
520
|
+
asked = {key: wanted[key] for key in AGREEMENT_FEATURES if key in wanted}
|
|
521
|
+
if not asked:
|
|
522
|
+
return False
|
|
523
|
+
# only the agreement is excused. Everything else demanded must already be true of
|
|
524
|
+
# the form, or "be" would answer a demand for a past tense it does not express —
|
|
525
|
+
# with the one exception that a bare verb form *is* the present tense.
|
|
526
|
+
for key, value in wanted.items():
|
|
527
|
+
if key in AGREEMENT_FEATURES or entry.features.get(key) == value:
|
|
528
|
+
continue
|
|
529
|
+
if key == "tense" and value == "present" and "tense" not in entry.features:
|
|
530
|
+
continue
|
|
531
|
+
return False
|
|
532
|
+
tense = wanted.get("tense", entry.features.get("tense", "present"))
|
|
533
|
+
marked = asked.get("number", "singular") == "singular" and asked.get("person", 3) == 3
|
|
534
|
+
return not (marked and tense == "present")
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
#: Dimensions a word is inflected in only once. "came" is already past, so no suffix
|
|
538
|
+
#: rule may add tense or aspect to it — that is how "cames" and "camed" were produced.
|
|
539
|
+
_ONCE: tuple[frozenset[str], ...] = (frozenset({"tense", "aspect"}), frozenset({"degree"}))
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _blocked(entry: Entry, feats: Mapping[str, Any]) -> set[str]:
|
|
543
|
+
"""The features on which a suffix rule may not apply to an already-inflected form."""
|
|
544
|
+
out = {key for key, value in feats.items()
|
|
545
|
+
if key in entry.features and entry.features[key] != value}
|
|
546
|
+
for dimension in _ONCE:
|
|
547
|
+
shared = dimension & set(feats)
|
|
548
|
+
if shared and dimension & set(entry.features):
|
|
549
|
+
out |= {key for key in shared if entry.features.get(key) != feats[key]}
|
|
550
|
+
return out
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def inflect(entry: Entry, wanted: Mapping[str, Any]) -> str | None:
|
|
554
|
+
"""See :func:`_inflect`; this is the cached front door (794 calls per sentence)."""
|
|
555
|
+
try:
|
|
556
|
+
return _inflect(entry, tuple(sorted(wanted.items())))
|
|
557
|
+
except TypeError: # an unhashable feature value: answer it directly
|
|
558
|
+
return _inflect.__wrapped__(entry, tuple(sorted(wanted.items(), key=repr)))
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
@lru_cache(maxsize=65536)
|
|
562
|
+
def _inflect(entry: Entry, demanded: tuple[tuple[str, Any], ...]) -> str | None:
|
|
563
|
+
"""The surface form of an entry carrying ``wanted``, by running the suffix rules backwards.
|
|
564
|
+
|
|
565
|
+
Parsing strips a suffix to find a lemma; saying something needs the other
|
|
566
|
+
direction, and using one table for both keeps "failed" and ``tense=past`` the
|
|
567
|
+
same fact rather than two lists that drift apart. Where several rules apply,
|
|
568
|
+
the one whose restored letters actually end the lemma wins ("share" + past is
|
|
569
|
+
"shared", not "shareed"), and the ``-es`` plural is reserved for the stems that
|
|
570
|
+
take it.
|
|
571
|
+
|
|
572
|
+
Three answers, not two. ``None`` means *contradicted* — "came" cannot be made
|
|
573
|
+
present, and a caller that wanted a present form must look elsewhere. The word
|
|
574
|
+
unchanged means *already so, or unmarked*: "came" carries no agreement because
|
|
575
|
+
English marks person and number on present verbs only, and "share" is how a
|
|
576
|
+
plural subject says it. A string means a suffix expressed the difference.
|
|
577
|
+
"""
|
|
578
|
+
wanted = dict(demanded)
|
|
579
|
+
if any(key in entry.features and entry.features[key] != value for key, value in wanted.items()):
|
|
580
|
+
return None
|
|
581
|
+
if all(entry.features.get(key) == value for key, value in wanted.items()):
|
|
582
|
+
return entry.word
|
|
583
|
+
word = entry.word
|
|
584
|
+
candidates: list[tuple[int, int, str]] = []
|
|
585
|
+
markable = False
|
|
586
|
+
for suffix, restore, feats, cats in SUFFIX_RULES:
|
|
587
|
+
if entry.cat not in cats or any(feats.get(k) != v for k, v in wanted.items()):
|
|
588
|
+
continue
|
|
589
|
+
markable = True # some suffix marks this, whether or not it fits *this* form
|
|
590
|
+
if _blocked(entry, feats) & set(wanted):
|
|
591
|
+
# a rule refused on a feature the caller asked for: the form already carries
|
|
592
|
+
# a different value, so it cannot carry this one either
|
|
593
|
+
return None
|
|
594
|
+
if _blocked(entry, feats):
|
|
595
|
+
continue # refused on something else: the ``-s`` rule is present-tense, and
|
|
596
|
+
# "came" declining *that* is not it declining agreement
|
|
597
|
+
if restore:
|
|
598
|
+
# "carry" -> "carries", but "say" -> "says": English swaps a final y for
|
|
599
|
+
# "ie" only after a consonant. Without that check the table inflected "say"
|
|
600
|
+
# to "saies", and the stemmer then refused "says" because nothing
|
|
601
|
+
# round-tripped to it.
|
|
602
|
+
if restore == "y" and not re.search(r"[^aeiou]y$", word):
|
|
603
|
+
continue
|
|
604
|
+
if word.endswith(restore):
|
|
605
|
+
candidates.append((2, len(suffix), word[: -len(restore)] + suffix))
|
|
606
|
+
continue
|
|
607
|
+
if suffix in ("es",) and not re.search(r"(?:s|x|z|ch|sh)$", word):
|
|
608
|
+
continue # "folders", not "folderes"
|
|
609
|
+
candidates.append((1, len(suffix), word + suffix))
|
|
610
|
+
if not candidates:
|
|
611
|
+
# The form is unmarked for something the language *does* mark by suffix, and
|
|
612
|
+
# that is the right answer: English marks person and number on present verbs
|
|
613
|
+
# only, so past "came" and plural "share" are simply how it is said. A feature
|
|
614
|
+
# no suffix marks at all (a future) is not this function's business — it needs
|
|
615
|
+
# an auxiliary, so refusing sends the caller to the production that has one,
|
|
616
|
+
# rather than saying "fail" and dropping the future on the floor.
|
|
617
|
+
if markable or _unmarked(entry.cat, wanted) or _agreement_unmarked(entry, wanted):
|
|
618
|
+
return word
|
|
619
|
+
return None
|
|
620
|
+
# a restored stem beats an appended one, and a longer suffix beats a shorter
|
|
621
|
+
# one ("failed", not "faild")
|
|
622
|
+
candidates.sort(key=lambda row: (-row[0], -row[1], row[2]))
|
|
623
|
+
return candidates[0][2]
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def words(*forms: str, cat: str, sem: Any = None, weight: float = 0.0, **features: Any) -> list[Entry]:
|
|
627
|
+
"""Several spellings of one entry: ``words("folder", "directory", cat="N", sem="folder")``."""
|
|
628
|
+
return [Entry(form, cat, dict(features), sem if sem is not None else form, weight) for form in forms]
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# ----------------------------------------------------------------- open class
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
#: Suffixes that betray a category for a word the lexicon has never seen, with the
|
|
635
|
+
#: letters the stem gets back and the features the suffix carries. A suffix is real
|
|
636
|
+
#: evidence, so a *marked* guess outranks a bare one: without that, "the north field
|
|
637
|
+
#: failed" has no way to tell which of three unknown words is the verb, and "field"
|
|
638
|
+
#: wins by position alone.
|
|
639
|
+
GUESS_SUFFIXES: tuple[tuple[str, str, str, Mapping[str, Any]], ...] = (
|
|
640
|
+
("ing", "", "V", {"aspect": "progressive"}),
|
|
641
|
+
("ied", "y", "V", {"tense": "past"}),
|
|
642
|
+
("ed", "", "V", {"tense": "past"}),
|
|
643
|
+
("ies", "y", "N", {"number": "plural"}),
|
|
644
|
+
("es", "", "N", {"number": "plural"}),
|
|
645
|
+
("s", "", "N", {"number": "plural"}),
|
|
646
|
+
("ies", "y", "V", {"number": "singular", "person": 3, "tense": "present"}),
|
|
647
|
+
("es", "", "V", {"number": "singular", "person": 3, "tense": "present"}),
|
|
648
|
+
("s", "", "V", {"number": "singular", "person": 3, "tense": "present"}),
|
|
649
|
+
("est", "", "Adj", {"degree": "superlative"}),
|
|
650
|
+
("er", "", "Adj", {"degree": "comparative"}),
|
|
651
|
+
("ly", "", "Adv", {}),
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _lost_e(stem: str) -> bool:
|
|
656
|
+
"""Whether a stripped stem is one that dropped a final "e" ("shar" <- "share").
|
|
657
|
+
|
|
658
|
+
English drops that "e" before a vowel-initial suffix and the surface form keeps no
|
|
659
|
+
record of it, which is why stripping alone produced "di" for "died" and "ow" for
|
|
660
|
+
"owes". Three shapes give it away: a vowel-final stem ("di"), a "v"-final one (no
|
|
661
|
+
English stem ends in a bare "v", so "arriv" is always "arrive"), and a stem of one
|
|
662
|
+
vowel group ending in a single consonant ("shar"). "open" has two vowel groups and
|
|
663
|
+
so keeps its own final consonant, which is what stops "opene".
|
|
664
|
+
"""
|
|
665
|
+
if not stem:
|
|
666
|
+
return False
|
|
667
|
+
if re.search(r"(?:s|x|z|ch|sh)$", stem):
|
|
668
|
+
return False # a sibilant stem takes "-es" ("box" -> "boxes"), so it lost nothing
|
|
669
|
+
if stem[-1] in "aiou" or stem[-1] == "v":
|
|
670
|
+
return True
|
|
671
|
+
# "w" and "y" after the vowel spell a diphthong rather than closing a syllable, so
|
|
672
|
+
# "show" keeps its own shape ("showed", not "showe" + "d"). The round-trip check
|
|
673
|
+
# cannot settle this one: *both* stems inflect back to "showed", so it can only
|
|
674
|
+
# reject an inconsistent stem, never choose between two consistent ones.
|
|
675
|
+
return bool(re.fullmatch(r"[^aeiou]*[aeiou][^aeiouwy]", stem))
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _stems(token: str, suffix: str, restore: str, cat: str, feats: Mapping[str, Any]) -> list[str]:
|
|
679
|
+
"""Stems that could have produced ``token``, best first, each verified by :func:`inflect`.
|
|
680
|
+
|
|
681
|
+
A stem is only accepted if running the *same* suffix table forward over it gives
|
|
682
|
+
back the word that was actually heard. That makes stemming and inflection one
|
|
683
|
+
verified pair rather than two rules that drift: "ow" is rejected for "owes"
|
|
684
|
+
because it would have been said "ows", and "carr" because it would have been
|
|
685
|
+
"carred". Where several stems survive, the one that restored letters wins.
|
|
686
|
+
"""
|
|
687
|
+
base = token[: -len(suffix)]
|
|
688
|
+
proposals = [(3, base + restore)] if restore else []
|
|
689
|
+
if not restore:
|
|
690
|
+
if _lost_e(base):
|
|
691
|
+
proposals.append((2, base + "e"))
|
|
692
|
+
proposals.append((1, base))
|
|
693
|
+
out: list[tuple[int, str]] = []
|
|
694
|
+
for tier, stem in proposals:
|
|
695
|
+
if not stem:
|
|
696
|
+
continue
|
|
697
|
+
if inflect(Entry(stem, cat, {}, stem), dict(feats)) == token:
|
|
698
|
+
out.append((tier, stem))
|
|
699
|
+
out.sort(key=lambda row: (-row[0], row[1]))
|
|
700
|
+
return [stem for _, stem in out]
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
@dataclass(frozen=True)
|
|
704
|
+
class OpenClass:
|
|
705
|
+
"""How an unknown token may still enter the grammar.
|
|
706
|
+
|
|
707
|
+
``sem="entity"`` makes an :class:`Entity` of ``kind``; ``sem="word"`` makes the
|
|
708
|
+
stem itself the meaning, which is what a noun, verb or adjective needs. With
|
|
709
|
+
``morphology`` the suffix decides the category and contributes its features, so
|
|
710
|
+
"failed" can be a past-tense verb even though no lexicon lists it.
|
|
711
|
+
|
|
712
|
+
Entries produced this way carry ``guessed=True`` and a low weight, so any real
|
|
713
|
+
lexical entry outranks them and a caller can see what was guessed at.
|
|
714
|
+
"""
|
|
715
|
+
|
|
716
|
+
pattern: str
|
|
717
|
+
cat: str
|
|
718
|
+
kind: str = "name"
|
|
719
|
+
weight: float = -0.2 # a reading whose suffix fits the category
|
|
720
|
+
sem: str = "entity"
|
|
721
|
+
features: Mapping[str, Any] = field(default_factory=dict)
|
|
722
|
+
morphology: bool = False
|
|
723
|
+
bare_weight: float | None = None # an unmarked reading; defaults just below `weight`
|
|
724
|
+
|
|
725
|
+
@staticmethod
|
|
726
|
+
def of(spec: "OpenClass | tuple[str, str, str]") -> "OpenClass":
|
|
727
|
+
return spec if isinstance(spec, OpenClass) else OpenClass(spec[0], spec[1], spec[2])
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def guess_entries(token: str, spec: OpenClass) -> list[Entry]:
|
|
731
|
+
"""Readings an unknown token can take under one open-class rule."""
|
|
732
|
+
text = _unquote(token)
|
|
733
|
+
if not spec.morphology:
|
|
734
|
+
sem = Entity(spec.kind, text) if spec.sem == "entity" else text.lower()
|
|
735
|
+
return [Entry(token, spec.cat, {"number": "singular", "guessed": True, **dict(spec.features)}, sem, spec.weight)]
|
|
736
|
+
low = text.lower()
|
|
737
|
+
out: list[Entry] = []
|
|
738
|
+
best: dict[tuple[str, tuple], str] = {}
|
|
739
|
+
for suffix, restore, cat, feats in GUESS_SUFFIXES:
|
|
740
|
+
if cat != spec.cat or not low.endswith(suffix) or len(low) <= len(suffix) + 1:
|
|
741
|
+
continue
|
|
742
|
+
found = _stems(low, suffix, restore, cat, feats)
|
|
743
|
+
if not found:
|
|
744
|
+
continue
|
|
745
|
+
# two rules can reach the same features by different suffixes ("owes" as -es or
|
|
746
|
+
# -s); keep the shorter stem, which is the one that stripped only the suffix
|
|
747
|
+
key = (cat, tuple(sorted(feats.items())))
|
|
748
|
+
if key not in best or len(found[0]) < len(best[key]):
|
|
749
|
+
best[key] = found[0]
|
|
750
|
+
for (cat, feats), stem in best.items():
|
|
751
|
+
out.append(Entry(token, cat, {"guessed": True, **dict(feats), **dict(spec.features)}, stem, spec.weight))
|
|
752
|
+
#: the bare form stays available, but a suffix that fits is the better guess —
|
|
753
|
+
#: otherwise "arrived" enters as a tenseless predicate and the past is lost
|
|
754
|
+
bare = spec.bare_weight if spec.bare_weight is not None else spec.weight - 0.2
|
|
755
|
+
out.append(Entry(token, spec.cat, {"number": "singular", "guessed": True, **dict(spec.features)}, low, bare))
|
|
756
|
+
return out
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
# --------------------------------------------------------------------- grammar
|
|
760
|
+
|
|
761
|
+
_QUOTES = {"'": "'", '"': '"', "`": "`", "“": "”", "‘": "’"}
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _unquote(token: str) -> str:
|
|
765
|
+
if len(token) >= 2 and token[0] in _QUOTES and token[-1] == _QUOTES[token[0]]:
|
|
766
|
+
return token[1:-1]
|
|
767
|
+
return token
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _sem_key(value: Any) -> Any:
|
|
771
|
+
"""How :func:`generate._same` distinguishes meanings, as a hashable key (None: cannot)."""
|
|
772
|
+
if isinstance(value, Entity):
|
|
773
|
+
return ("entity", value.text.lower())
|
|
774
|
+
try:
|
|
775
|
+
hash(value)
|
|
776
|
+
except TypeError:
|
|
777
|
+
return None
|
|
778
|
+
return ("value", value)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
@dataclass(frozen=True)
|
|
782
|
+
class Grammar:
|
|
783
|
+
productions: tuple[Production, ...] = ()
|
|
784
|
+
lexicon: Lexicon = field(default_factory=Lexicon)
|
|
785
|
+
start: tuple[str, ...] = ("S",) # categories that may cover a whole utterance
|
|
786
|
+
#: token shapes that may enter as open-class entities even when unknown:
|
|
787
|
+
#: (regex, category, entity kind). This is where file names and proper names get
|
|
788
|
+
#: in without being listed.
|
|
789
|
+
open_class: tuple[Any, ...] = () # OpenClass, or a bare (regex, cat, kind) tuple
|
|
790
|
+
#: Words no constituent may span. "then" sequences two requests, so letting a
|
|
791
|
+
#: modifier attach across it turns "make a folder then go to documents" into one
|
|
792
|
+
#: request with a destination.
|
|
793
|
+
barriers: tuple[str, ...] = ("then", "afterwards", ".", ";", "?", "!")
|
|
794
|
+
name: str = "grammar"
|
|
795
|
+
|
|
796
|
+
def __post_init__(self) -> None:
|
|
797
|
+
by_lhs: dict[str, list[Production]] = defaultdict(list)
|
|
798
|
+
for prod in self.productions:
|
|
799
|
+
by_lhs[prod.lhs.name].append(prod)
|
|
800
|
+
object.__setattr__(self, "_by_lhs", dict(by_lhs))
|
|
801
|
+
specs = [OpenClass.of(spec) for spec in self.open_class]
|
|
802
|
+
object.__setattr__(self, "_open", tuple((re.compile(spec.pattern), spec) for spec in specs))
|
|
803
|
+
|
|
804
|
+
def by_lhs(self, name: str) -> list[Production]:
|
|
805
|
+
return getattr(self, "_by_lhs").get(name, [])
|
|
806
|
+
|
|
807
|
+
def categories(self) -> set[str]:
|
|
808
|
+
return set(getattr(self, "_by_lhs"))
|
|
809
|
+
|
|
810
|
+
def entries_by_cat(self, cat: str) -> tuple[Entry, ...]:
|
|
811
|
+
"""Every entry of a category, indexed on first use (generation asks by category)."""
|
|
812
|
+
index = getattr(self, "_by_cat", None)
|
|
813
|
+
if index is None:
|
|
814
|
+
index = defaultdict(list)
|
|
815
|
+
for entries in self.lexicon.entries.values():
|
|
816
|
+
for entry in entries:
|
|
817
|
+
index[entry.cat].append(entry)
|
|
818
|
+
index = {k: tuple(v) for k, v in index.items()}
|
|
819
|
+
object.__setattr__(self, "_by_cat", index)
|
|
820
|
+
return index.get(cat, ())
|
|
821
|
+
|
|
822
|
+
def entries_saying(self, cat: str, meaning: Any) -> tuple[Entry, ...]:
|
|
823
|
+
"""Entries of a category that could *mean* this, indexed on first use.
|
|
824
|
+
|
|
825
|
+
Generation asks "what words of category C mean M?" for every daughter of every
|
|
826
|
+
candidate production, and answering it by scanning every entry of C was 13.3 s
|
|
827
|
+
of a 24.9 s run — the copula alone is six entries among some sixty verbs. The
|
|
828
|
+
index is keyed the way :func:`generate._same` compares: an entity matches an
|
|
829
|
+
entity with the same text, and anything else matches an equal value. Entries
|
|
830
|
+
whose meaning is not hashable stay in a bucket that is always scanned, so the
|
|
831
|
+
answer is the same set either way.
|
|
832
|
+
"""
|
|
833
|
+
index = getattr(self, "_by_cat_sem", None)
|
|
834
|
+
if index is None:
|
|
835
|
+
index, loose = {}, defaultdict(list)
|
|
836
|
+
for entries in self.lexicon.entries.values():
|
|
837
|
+
for entry in entries:
|
|
838
|
+
key = _sem_key(entry.sem)
|
|
839
|
+
if key is None:
|
|
840
|
+
loose[entry.cat].append(entry)
|
|
841
|
+
else:
|
|
842
|
+
index.setdefault((entry.cat, key), []).append(entry)
|
|
843
|
+
index = {k: tuple(v) for k, v in index.items()}
|
|
844
|
+
object.__setattr__(self, "_by_cat_sem", index)
|
|
845
|
+
object.__setattr__(self, "_loose_sem", {k: tuple(v) for k, v in loose.items()})
|
|
846
|
+
key = _sem_key(meaning)
|
|
847
|
+
found = index.get((cat, key), ()) if key is not None else ()
|
|
848
|
+
loose = getattr(self, "_loose_sem").get(cat, ())
|
|
849
|
+
if not loose:
|
|
850
|
+
return found if key is not None else self.entries_by_cat(cat)
|
|
851
|
+
if key is None:
|
|
852
|
+
return self.entries_by_cat(cat)
|
|
853
|
+
return found + loose
|
|
854
|
+
|
|
855
|
+
def entries_for(self, token: str) -> list[Entry]:
|
|
856
|
+
"""Lexical readings of a token, plus open-class readings for unknown shapes."""
|
|
857
|
+
found = self.lexicon.lookup(token)
|
|
858
|
+
if not found:
|
|
859
|
+
for lemma in self.lexicon.near(token):
|
|
860
|
+
found.extend(replace(e, word=token, weight=e.weight - 1.2) for e in self.lexicon.entries[lemma])
|
|
861
|
+
known = bool(found)
|
|
862
|
+
for pattern, spec in getattr(self, "_open"):
|
|
863
|
+
# a word the lexicon knows keeps its own readings: otherwise "that file"
|
|
864
|
+
# reads as a thing called "that", and "no" as a name
|
|
865
|
+
if known and spec.cat in ("Name", "N", "V", "Adj", "Adv"):
|
|
866
|
+
continue
|
|
867
|
+
if pattern.fullmatch(token) and not any(e.cat == spec.cat for e in found):
|
|
868
|
+
found.extend(guess_entries(token, spec))
|
|
869
|
+
return found
|
|
870
|
+
|
|
871
|
+
def extend(self, *, productions: Iterable[Production] = (), lexicon: Lexicon | None = None,
|
|
872
|
+
entries: Iterable[Entry] = (), background: Mapping[str, float] | None = None,
|
|
873
|
+
start: Sequence[str] | None = None, open_class: Sequence[tuple[str, str, str]] | None = None,
|
|
874
|
+
name: str | None = None) -> "Grammar":
|
|
875
|
+
lex = lexicon or self.lexicon
|
|
876
|
+
if entries or background:
|
|
877
|
+
lex = lex.extend(*entries, background=background)
|
|
878
|
+
return Grammar(
|
|
879
|
+
productions=self.productions + tuple(productions),
|
|
880
|
+
lexicon=lex,
|
|
881
|
+
start=tuple(start) if start is not None else self.start,
|
|
882
|
+
open_class=tuple(open_class) if open_class is not None else self.open_class,
|
|
883
|
+
barriers=self.barriers,
|
|
884
|
+
name=name or self.name,
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
__all__ = [
|
|
889
|
+
"ABSENT", "Ask", "Attach", "Bindings", "Build", "Cat", "Coord", "Ent", "Entry", "FVar", "GUESS_SUFFIXES", "Grammar",
|
|
890
|
+
"Head", "Lexicon", "OpenClass", "guess_entries",
|
|
891
|
+
"Lit", "Locative", "Merge", "Order", "Production", "Qualify", "Sem", "SemRef", "SUFFIX_RULES", "Terminal",
|
|
892
|
+
"build_sem", "ground", "inflect", "merge", "parse_cat", "production", "rename", "resolve", "unify", "words",
|
|
893
|
+
]
|