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,574 @@
1
+ """Saying a meaning out loud, with the grammar that reads it.
2
+
3
+ Generation inverts each production's semantic spec: given the mother's meaning,
4
+ work out what each daughter would have to mean, then realise the daughters. The
5
+ specs are data, so this is a search over the *same* productions the parser used
6
+ rather than a second grammar that has to be kept in step — the failure mode this
7
+ module exists to avoid is a speaker and a listener that quietly disagree.
8
+
9
+ Coverage is partial by construction: a production whose spec cannot be inverted
10
+ is skipped, and if nothing realises the meaning the caller gets ``None`` rather
11
+ than an approximation. :func:`round_trip` is the test that matters — say it, read
12
+ it back, and check the meaning survived.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from typing import Any, Iterable, Mapping, Sequence
19
+
20
+ from .grammar import (
21
+ ABSENT, Ask, Attach, Build, Cat, Coord, Ent, Entry, FVar, Grammar, Head, Lit, Locative, Merge, Order, Production,
22
+ Qualify, Terminal, inflect,
23
+ )
24
+ from .semantics import Entity, Frame, Question, Request
25
+
26
+
27
+ class Any_:
28
+ """A daughter whose meaning the mother does not constrain (a determiner, say)."""
29
+
30
+ def __repr__(self) -> str:
31
+ return "ANY"
32
+
33
+
34
+ ANY = Any_()
35
+
36
+ #: features that reach the meaning through inflection or a function word, and so
37
+ #: must be demanded of a daughter rather than produced by a production
38
+ GRAMMATICAL = ("tense", "aspect", "number", "degree", "person")
39
+
40
+ #: what a subject imposes on its verb. English marks agreement on the verb, so these
41
+ #: are demanded of a verbal daughter and never of the noun phrase that supplied them.
42
+ AGREEMENT = ("number", "person")
43
+
44
+ #: categories that can express agreement. Demanding person of a name would make the
45
+ #: name unsayable, since no rule inflects one.
46
+ AGREES = ("V", "Aux", "VP")
47
+
48
+ #: The one feature a production may state without the meaning carrying it. Declarative
49
+ #: is the unmarked mood, so a bare frame is a statement. Every other feature a
50
+ #: production states — a negation, a perfect, a repair mark — must be in the meaning, or
51
+ #: generation invents it: that is how "no one 'm share grain" happened, and it is what
52
+ #: lets a production be readable but unsayable ("plenty food").
53
+ DEFAULTED = ("mood",)
54
+
55
+ #: features a word *asserts* by carrying them. Using a form that carries one the
56
+ #: meaning does not have adds to the meaning, so such a form is refused rather than
57
+ #: merely dispreferred: a tenseless frame said as "gave" claims a past that nobody
58
+ #: said, which is how "should gave food" survived a fewest-words tie against "should
59
+ #: give food". Number and degree are not on this list — an unmarked noun is still
60
+ #: singular, and nothing is claimed by saying so.
61
+ OVERSTATES = ("tense", "aspect", "polarity", "modality")
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class Need:
66
+ """What a daughter must mean, and which grammatical features it must carry."""
67
+
68
+ meaning: Any = ANY
69
+ features: tuple[tuple[str, Any], ...] = ()
70
+
71
+
72
+ def realize(grammar: Grammar, meaning: Any, *, cat: str | None = None, depth: int = 8) -> str | None:
73
+ """The best surface string for a meaning, or ``None`` if the grammar cannot say it."""
74
+ cats = [cat] if cat else list(_default_cats(meaning, grammar))
75
+ best: _Said | None = None
76
+ memo: dict = {}
77
+ for name in cats:
78
+ got = _realize(grammar, name, Need(meaning), depth, memo)
79
+ if got is not None and (best is None or _better(got, best)):
80
+ best = got
81
+ return best.text if best else None
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class _Said:
86
+ score: float
87
+ tokens: int
88
+ text: str
89
+
90
+
91
+ def _better(a: _Said, b: _Said) -> bool:
92
+ """Fewest words wins; ties go to the higher-scoring derivation.
93
+
94
+ Length first, not score first: production weights exist to *rank parses*, and
95
+ several are positive, so a score-first search is rewarded for piling on structure
96
+ ("the field called north had fail and been..."). Every candidate that reaches here
97
+ already accounts for every role and feature — that is what ``_invert`` guarantees —
98
+ so the shortest one is the one that says exactly the meaning and nothing else.
99
+ """
100
+ return (-a.tokens, a.score) > (-b.tokens, b.score)
101
+
102
+
103
+ def _default_cats(meaning: Any, grammar: Grammar) -> Iterable[str]:
104
+ if isinstance(meaning, Request):
105
+ return ("IMP",)
106
+ if isinstance(meaning, Question):
107
+ return ("Q",)
108
+ if isinstance(meaning, Frame):
109
+ return ("S", "VP")
110
+ return ("NP",)
111
+
112
+
113
+ def _realize(grammar: Grammar, cat: str, need: Need, depth: int,
114
+ memo: dict | None = None) -> _Said | None:
115
+ """The best way to say ``need`` as a ``cat``, or None.
116
+
117
+ One six-word sentence asked this 24,103 times for **74** distinct (category, need)
118
+ pairs, because every candidate production re-explores the same daughters. So the
119
+ answers are memoised for the duration of one :func:`realize` call.
120
+
121
+ That memo is only sound if a result does not depend on *where* it was reached from,
122
+ which is why the search no longer carries a set of pairs already on the stack. That
123
+ set was there to stop left recursion, and the depth limit already does: depth falls
124
+ by one at every level, so ``VP -> VP PP`` unwinds on its own. With the stack gone,
125
+ an answer depends on nothing but the category, the need and the depth remaining —
126
+ so those three are the key, and the memo is exact rather than approximate.
127
+ """
128
+ said, _ = _search(grammar, cat, need, depth, {} if memo is None else memo)
129
+ return said
130
+
131
+
132
+ def _search(grammar: Grammar, cat: str, need: Need, depth: int,
133
+ memo: dict) -> tuple[_Said | None, int]:
134
+ """The best way to say this, and how many levels of depth that derivation used.
135
+
136
+ The depth remaining is part of what an answer depends on, so it is part of the key.
137
+ But an answer that used three levels is the same answer at every depth of three or
138
+ more, and reporting the depth *used* is what lets one computation serve them all —
139
+ without it the same sentence was rebuilt once per depth.
140
+ """
141
+ if depth <= 0:
142
+ return None, 0
143
+ try:
144
+ base: Any = (cat, need)
145
+ hash(base)
146
+ except TypeError: # a meaning that is not hashable still needs a stable key
147
+ base = (cat, repr(need))
148
+ found = memo.get(base)
149
+ if found is not None and found[1] <= depth:
150
+ return found
151
+ exact = memo.get((base, depth))
152
+ if exact is not None:
153
+ return exact
154
+ best: _Said | None = None
155
+ used = 0
156
+
157
+ for score, word in _lexical(grammar, cat, need):
158
+ said = _Said(score, 1, word)
159
+ if best is None or _better(said, best):
160
+ best, used = said, 1
161
+
162
+ for prod in grammar.by_lhs(cat):
163
+ needs = _invert(prod, need, grammar)
164
+ if needs is None:
165
+ continue
166
+ pieces: list[str] = []
167
+ score, tokens, ok, deepest = prod.weight, 0, True, 0
168
+ for i, symbol in enumerate(prod.rhs):
169
+ if isinstance(symbol, Terminal):
170
+ pieces.append(symbol.word)
171
+ tokens += 1
172
+ continue
173
+ sub = needs.get(i, Need())
174
+ # a prohibition is checked against the daughter's *meaning*: the tense that
175
+ # would make "ought gave" is carried by the complement frame, not demanded
176
+ # of it, so refusing the demand would not have caught it
177
+ if any(v is ABSENT and _carries(sub.meaning, k) for k, v in symbol.features.items()):
178
+ ok = False
179
+ break
180
+ # the production's own category features are a constraint on the daughter,
181
+ # prohibitions included: a forbidden feature may sit on the *entry* rather
182
+ # than the meaning ("heaps" is a partitive), so the demand has to reach the
183
+ # lexicon rather than being checked here and dropped
184
+ fixed = {k: v for k, v in symbol.features.items() if not isinstance(v, FVar)}
185
+ if fixed:
186
+ sub = Need(sub.meaning, tuple(sorted({**dict(sub.features), **fixed}.items())))
187
+ got, sub_used = _search(grammar, symbol.name, sub, depth - 1, memo)
188
+ if got is None:
189
+ ok = False
190
+ break
191
+ deepest = max(deepest, sub_used)
192
+ score += got.score
193
+ tokens += got.tokens
194
+ pieces.append(got.text)
195
+ if ok:
196
+ said = _Said(score, tokens, " ".join(p for p in pieces if p))
197
+ if best is None or _better(said, best):
198
+ best, used = said, deepest + 1
199
+ answer = (best, used)
200
+ memo[(base, depth)] = answer
201
+ if best is not None:
202
+ held = memo.get(base)
203
+ if held is None or held[0] is None or _better(best, held[0]) or (
204
+ best == held[0] and used < held[1]):
205
+ memo[base] = answer
206
+ return answer
207
+
208
+
209
+ def _lexical(grammar: Grammar, cat: str, need: Need) -> list[tuple[float, str]]:
210
+ """Surface forms of this category that satisfy the need, inflecting when required."""
211
+ wanted = dict(need.features)
212
+ listed: list[tuple[float, str]] = []
213
+ derived: list[tuple[float, str]] = []
214
+ candidates = (grammar.entries_by_cat(cat) if need.meaning is ANY
215
+ else grammar.entries_saying(cat, need.meaning))
216
+ for entry in candidates:
217
+ if need.meaning is not ANY and not _same(entry.sem, need.meaning):
218
+ continue
219
+ if any(k in entry.features and k not in wanted for k in OVERSTATES):
220
+ continue # the form claims more than the meaning does
221
+ if any(v is ABSENT and k in entry.features for k, v in wanted.items()):
222
+ continue # a forbidden feature: "heaps bread" needs its "of"
223
+ missing = {k: v for k, v in wanted.items()
224
+ if v is not ABSENT and entry.features.get(k) != v}
225
+ if not missing:
226
+ listed.append((entry.weight, entry.word))
227
+ continue
228
+ form = inflect(entry, missing)
229
+ if form is not None:
230
+ derived.append((entry.weight, form)) # inflection is free: it is the same word
231
+ # a listed form beats a derived one: the lexicon has "came", so the suffix rules
232
+ # are not asked to invent "comed"
233
+ out = listed or derived
234
+ out.extend(_open_class(grammar, cat, need))
235
+ return sorted(out, key=lambda o: (-o[0], o[1]))
236
+
237
+
238
+ def _open_class(grammar: Grammar, cat: str, need: Need) -> list[tuple[float, str]]:
239
+ """A name, path, literal — or a word the lexicon never had — is said by writing it.
240
+
241
+ The open-class rules that let an unknown word *in* also let it back *out*, which is
242
+ what keeps a village's own vocabulary sayable: a predicate like ``fail`` that no
243
+ lexicon lists is still inflected to "failed" by the shared suffix table.
244
+ """
245
+ meaning = need.meaning
246
+ wanted = dict(need.features)
247
+ out: list[tuple[float, str]] = []
248
+ for pattern, spec in getattr(grammar, "_open", ()):
249
+ if spec.cat != cat:
250
+ continue
251
+ if spec.sem == "word" and isinstance(meaning, str):
252
+ # the bare form already carries the unmarked features (a noun is singular,
253
+ # a verb is tenseless), so only the rest has to be inflected — but only for
254
+ # what was not demanded, or the default contradicts the demand and a plural
255
+ # noun becomes unsayable ("the fields are empty" had no way to be said)
256
+ defaults = {"number": "singular"} if cat in ("N", "Adj") and "number" not in wanted else {}
257
+ entry = Entry(meaning, cat, {**defaults, **dict(spec.features)}, meaning, spec.weight)
258
+ if any(v is ABSENT and k in entry.features for k, v in wanted.items()):
259
+ continue
260
+ missing = {k: v for k, v in wanted.items()
261
+ if v is not ABSENT and entry.features.get(k) != v}
262
+ form = meaning if not missing else inflect(entry, missing)
263
+ if form is not None and pattern.fullmatch(form):
264
+ out.append((spec.weight, form))
265
+ continue
266
+ if not isinstance(meaning, Entity) or spec.kind != meaning.kind:
267
+ continue
268
+ text = f"'{meaning.text}'" if spec.kind == "literal" else meaning.text
269
+ if pattern.fullmatch(text):
270
+ out.append((spec.weight, text))
271
+ return out
272
+
273
+
274
+ def _agreement(value: Any) -> dict[str, Any]:
275
+ """The person and number a referring expression imposes on its verb.
276
+
277
+ Parsing gets this from unification: ``NP[number=?n] VP[number=?n]`` ties the two
278
+ together and the words supply the value. Generation has the opposite problem — the
279
+ value has to come from the entity being talked about, and a name carries no
280
+ features at all, though it is third person singular for every purpose here.
281
+ """
282
+ if isinstance(value, (Request, Question)):
283
+ value = value.frame
284
+ if not isinstance(value, Entity):
285
+ return {}
286
+ out = {key: value.features[key] for key in AGREEMENT if key in value.features}
287
+ out.setdefault("number", "singular")
288
+ out.setdefault("person", 3)
289
+ return out
290
+
291
+
292
+ def _carries(meaning: Any, key: str) -> bool:
293
+ """Whether a meaning already carries a feature a daughter is forbidden to carry."""
294
+ if isinstance(meaning, (Request, Question)):
295
+ meaning = meaning.frame
296
+ return key in getattr(meaning, "features", {})
297
+
298
+
299
+ def _same(a: Any, b: Any) -> bool:
300
+ if isinstance(a, Entity) and isinstance(b, Entity):
301
+ return a.text.lower() == b.text.lower()
302
+ return a == b
303
+
304
+
305
+ def _invert(prod: Production, need: Need, grammar: Grammar) -> dict[int, Need] | None:
306
+ """What each daughter must mean for this production to produce ``need``."""
307
+ target = need.meaning
308
+ sem = prod.sem
309
+ lift = {target_feature: (index, source) for target_feature, index, source in getattr(sem, "lift", ())}
310
+ needs: dict[int, Need] = {}
311
+ extra: dict[int, dict[str, Any]] = {}
312
+
313
+ def demand(index: int, meaning: Any = ANY, **features: Any) -> None:
314
+ current = needs.get(index, Need())
315
+ merged = dict(current.features) | extra.get(index, {}) | features
316
+ needs[index] = Need(meaning if meaning is not ANY else current.meaning, tuple(sorted(merged.items())))
317
+
318
+ def take_lift(features: Mapping[str, Any]) -> dict[str, Any] | None:
319
+ """Route features that come from inflection to the daughter that carries them."""
320
+ left = dict(features)
321
+ for name, (index, source) in lift.items():
322
+ if name in left:
323
+ extra.setdefault(index, {})[source] = left.pop(name)
324
+ return left
325
+
326
+ def agree() -> None:
327
+ """Route agreement along the grammar's own agreement variables.
328
+
329
+ A variable shared by two symbols is the grammar saying they agree; shared with
330
+ the mother, that it passes through. The whole bundle travels rather than just
331
+ the key the variable happens to be written on, because ``?n`` between a subject
332
+ and its verb is shorthand for subject-verb agreement, and English agreement is
333
+ person *and* number. Without this the copula was chosen alphabetically: "Nise
334
+ am hungry".
335
+ """
336
+ slots: dict[str, list[int]] = {}
337
+ for index, symbol in enumerate(prod.rhs):
338
+ if isinstance(symbol, Cat):
339
+ for value in symbol.features.values():
340
+ if isinstance(value, FVar):
341
+ slots.setdefault(value.name, []).append(index)
342
+ mother = {k: v for k, v in need.features if k in AGREEMENT}
343
+ lhs_vars = {v.name for v in prod.lhs.features.values() if isinstance(v, FVar)}
344
+ for name, indices in slots.items():
345
+ found = dict(mother) if name in lhs_vars else {}
346
+ source = None
347
+ for index in indices:
348
+ got = _agreement(needs[index].meaning) if index in needs else {}
349
+ if got:
350
+ found, source = {**got, **found}, index
351
+ break
352
+ if not found:
353
+ continue
354
+ for index in indices:
355
+ if index == source or not isinstance(prod.rhs[index], Cat):
356
+ continue
357
+ if prod.rhs[index].name not in AGREES:
358
+ continue
359
+ current = needs.get(index, Need())
360
+ merged = dict(current.features) | found
361
+ needs[index] = Need(current.meaning, tuple(sorted(merged.items())))
362
+
363
+ def done() -> dict[int, Need]:
364
+ """Materialise the demands, including features routed by ``take_lift``."""
365
+ for index, feats in extra.items():
366
+ current = needs.get(index, Need())
367
+ merged = dict(current.features) | feats
368
+ needs[index] = Need(current.meaning, tuple(sorted(merged.items())))
369
+ agree()
370
+ return needs
371
+
372
+ if target is ANY:
373
+ return {}
374
+
375
+ # A feature lifted from the *head* daughter is inflection ("failed"); one lifted
376
+ # from another daughter is a function word ("did fail"). If the meaning lacks the
377
+ # feature, that word would be invented — "no one 'm share grain" — so the
378
+ # production is refused. Inflection-carrying productions stay available, which is
379
+ # what a tenseless frame needs.
380
+ head = getattr(sem, "predicate_from", None)
381
+ if head is None:
382
+ head = getattr(sem, "index", None)
383
+ if lift and head is not None and any(index != head for _, (index, _) in lift.items()):
384
+ carried = target.frame.features if isinstance(target, (Request, Question)) else getattr(target, "features", {})
385
+ if not any(name in carried for name in lift):
386
+ return None
387
+
388
+ if isinstance(sem, Head):
389
+ if isinstance(sem.index, int):
390
+ demand(sem.index, target, **dict(need.features))
391
+ return done()
392
+ return None
393
+
394
+ if isinstance(sem, Lit):
395
+ return {} if _same(sem.value, target) else None
396
+
397
+ if isinstance(sem, Order):
398
+ if not isinstance(target, Request) or not isinstance(sem.index, int):
399
+ return None
400
+ demand(sem.index, Frame(target.frame.predicate, target.frame.roles,
401
+ {k: v for k, v in target.frame.features.items() if k != "mood"}))
402
+ return done()
403
+
404
+ if isinstance(sem, Ask):
405
+ if not isinstance(target, Question):
406
+ return None
407
+ inner = Frame(target.frame.predicate, target.frame.roles,
408
+ {k: v for k, v in target.frame.features.items() if k != "mood"})
409
+ if sem.asked_from is not None and isinstance(sem.asked_from, int):
410
+ demand(sem.asked_from, target.asked)
411
+ elif sem.asked != target.asked:
412
+ return None
413
+ if isinstance(sem.index, int):
414
+ demand(sem.index, inner)
415
+ return done()
416
+ nested = _invert(Production(prod.lhs, prod.rhs, sem.index, prod.weight, prod.name), Need(inner), grammar)
417
+ if nested is None:
418
+ return None
419
+ for i, sub in nested.items():
420
+ demand(i, sub.meaning, **dict(sub.features))
421
+ return done()
422
+
423
+ if isinstance(sem, Coord):
424
+ if not isinstance(target, tuple) or len(target) != len(sem.indices):
425
+ return None
426
+ for ref, value in zip(sem.indices, target):
427
+ if not isinstance(ref, int):
428
+ return None
429
+ demand(ref, value)
430
+ return done()
431
+
432
+ if isinstance(sem, Ent):
433
+ if not isinstance(target, Entity):
434
+ return None
435
+ features = dict(target.features)
436
+ for key, ref in sem.features_from:
437
+ if not isinstance(ref, int) or key not in features:
438
+ return None
439
+ demand(ref, features.pop(key))
440
+ for key, value in sem.features:
441
+ if features.pop(key, value if key in DEFAULTED else None) != value:
442
+ return None
443
+ left = take_lift(features)
444
+ if left: # features this production cannot express
445
+ return None
446
+ return done()
447
+
448
+ if isinstance(sem, Qualify):
449
+ if not isinstance(sem.index, int):
450
+ return None
451
+ if isinstance(target, Entity):
452
+ features = dict(target.features)
453
+ for key, ref in sem.features_from:
454
+ if not isinstance(ref, int) or key not in features:
455
+ return None
456
+ demand(ref, features.pop(key))
457
+ for key, value in sem.features:
458
+ if features.pop(key, value if key in DEFAULTED else None) != value:
459
+ return None
460
+ left = take_lift(features)
461
+ if left is None:
462
+ return None
463
+ demand(sem.index, Entity(target.kind, target.text, left, target.ref, target.candidates))
464
+ return done()
465
+ if isinstance(target, Frame):
466
+ features = dict(target.features)
467
+ for key, ref in sem.features_from:
468
+ if not isinstance(ref, int) or key not in features:
469
+ return None
470
+ demand(ref, features.pop(key))
471
+ for key, value in sem.features:
472
+ if features.pop(key, value if key in DEFAULTED else None) != value:
473
+ return None
474
+ roles = dict(target.roles)
475
+ for role, ref in sem.roles_from:
476
+ if not isinstance(ref, int) or role not in roles:
477
+ return None
478
+ demand(ref, roles.pop(role))
479
+ left = take_lift(features)
480
+ if left is None:
481
+ return None
482
+ demand(sem.index, Frame(target.predicate, roles, left))
483
+ return done()
484
+ return None
485
+
486
+ if isinstance(sem, Attach):
487
+ if not isinstance(sem.index, int) or not isinstance(sem.modifier, int):
488
+ return None
489
+ holder = target if isinstance(target, (Frame, Entity)) else None
490
+ if holder is None:
491
+ return None
492
+ roles = dict(holder.roles) if isinstance(holder, Frame) else dict(holder.features)
493
+ markable = {e.sem for e in grammar.entries_by_cat("P")}
494
+ for role in sorted(roles):
495
+ if role in GRAMMATICAL or role in ("mood",):
496
+ continue
497
+ if role not in markable:
498
+ continue # no preposition marks it, so it is not a PP: "north field", not "field called north"
499
+ rest = {k: v for k, v in roles.items() if k != role}
500
+ base = (Frame(holder.predicate, rest, holder.features) if isinstance(holder, Frame)
501
+ else Entity(holder.kind, holder.text, rest, holder.ref, holder.candidates))
502
+ demand(sem.index, base)
503
+ demand(sem.modifier, Frame("_pp", {"role": role, "value": roles[role]}))
504
+ return done()
505
+ return None
506
+
507
+ if isinstance(sem, Locative):
508
+ if not isinstance(target, Frame) or target.predicate != sem.predicate:
509
+ return None
510
+ roles = dict(target.roles)
511
+ theme = None
512
+ if sem.theme is not None:
513
+ if not isinstance(sem.theme, int) or sem.theme_role not in roles:
514
+ return None
515
+ theme = roles.pop(sem.theme_role)
516
+ demand(sem.theme, theme)
517
+ if len(roles) != 1 or not isinstance(sem.modifier, int):
518
+ return None
519
+ role, value = next(iter(roles.items()))
520
+ demand(sem.modifier, Frame("_pp", {"role": role, "value": value}))
521
+ if take_lift(dict(target.features)):
522
+ return None
523
+ return done()
524
+
525
+ if isinstance(sem, (Build, Merge)):
526
+ if not isinstance(target, Frame):
527
+ return None
528
+ roles = dict(target.roles)
529
+ features = {k: v for k, v in target.features.items() if k != "mood"}
530
+ if isinstance(sem, Build):
531
+ if sem.predicate is not None and sem.predicate != target.predicate:
532
+ return None
533
+ if sem.predicate_from is not None:
534
+ if not isinstance(sem.predicate_from, int):
535
+ return None
536
+ demand(sem.predicate_from, target.predicate)
537
+ else:
538
+ if not isinstance(sem.index, int):
539
+ return None
540
+ for role, ref in sem.roles:
541
+ if not isinstance(ref, int) or role not in roles:
542
+ return None
543
+ demand(ref, roles.pop(role))
544
+ for key, value in sem.features:
545
+ if features.pop(key, value if key in DEFAULTED else None) != value:
546
+ return None
547
+ # a feature demanded *of* this constituent is expressed inside it: the perfect
548
+ # production asks its complement for ``VP[tense=past]``, and that tense has to
549
+ # reach the verb rather than fall off at the mother ("has arrive")
550
+ for key, value in need.features:
551
+ if key in lift and key not in features and value is not ABSENT:
552
+ features[key] = value
553
+ left = take_lift(features)
554
+ if left is None:
555
+ return None
556
+ if isinstance(sem, Merge):
557
+ demand(sem.index, Frame(target.predicate, roles, left))
558
+ return done()
559
+ if roles or left: # a Build must account for every role and feature itself
560
+ return None
561
+ return done()
562
+
563
+ return None
564
+
565
+
566
+ def round_trip(grammar: Grammar, meaning: Any, *, cat: str | None = None) -> tuple[str | None, Any]:
567
+ """Say it, read it back: the surface string and the meaning that came back."""
568
+ from .chart import understand
569
+
570
+ text = realize(grammar, meaning, cat=cat)
571
+ if text is None:
572
+ return None, None
573
+ back = understand(grammar, text)
574
+ return text, (back.meanings[0] if back.meanings else None)