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