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
tensorcode/relation.py ADDED
@@ -0,0 +1,771 @@
1
+ """Comparing two things, and returning what the comparison asks for.
2
+
3
+ A question can put two things in competition — "Which was published first, A or B?", "Are X and
4
+ Y from the same country?", "What profession do A and B have in common?" — and answering it needs
5
+ an operation that span extraction cannot perform. Measured consequence
6
+ (``docs/revival/26-selection-and-answer-type.md``): on HotpotQA an extractive answerer scores
7
+ 0.1935 on comparison questions *with the gold evidence already in hand*, identically for a
8
+ trained selector and for a perfect oracle, against 0.4958 on questions that travel through an
9
+ intermediate. Selection is not the gap. The answer to "who was born first" is one of the two
10
+ names the question offers, and to "are both X?" it is a yes or a no; neither is reliably a
11
+ substring of the evidence, so no span can be copied out to produce it.
12
+
13
+ The operation has four parts, and each can refuse:
14
+
15
+ ``read`` what relation is asked, over which two things, on what attribute.
16
+ ``values`` one value per candidate, read out of the evidence and typed.
17
+ ``apply`` the relation over those values.
18
+ ``resolve`` the three in sequence, returning what the question wants.
19
+
20
+ Refusals are named rather than guessed: a missing value says which candidate it is missing for,
21
+ and two values of different dimensions say so instead of comparing their raw numbers. The
22
+ arithmetic and the ordering are :mod:`tensorcode.quantity` and :mod:`tensorcode.temporal`; this
23
+ module reads values and picks relations, it does not do sums.
24
+
25
+ Nothing here is trained. The question side is surface rules because the grammar in
26
+ :mod:`tensorcode.language`, while it parses these questions with 0.91 coverage, does not carry a
27
+ coordination of two candidates or a comparative relation in its frames — it reads "born first"
28
+ as a description and assigns "Are both A and B American rock bands?" the predicate ``located``.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import re
34
+ from dataclasses import dataclass, field
35
+ from datetime import datetime, timezone
36
+ from enum import Enum
37
+
38
+ from .answer_type import AnswerType, Shape, asked_for, contains_words, shape
39
+ from .outcomes import Unknown
40
+ from .quantity import Quantity, compare as compare_quantities
41
+ from .records import Ref, Store
42
+ from .semantics_bridge import quantities_in_text
43
+ from .temporal import relate, tell_event
44
+
45
+
46
+ class Relation(str, Enum):
47
+ """What a comparison asks to be done with two values."""
48
+
49
+ earlier = "earlier" # which came first
50
+ later = "later" # which came last / is more recent
51
+ greater = "greater" # which is more / larger / longer
52
+ less = "less" # which is fewer / smaller / shorter
53
+ same = "same" # do they share this attribute
54
+ different = "different" # do they differ on it
55
+ both = "both" # do they both satisfy this predicate
56
+ shared = "shared" # what attribute do they have in common
57
+
58
+
59
+ #: the answer a family produces: one of the named candidates, a yes/no, or a shared value
60
+ FAMILIES = {
61
+ Relation.earlier: "which_of_order", Relation.later: "which_of_order",
62
+ Relation.greater: "which_of_magnitude", Relation.less: "which_of_magnitude",
63
+ Relation.same: "yes_no", Relation.different: "yes_no", Relation.both: "yes_no",
64
+ Relation.shared: "shared_attribute",
65
+ }
66
+
67
+ _ORDER_FIRST = re.compile(r"\b(?:first|earlier|earliest|sooner|older|elder|oldest|prior|before)\b", re.I)
68
+ _ORDER_LAST = re.compile(r"\b(?:last|later|latest|newer|younger|youngest|most recent|most recently|after)\b", re.I)
69
+ _MAGNITUDE_MORE = re.compile(r"\b(?:more|greater|larger|bigger|longer|taller|higher|heavier|most|farther|further|greatest|largest|longest)\b", re.I)
70
+ _MAGNITUDE_LESS = re.compile(r"\b(?:less|fewer|smaller|shorter|lower|least|fewest|closer|smallest)\b", re.I)
71
+ _SAME = re.compile(r"\b(?:same|alike|shared)\b", re.I)
72
+ _DIFFERENT = re.compile(r"\b(?:different|differ|distinct)\b", re.I)
73
+ _BOTH = re.compile(r"\b(?:both|either|all)\b", re.I)
74
+ _SHARED = re.compile(r"\bin common\b|\b(?:mutual|shared)\b|\b(?:have|share)s?\s+which\b|\bwhich\s+\w+(?:\s+\w+)?\s+(?:do|does)\s+(?:they|both)\b", re.I)
75
+
76
+ #: an interrogative anywhere in the question: "are both A and B located in which borough?" opens
77
+ #: with an auxiliary but asks for a borough, and answering it "yes" answers a different question
78
+ _WH = re.compile(r"\b(?:what|which|who|whose|where|when)\b", re.I)
79
+
80
+ #: a year, the value an order comparison almost always turns on
81
+ _YEAR = re.compile(r"\b(1[0-9]{3}|20[0-2][0-9])\b")
82
+ #: cues that tell which of an entity's several years is the one being compared
83
+ _ORDER_CUES = {
84
+ "born": ("born", "birth", "b."), "died": ("died", "death", "d."),
85
+ "released": ("released", "release", "premiered", "premiere", "aired", "debuted", "opened"),
86
+ "founded": ("founded", "formed", "established", "started", "begun", "began", "created", "built", "incorporated"),
87
+ "published": ("published", "publication", "printed", "issued", "wrote", "written"),
88
+ }
89
+ _STOP = {"the", "a", "an", "of", "and", "or", "in", "on", "at", "to", "for", "is", "are", "was", "were",
90
+ "which", "who", "whom", "what", "that", "this", "these", "those", "do", "does", "did", "has",
91
+ "have", "had", "both", "same", "different", "common", "mutual", "first", "last", "more", "less",
92
+ "older", "younger", "earlier", "later", "between", "by", "from", "with", "their", "they", "them",
93
+ "be", "been", "it", "its", "as", "than", "into", "about", "also", "known"}
94
+ _WORDS = re.compile(r"[A-Za-z0-9']+")
95
+ #: a capitalised run, which is how a named candidate appears in a question
96
+ _NAME = re.compile(r"\b(?:[A-Z][\w.&'’-]*|of|the|and|de|von|van|da|del|di|for|in)(?:\s+(?:[A-Z][\w.&'’-]*|of|the|and|de|von|van|da|del|di|for|in))*")
97
+
98
+
99
+ def _words(text: str) -> list[str]:
100
+ return [w.lower() for w in _WORDS.findall(text or "")]
101
+
102
+
103
+ def _stem(word: str) -> str:
104
+ """Crude singularisation. Intersections are the operation here, and "rock bands" must meet
105
+ "rock band"; a plural that does not stem costs a correct yes."""
106
+ if word.endswith("ies") and len(word) > 4:
107
+ return word[:-3] + "y"
108
+ if word.endswith("es") and len(word) > 3:
109
+ # only a sibilant plural loses the whole "es": boxes -> box, but games -> game
110
+ return word[:-2] if word[-3] in "sxzh" else word[:-1]
111
+ if word.endswith("s") and not word.endswith("ss") and len(word) > 3:
112
+ return word[:-1]
113
+ return word
114
+
115
+
116
+ def _content(text: str) -> list[str]:
117
+ return [_stem(w) for w in _words(text) if w not in _STOP and len(w) > 2]
118
+
119
+
120
+ # --------------------------------------------------------------- the question
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class Comparison:
125
+ """What a comparison question asks: a relation, over two named things, on an attribute."""
126
+
127
+ relation: Relation
128
+ candidates: tuple[str, str]
129
+ attribute: str
130
+ wants: AnswerType
131
+ question: str = ""
132
+
133
+ @property
134
+ def family(self) -> str:
135
+ return FAMILIES[self.relation]
136
+
137
+ def describe(self) -> str:
138
+ return f"{self.relation.value}({self.candidates[0]!r}, {self.candidates[1]!r}) on {self.attribute!r}"
139
+
140
+
141
+ def _relation_of(question: str) -> Relation | None:
142
+ """Which relation the surface asks for. Order is checked before magnitude because "older"
143
+ reads as an age comparison and answers with a date, and before same/different because
144
+ "Which came first, A or B?" can also contain "both"."""
145
+ low = f" {question.lower()} "
146
+ if _SHARED.search(question):
147
+ return Relation.shared
148
+ if _ORDER_FIRST.search(low):
149
+ return Relation.earlier
150
+ if _ORDER_LAST.search(low):
151
+ return Relation.later
152
+ if _MAGNITUDE_MORE.search(low):
153
+ return Relation.greater
154
+ if _MAGNITUDE_LESS.search(low):
155
+ return Relation.less
156
+ if _DIFFERENT.search(low):
157
+ return Relation.different
158
+ if _SAME.search(low):
159
+ return Relation.same
160
+ if _BOTH.search(low):
161
+ return Relation.both
162
+ return None
163
+
164
+
165
+ _CONNECTORS = ("of", "the", "and", "de", "von", "van", "da", "del", "di", "for", "in", "a", "an")
166
+ _INTERROGATIVES = ("Which", "Who", "Whose", "What", "Were", "Was", "Are", "Is", "Do", "Does", "Did",
167
+ "Has", "Have", "Had", "Between", "Both", "If", "In", "The")
168
+
169
+
170
+ _LEADING = tuple(w.lower() for w in _INTERROGATIVES[:-1]) + _CONNECTORS
171
+
172
+
173
+ def _trim(name: str) -> str:
174
+ """A name does not begin or end with a connector: "Ed Wood of the" is "Ed Wood"."""
175
+ words = name.strip(" ,.;:").split()
176
+ while len(words) > 1 and words[0].lower() in _LEADING:
177
+ words.pop(0)
178
+ while words and words[-1].lower() in _CONNECTORS:
179
+ words.pop()
180
+ return " ".join(words)
181
+
182
+
183
+ def _coordinated(question: str) -> tuple[str, str] | None:
184
+ """The two names a question coordinates: "A or B", "both A and B"."""
185
+ parts = re.split(r",?\s+\bor\b\s+|,?\s+\band\b\s+", question.strip().rstrip("?"))
186
+ if len(parts) < 2:
187
+ return None
188
+ lower = tuple(w.lower() for w in _INTERROGATIVES)
189
+ runs = [[_trim(m) for m in _NAME.findall(part) if _trim(m).lower() not in lower]
190
+ for part in (parts[0], parts[-1])]
191
+ if not runs[0] or not runs[1]:
192
+ return None
193
+ first, second = (max(r, key=len) for r in runs)
194
+ if not first or not second or first.lower() == second.lower():
195
+ return None
196
+ if len(first.split()) > 12 or len(second.split()) > 12:
197
+ return None
198
+ return (first, second)
199
+
200
+
201
+ def _best_title(half: str, titles: list[str]) -> str | None:
202
+ """The evidence title this half of a coordination names, if any.
203
+
204
+ This is what rescues a greedy capitalised run: "Kings of Leon American" contains the title
205
+ "Kings of Leon", and the title is the name of the thing being compared.
206
+ """
207
+ matches = [t for t in titles if contains_words(half, t) or contains_words(t, half)]
208
+ return max(matches, key=len) if matches else None
209
+
210
+
211
+ def _candidates(question: str, titles: tuple[str, ...] = ()) -> tuple[str, str] | None:
212
+ """The two things being compared.
213
+
214
+ Two readers. If the evidence carries titles — a document collection names its entities — the
215
+ candidates are the titles the question mentions, which is exact. Otherwise they are read off
216
+ the question's own coordination, which is where a comparison puts them. When both are
217
+ available the coordination is mapped onto the titles, which fixes a run that swallowed a word
218
+ of the predicate.
219
+ """
220
+ named = [t for t in titles if t and contains_words(question, t)]
221
+ pair = _coordinated(question)
222
+ if named and pair is not None:
223
+ mapped = [_best_title(half, named) for half in pair]
224
+ if all(mapped) and mapped[0].lower() != mapped[1].lower():
225
+ return (mapped[0], mapped[1]) # type: ignore[return-value]
226
+ if len(named) == 2:
227
+ return (named[0], named[1])
228
+ return pair
229
+
230
+
231
+ def _attribute(question: str, candidates: tuple[str, str]) -> str:
232
+ """The property the comparison is about, with the candidates and the relation words removed."""
233
+ text = question
234
+ for c in candidates:
235
+ text = re.sub(re.escape(c), " ", text, flags=re.I)
236
+ return " ".join(_content(text))
237
+
238
+
239
+ def read(question: str, titles: tuple[str, ...] = ()) -> Comparison | Unknown:
240
+ """Read a comparison off a question, or refuse and say why.
241
+
242
+ ``titles`` are the names the evidence collection uses, when it has them; they make candidate
243
+ identification exact rather than a guess at a coordination.
244
+ """
245
+ relation = _relation_of(question)
246
+ if relation is None:
247
+ return Unknown("relation_unsupported", "no comparative, same/different or in-common cue in the question")
248
+ # ``shape`` reads " or ", "both", "more than" and the comparatives; it does not read "in
249
+ # common" or "of the same", so a same/different/shared/both cue counts as a marker in its own
250
+ # right. An order or magnitude cue alone does not: "who was the first president of X and what
251
+ # did he found?" is a bridge question containing the word "first".
252
+ if relation in (Relation.both, Relation.same, Relation.different) and _WH.search(question):
253
+ # the cue says "both", but the question asks for a value rather than for a verdict
254
+ relation = Relation.shared
255
+ self_naming = shape(question) is Shape.comparison
256
+ if not self_naming and relation not in (Relation.shared, Relation.same, Relation.different, Relation.both):
257
+ return Unknown("not_a_comparison", "the question does not name its own candidates")
258
+ pair = _candidates(question, titles)
259
+ if pair is None:
260
+ return Unknown("candidates_unclear", "could not read exactly two named things being compared")
261
+ return Comparison(relation, pair, _attribute(question, pair), asked_for(question), question)
262
+
263
+
264
+ # ---------------------------------------------------------------- the evidence
265
+
266
+
267
+ @dataclass
268
+ class Values:
269
+ """One value per candidate, and what they were read from."""
270
+
271
+ values: dict[str, object] = field(default_factory=dict)
272
+ sources: dict[str, str] = field(default_factory=dict)
273
+ missing: list[str] = field(default_factory=list)
274
+
275
+
276
+ def _titles_for(candidate: str, evidence: tuple[tuple[str, str], ...]) -> list[str]:
277
+ return [t for t, _ in evidence
278
+ if t and (contains_words(t, candidate) or contains_words(candidate, t))]
279
+
280
+
281
+ def _for_candidate(candidate: str, evidence: tuple[tuple[str, str], ...]) -> list[str]:
282
+ """The sentences that speak about this candidate: its own document's, else any that name it."""
283
+ own = [s for title, s in evidence
284
+ if title and (contains_words(title, candidate) or contains_words(candidate, title))]
285
+ return own or [s for _, s in evidence if contains_words(s, candidate)]
286
+
287
+
288
+ def _cue_for(attribute: str) -> tuple[str, ...]:
289
+ """Which event an order comparison is about, so the right year is chosen among several."""
290
+ words = set(_words(attribute))
291
+ for cues in _ORDER_CUES.values():
292
+ if words & set(cues):
293
+ return cues
294
+ return ()
295
+
296
+
297
+ def year_in(sentences: list[str], attribute: str) -> tuple[int, str] | None:
298
+ """The year an order comparison turns on: the one beside the event's cue, else the earliest.
299
+
300
+ A biography states several years; "born first" is about one of them. Taking the earliest is
301
+ the right default for birth and founding (the first year a thing is mentioned in its own
302
+ article is usually its origin) and is stated as a default rather than a rule.
303
+ """
304
+ cues = _cue_for(attribute)
305
+ earliest: tuple[int, str] | None = None
306
+ nearest: tuple[int, int, str] | None = None # distance, year, sentence
307
+ for sentence in sentences:
308
+ low = sentence.lower()
309
+ spots = [m.start() for cue in cues for m in re.finditer(re.escape(cue), low)]
310
+ for match in _YEAR.finditer(sentence):
311
+ year = int(match.group(0))
312
+ if earliest is None or year < earliest[0]:
313
+ earliest = (year, sentence)
314
+ if not spots:
315
+ continue
316
+ # English puts the year after the verb ("died in 1994"), so a year that follows its
317
+ # cue is nearer than one the same distance in front of it — otherwise "(born 1930)
318
+ # died in 1994" answers a question about the death with the birth year.
319
+ gap = min((match.start() - spot) if match.start() >= spot
320
+ else (spot - match.start()) + 25 for spot in spots)
321
+ if gap <= 40 and (nearest is None or gap < nearest[0]):
322
+ nearest = (gap, year, sentence)
323
+ if nearest is not None:
324
+ return (nearest[1], nearest[2])
325
+ return earliest
326
+
327
+
328
+ #: :func:`tensorcode.semantics_bridge.quantities_in_text` reads digits only, so "a band with four
329
+ #: members" yields no quantity at all. Prose spells small numbers out, and a magnitude comparison
330
+ #: that cannot read "four" refuses on half its cases; these are substituted before the reader runs.
331
+ SPELLED = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8,
332
+ "nine": 9, "ten": 10, "eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14,
333
+ "fifteen": 15, "sixteen": 16, "seventeen": 17, "eighteen": 18, "nineteen": 19,
334
+ "twenty": 20, "thirty": 30, "forty": 40, "fifty": 50, "sixty": 60, "seventy": 70,
335
+ "eighty": 80, "ninety": 90, "hundred": 100, "thousand": 1000, "million": 1_000_000,
336
+ "billion": 1_000_000_000, "dozen": 12, "solo": 1, "duo": 2, "trio": 3, "quartet": 4,
337
+ "quintet": 5, "sextet": 6, "septet": 7, "octet": 8}
338
+ _SPELLED_RE = re.compile(r"\b(" + "|".join(SPELLED) + r")\b", re.I)
339
+
340
+
341
+ def digitise(text: str) -> str:
342
+ """Spelled-out numbers as digits, so a digit-only quantity reader can see them."""
343
+ return _SPELLED_RE.sub(lambda m: f"{SPELLED[m.group(0).lower()]:g}", text)
344
+
345
+
346
+ def references(question: str, candidates: tuple[str, str]) -> tuple[str, ...]:
347
+ """Names the question measures against, other than the two candidates.
348
+
349
+ "Which airport is closer to Washington D.C., Dulles or Gainesville Regional?" compares two
350
+ distances *to Washington*. Without this, the operation compared Dulles's distance to
351
+ Washington with Gainesville Regional's distance to Gainesville and answered confidently.
352
+ """
353
+ out = []
354
+ for run in _NAME.findall(question):
355
+ name = _trim(run)
356
+ if not name or name.lower() in tuple(w.lower() for w in _INTERROGATIVES):
357
+ continue
358
+ if any(contains_words(c, name) or contains_words(name, c) for c in candidates):
359
+ continue
360
+ out.append(name)
361
+ return tuple(out)
362
+
363
+
364
+ def _forms(text: str) -> set[str]:
365
+ return {w for word in _words(text) for w in (word, _stem(word))}
366
+
367
+
368
+ def quantities_offered(sentences: list[str], attribute: str, require: tuple[str, ...] = ()
369
+ ) -> tuple[list[tuple[Quantity, str]], list[tuple[Quantity, str]]]:
370
+ """Quantities said of a candidate: the ones the question's noun names, and the rest.
371
+
372
+ The rest are candidates for a joint choice — see :func:`pair_of_quantities` — because which
373
+ number a sentence offers is often only decidable by looking at what the *other* candidate
374
+ offers. A bare number the reader labelled "item" carries no property and is not offered.
375
+ """
376
+ if require:
377
+ sentences = [s for s in sentences if any(contains_words(s, r) for r in require)]
378
+ wanted = _forms(attribute)
379
+ matched: list[tuple[Quantity, str]] = []
380
+ offered: list[tuple[Quantity, str]] = []
381
+ for sentence in sentences:
382
+ for mention in quantities_in_text(digitise(sentence)):
383
+ q = mention.quantity
384
+ if wanted & (_forms(mention.of) | _forms(str(q.unit))):
385
+ matched.append((q, sentence))
386
+ continue
387
+ generic = q.unit.dimensionless or str(q.unit) in ("item", "")
388
+ if generic or _YEAR.fullmatch(f"{q.value:.0f}"):
389
+ continue
390
+ offered.append((q, sentence))
391
+ return matched, offered
392
+
393
+
394
+ def pair_of_quantities(a: tuple[list, list], b: tuple[list, list]
395
+ ) -> tuple[tuple[Quantity, str], tuple[Quantity, str]] | None:
396
+ """One quantity per candidate, chosen so the two are of one dimension.
397
+
398
+ "Which canal is longer, the Shinnecock or the Wiconisco?" states 4700 feet for one and 12
399
+ miles for the other, and neither sentence repeats the question's noun. What makes them the
400
+ right two numbers is that they are both lengths, and nothing else on offer is. When more than
401
+ one dimension is shared, the choice is ambiguous and the operation refuses.
402
+ """
403
+ for left, right in ((a[0], b[0]), (a[0] + a[1], b[0] + b[1])):
404
+ if left and right:
405
+ dims_a = {q.dimension for q, _ in left}
406
+ dims_b = {q.dimension for q, _ in right}
407
+ shared = dims_a & dims_b
408
+ if len(shared) == 1:
409
+ dim = shared.pop()
410
+ return (next(p for p in left if p[0].dimension == dim),
411
+ next(p for p in right if p[0].dimension == dim))
412
+ if len(shared) > 1:
413
+ return None
414
+ return None
415
+
416
+
417
+ #: demonyms and country names, for the commonest same/different attribute there is. A lexicon,
418
+ #: not a model: "were A and B from the same country?" is answered by comparing two nationality
419
+ #: words, and no amount of sentence overlap substitutes for knowing which words those are.
420
+ NATIONALITIES = (
421
+ "american", "british", "english", "scottish", "welsh", "irish", "canadian", "australian",
422
+ "new zealand", "french", "german", "italian", "spanish", "portuguese", "dutch", "belgian",
423
+ "swiss", "austrian", "swedish", "norwegian", "danish", "finnish", "icelandic", "russian",
424
+ "polish", "czech", "slovak", "hungarian", "romanian", "bulgarian", "serbian", "croatian",
425
+ "slovenian", "bosnian", "albanian", "greek", "turkish", "ukrainian", "belarusian", "estonian",
426
+ "latvian", "lithuanian", "chinese", "japanese", "korean", "indian", "pakistani", "bangladeshi",
427
+ "sri lankan", "nepali", "thai", "vietnamese", "filipino", "indonesian", "malaysian",
428
+ "singaporean", "mongolian", "iranian", "iraqi", "israeli", "lebanese", "syrian", "jordanian",
429
+ "saudi", "egyptian", "moroccan", "algerian", "tunisian", "libyan", "sudanese", "ethiopian",
430
+ "kenyan", "nigerian", "ghanaian", "senegalese", "cameroonian", "ugandan", "tanzanian",
431
+ "zimbabwean", "zambian", "south african", "mexican", "guatemalan", "cuban", "jamaican",
432
+ "haitian", "dominican", "puerto rican", "colombian", "venezuelan", "ecuadorian", "peruvian",
433
+ "bolivian", "chilean", "argentine", "argentinian", "uruguayan", "paraguayan", "brazilian",
434
+ "scandinavian", "soviet", "yugoslav", "taiwanese", "hong kong", "kazakh", "uzbek", "georgian",
435
+ "armenian", "azerbaijani", "afghan", "cambodian", "laotian", "burmese", "myanmar",
436
+ )
437
+ _COUNTRIES = tuple(c for c in (
438
+ "united states", "america", "united kingdom", "england", "scotland", "wales", "ireland",
439
+ "canada", "australia", "france", "germany", "italy", "spain", "japan", "china", "india",
440
+ "russia", "poland", "brazil", "mexico", "argentina", "sweden", "norway", "denmark", "finland",
441
+ "netherlands", "belgium", "switzerland", "austria", "greece", "turkey", "israel", "egypt",
442
+ "south africa", "nigeria", "kenya", "korea", "vietnam", "thailand", "philippines",
443
+ ) )
444
+
445
+ #: which kind of value an attribute names, and how to find it
446
+ _NATIONALITY_CUES = ("nationality", "country", "nation", "citizenship", "from the same",
447
+ "same country", "national")
448
+ _KIND_CUES = ("profession", "occupation", "job", "career", "genre", "type", "kind", "field",
449
+ "role", "sport", "instrument", "discipline", "subject", "category", "industry")
450
+ CONTINENTS = ("africa", "asia", "europe", "north america", "south america", "australia",
451
+ "antarctica", "oceania", "eurasia", "american", "african", "asian", "european")
452
+ _GEOGRAPHY_CUES = ("continent", "hemisphere")
453
+ _COPULA = re.compile(r"\b(?:is|was|were|are|being|became|remains)\b", re.I)
454
+
455
+
456
+ def complement_of(sentence: str) -> str:
457
+ """What a sentence predicates of its subject: the text after the copula.
458
+
459
+ "Scott Derrickson is an American director" says *American director*; the subject's own name is
460
+ not part of what is said about it, and including it makes every pair of people who share a
461
+ first name look alike.
462
+ """
463
+ stripped = re.sub(r"\([^)]*\)", " ", sentence)
464
+ match = _COPULA.search(stripped)
465
+ return stripped[match.end() :] if match else stripped
466
+
467
+
468
+ def attribute_kind(attribute: str) -> str:
469
+ """Which reader an attribute needs: a nationality, a kind-of-thing, or nothing known."""
470
+ low = f" {attribute.lower()} "
471
+ if any(c in low for c in _NATIONALITY_CUES):
472
+ return "nationality"
473
+ if any(c in low for c in _GEOGRAPHY_CUES):
474
+ return "geography"
475
+ if any(f" {c} " in low or c in low for c in _KIND_CUES):
476
+ return "kind"
477
+ return "unknown"
478
+
479
+
480
+ def said_of(sentences: list[str]) -> set[str]:
481
+ """Every content word said about a candidate — the test set for "are both X?"."""
482
+ return {w for s in sentences for w in _content(s)}
483
+
484
+
485
+ def attribute_phrases(sentences: list[str], attribute: str, *, need_reader: bool = True) -> set[str] | None:
486
+ """The value of the asked attribute for one candidate, or ``None`` if it cannot be read.
487
+
488
+ Refusing here is the point. Comparing two *whole sentences* for overlap says two film
489
+ directors of different nationalities are "the same nationality" because both sentences
490
+ contain the word "director"; only the attribute's own value can answer the question.
491
+ """
492
+ kind = attribute_kind(attribute)
493
+ text = " ".join(sentences).lower()
494
+ if kind == "nationality":
495
+ found = {n for n in NATIONALITIES if re.search(rf"\b{re.escape(n)}\b", text)}
496
+ found |= {c for c in _COUNTRIES if re.search(rf"\b{re.escape(c)}\b", text)}
497
+ return found or None
498
+ if kind == "geography":
499
+ return {c for c in CONTINENTS if re.search(rf"\b{re.escape(c)}\b", text)} or None
500
+ if kind == "kind" or not need_reader:
501
+ complement = " ".join(complement_of(s) for s in sentences)
502
+ return (set(_content(complement)) - set(_content(attribute))) or None
503
+ # No reader for this attribute. Its value is whatever the evidence says beside the attribute's
504
+ # own name: "the family Cistaceae" answers a question about families. Falling back to the
505
+ # overlap of two whole sentences instead was measured on train and was wrong in the worst
506
+ # way — two genera both described as "flowering plants" were called the same family.
507
+ head = attribute_head(attribute)
508
+ if not head:
509
+ return None
510
+ found: set[str] = set()
511
+ for spot in re.finditer(rf"\b{re.escape(head)}\w{{0,3}}\b", text):
512
+ after = _content(text[spot.end() : spot.end() + 40])
513
+ before = _content(text[max(0, spot.start() - 30) : spot.start()])
514
+ found.update(after[:3])
515
+ found.update(before[-1:])
516
+ return found or None
517
+
518
+
519
+ def attribute_head(attribute: str) -> str:
520
+ """The noun whose value is being compared: the word after "same", else the last content word."""
521
+ words = _content(attribute)
522
+ if not words:
523
+ return ""
524
+ if "same" in attribute.lower().split():
525
+ tail = attribute.lower().split()
526
+ i = tail.index("same")
527
+ rest = [_stem(w) for w in tail[i + 1 :] if w not in _STOP]
528
+ if rest:
529
+ return rest[-1]
530
+ return words[-1]
531
+
532
+
533
+ def values(comparison: Comparison, evidence: tuple[tuple[str, str], ...]) -> Values:
534
+ """Read one value per candidate out of the evidence, typed by what the relation needs."""
535
+ got = Values()
536
+ if comparison.family == "which_of_magnitude":
537
+ require = references(comparison.question, comparison.candidates)
538
+ offers = {}
539
+ for candidate in comparison.candidates:
540
+ sentences = _for_candidate(candidate, evidence)
541
+ if not sentences:
542
+ got.missing.append(candidate)
543
+ else:
544
+ offers[candidate] = quantities_offered(sentences, comparison.attribute, require)
545
+ if got.missing:
546
+ return got
547
+ a_name, b_name = comparison.candidates
548
+ chosen = pair_of_quantities(offers[a_name], offers[b_name])
549
+ if chosen is None:
550
+ got.missing.extend(name for name in comparison.candidates
551
+ if not any(offers[name]))
552
+ if not got.missing: # both offered something, but not one comparable pair
553
+ got.missing.append(f"a comparable pair for {a_name} and {b_name}")
554
+ return got
555
+ for name, (quantity, sentence) in zip(comparison.candidates, chosen):
556
+ got.values[name], got.sources[name] = quantity, sentence
557
+ return got
558
+ for candidate in comparison.candidates:
559
+ sentences = _for_candidate(candidate, evidence)
560
+ if not sentences:
561
+ got.missing.append(candidate)
562
+ continue
563
+ if comparison.family == "which_of_order":
564
+ found = year_in(sentences, comparison.attribute)
565
+ elif comparison.relation is Relation.both:
566
+ words = said_of(sentences + _titles_for(candidate, evidence))
567
+ found = (words, sentences[0]) if words else None
568
+ else:
569
+ phrases = attribute_phrases(sentences, comparison.attribute,
570
+ need_reader=comparison.relation is not Relation.shared)
571
+ found = (phrases, sentences[0]) if phrases is not None else None
572
+ if found is None:
573
+ got.missing.append(candidate)
574
+ else:
575
+ got.values[candidate] = found[0]
576
+ got.sources[candidate] = found[1]
577
+ return got
578
+
579
+
580
+ # --------------------------------------------------------------- the operation
581
+
582
+
583
+ @dataclass(frozen=True)
584
+ class Resolved:
585
+ """What the comparison answers, and the working that produced it."""
586
+
587
+ text: str
588
+ relation: Relation
589
+ steps: tuple[str, ...] = ()
590
+ evidence: tuple[str, ...] = ()
591
+
592
+ def describe(self) -> str:
593
+ return f"{self.text} — {' ; '.join(self.steps)}"
594
+
595
+
596
+ def _order(a_name: str, a_year: int, b_name: str, b_year: int, relation: Relation) -> Resolved | Unknown:
597
+ """Order two years through :mod:`tensorcode.temporal`, so the ordering is recorded as claims."""
598
+ mind = Store()
599
+ source = Ref("obs:evidence")
600
+ a_ref, b_ref = Ref("thing:a"), Ref("thing:b")
601
+ tell_event(mind, a_ref, at=datetime(a_year, 1, 1, tzinfo=timezone.utc), kind="compared", source=source)
602
+ tell_event(mind, b_ref, at=datetime(b_year, 1, 1, tzinfo=timezone.utc), kind="compared", source=source)
603
+ how = relate(mind, a_ref, b_ref)
604
+ if isinstance(how, Unknown):
605
+ return how
606
+ if how == "simultaneous":
607
+ return Unknown("tied", f"{a_name} and {b_name} are both {a_year}")
608
+ earlier = a_name if how == "before" else b_name
609
+ later = b_name if how == "before" else a_name
610
+ wanted = earlier if relation is Relation.earlier else later
611
+ return Resolved(wanted, relation,
612
+ (f"{a_name}: {a_year}", f"{b_name}: {b_year}", f"{earlier} is before {later}",
613
+ f"asked for the {relation.value} one, so {wanted}"))
614
+
615
+
616
+ def _counted(q: Quantity) -> bool:
617
+ """Is this a count of some thing, rather than a measure in a physical unit?"""
618
+ dims = q.dimension
619
+ return len(dims) == 1 and dims[0][0].startswith("count:") and dims[0][1] == 1
620
+
621
+
622
+ def _magnitude(a_name: str, a: Quantity, b_name: str, b: Quantity, relation: Relation) -> Resolved | Unknown:
623
+ if not a.comparable(b) and _counted(a) and _counted(b):
624
+ # quantity.compare refuses a count of people against a count of residents, correctly for
625
+ # arithmetic — you cannot add them. A question that asks which has more has already said
626
+ # the two counts are of one thing, so the magnitudes compare and the crossing is recorded.
627
+ how = "greater" if a.value > b.value else "less" if a.value < b.value else "equal"
628
+ crossed = f"compared {a.unit} with {b.unit} as counts, on the question's word"
629
+ else:
630
+ how = compare_quantities(a, b)
631
+ crossed = ""
632
+ if isinstance(how, Unknown):
633
+ return how # dimension mismatch, named by quantity.compare
634
+ if how == "equal":
635
+ return Unknown("tied", f"{a_name} and {b_name} are both {a}")
636
+ bigger = a_name if how == "greater" else b_name
637
+ smaller = b_name if how == "greater" else a_name
638
+ wanted = bigger if relation is Relation.greater else smaller
639
+ steps = (f"{a_name}: {a}", f"{b_name}: {b}", f"{bigger} is greater than {smaller}",
640
+ f"asked for the {relation.value} one, so {wanted}")
641
+ return Resolved(wanted, relation, steps + ((crossed,) if crossed else ()))
642
+
643
+
644
+ def _yes_no(comparison: Comparison, got: Values) -> Resolved | Unknown:
645
+ """Same, different, or both: a yes or a no, from what is said about each candidate."""
646
+ a_name, b_name = comparison.candidates
647
+ a, b = got.values.get(a_name), got.values.get(b_name)
648
+ if not isinstance(a, set) or not isinstance(b, set):
649
+ return Unknown("value_missing", "nothing said about one of the candidates")
650
+ if comparison.relation is Relation.both:
651
+ wanted = set(_content(comparison.attribute))
652
+ if not wanted:
653
+ return Unknown("attribute_empty", "the question names no predicate to test")
654
+ holds = wanted <= a and wanted <= b
655
+ return Resolved("yes" if holds else "no", comparison.relation,
656
+ (f"asked whether both are {' '.join(sorted(wanted))}",
657
+ f"{a_name} is missing {' '.join(sorted(wanted - a)) or 'nothing'}",
658
+ f"{b_name} is missing {' '.join(sorted(wanted - b)) or 'nothing'}"))
659
+ overlap = a & b
660
+ same = bool(overlap)
661
+ holds = same if comparison.relation is Relation.same else not same
662
+ return Resolved("yes" if holds else "no", comparison.relation,
663
+ (f"shared: {' '.join(sorted(overlap)[:6]) or 'nothing'}",
664
+ f"asked whether they are {comparison.relation.value}, so {'yes' if holds else 'no'}"))
665
+
666
+
667
+ def _shared(comparison: Comparison, got: Values) -> Resolved | Unknown:
668
+ """What two things have in common: the attribute value both their descriptions carry."""
669
+ a_name, b_name = comparison.candidates
670
+ a, b = got.values.get(a_name), got.values.get(b_name)
671
+ if not isinstance(a, set) or not isinstance(b, set):
672
+ return Unknown("value_missing", "nothing said about one of the candidates")
673
+ overlap = a & b
674
+ if not overlap:
675
+ return Unknown("no_shared_value", f"nothing said of both {a_name} and {b_name}")
676
+ phrase = longest_shared_phrase(complement_of(got.sources[a_name]),
677
+ complement_of(got.sources[b_name]))
678
+ if phrase is None:
679
+ heads = _content(complement_of(got.sources[a_name]))
680
+ phrase = max(overlap, key=lambda w: heads.index(w) if w in heads else -1)
681
+ return Resolved(phrase, comparison.relation,
682
+ (f"said of both: {' '.join(sorted(overlap)[:8])}", f"answering with {phrase!r}"))
683
+
684
+
685
+ def longest_shared_phrase(a: str, b: str) -> str | None:
686
+ """The longest run of words the two descriptions share, in its original spelling.
687
+
688
+ A single word under-answers: two people described as "an American actress and film director"
689
+ and "a French film director" share *film director*, and the gold answer to what they have in
690
+ common is the phrase, not its head. Matching is on stemmed words so a plural meets a
691
+ singular, but what is returned is the surface text, because "genu" is not an answer.
692
+ """
693
+ a_words, b_words = _WORDS.findall(a), _WORDS.findall(b)
694
+ a_stem = [_stem(w.lower()) for w in a_words]
695
+ b_stem = [_stem(w.lower()) for w in b_words]
696
+ best: tuple[int, int] | None = None # length, start in a
697
+ for i in range(len(a_stem)):
698
+ for j in range(len(b_stem)):
699
+ n = 0
700
+ while i + n < len(a_stem) and j + n < len(b_stem) and a_stem[i + n] == b_stem[j + n]:
701
+ n += 1
702
+ while n and (a_stem[i + n - 1] in _STOP or len(a_stem[i + n - 1]) <= 2):
703
+ n -= 1 # a phrase does not end in a stopword
704
+ start = i
705
+ while n and (a_stem[start] in _STOP or len(a_stem[start]) <= 2):
706
+ start += 1
707
+ n -= 1 # nor begin in one
708
+ if n and (best is None or n > best[0]):
709
+ best = (n, start)
710
+ if best is None:
711
+ return None
712
+ n, start = best
713
+ return " ".join(a_words[start : start + n])
714
+
715
+
716
+ def apply(comparison: Comparison, got: Values) -> Resolved | Unknown:
717
+ """Apply the relation to the values, or refuse for a named reason."""
718
+ if got.missing:
719
+ return Unknown("value_missing", f"no value found for {', '.join(got.missing)}")
720
+ a_name, b_name = comparison.candidates
721
+ a, b = got.values[a_name], got.values[b_name]
722
+ if comparison.family == "which_of_order":
723
+ if not (isinstance(a, int) and isinstance(b, int)):
724
+ return Unknown("incomparable", "an order comparison needs a year for each candidate")
725
+ return _order(a_name, a, b_name, b, comparison.relation)
726
+ if comparison.family == "which_of_magnitude":
727
+ if not (isinstance(a, Quantity) and isinstance(b, Quantity)):
728
+ return Unknown("incomparable", "a magnitude comparison needs a quantity for each candidate")
729
+ return _magnitude(a_name, a, b_name, b, comparison.relation)
730
+ if comparison.family == "yes_no":
731
+ return _yes_no(comparison, got)
732
+ return _shared(comparison, got)
733
+
734
+
735
+ def resolve(question: str, evidence: tuple[tuple[str, str], ...]) -> Resolved | Unknown:
736
+ """Answer a comparison question from evidence, or refuse with a named reason."""
737
+ comparison = read(question, tuple(dict.fromkeys(t for t, _ in evidence)))
738
+ if isinstance(comparison, Unknown):
739
+ return comparison
740
+ got = values(comparison, evidence)
741
+ out = apply(comparison, got)
742
+ if isinstance(out, Resolved):
743
+ sources = tuple(dict.fromkeys(got.sources.values()))
744
+ return Resolved(out.text, out.relation, out.steps, sources)
745
+ return out
746
+
747
+
748
+ # ----------------------------------------------------------- word problems
749
+
750
+
751
+ _DIFFERENCE = re.compile(r"how (?:many|much) (?:more|fewer|less|longer|older|younger|farther|greater)\b", re.I)
752
+ _TIMES = re.compile(r"how many times\b", re.I)
753
+
754
+
755
+ def difference_in(question: str, text: str) -> Resolved | Unknown:
756
+ """A "how many more X than Y" word problem: the gap between two quantities of one dimension.
757
+
758
+ This is the same operation as :func:`_magnitude` with a subtraction instead of a winner, and
759
+ it exists to test whether the comparison faculty transfers to arithmetic word problems.
760
+ """
761
+ if not _DIFFERENCE.search(question):
762
+ return Unknown("not_a_difference", "the question does not ask for a gap between two amounts")
763
+ mentions = quantities_in_text(text)
764
+ if len(mentions) < 2:
765
+ return Unknown("value_missing", f"found {len(mentions)} quantities, need two")
766
+ a, b = mentions[0].quantity, mentions[1].quantity
767
+ if not a.comparable(b) and not (_counted(a) and _counted(b)):
768
+ return Unknown("incomparable", f"cannot subtract {b} from {a}")
769
+ gap = abs(a.base() - b.base()) if a.comparable(b) else abs(a.value - b.value)
770
+ return Resolved(f"{gap:g}", Relation.greater,
771
+ (f"{a} and {b}", f"difference {gap:g}"))