tensorcode 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tensorcode/__init__.py +84 -0
- tensorcode/actions.py +137 -0
- tensorcode/answer_type.py +222 -0
- tensorcode/awareness.py +344 -0
- tensorcode/backends/__init__.py +0 -0
- tensorcode/backends/builtin.py +167 -0
- tensorcode/backends/hf_local.py +89 -0
- tensorcode/backends/linear.py +133 -0
- tensorcode/backends/neural.py +361 -0
- tensorcode/causal.py +262 -0
- tensorcode/change.py +566 -0
- tensorcode/chunking.py +195 -0
- tensorcode/cognition.py +311 -0
- tensorcode/context.py +97 -0
- tensorcode/control.py +291 -0
- tensorcode/cues.py +192 -0
- tensorcode/expectation.py +270 -0
- tensorcode/frames.py +232 -0
- tensorcode/language/__init__.py +36 -0
- tensorcode/language/chart.py +558 -0
- tensorcode/language/discourse.py +132 -0
- tensorcode/language/domains/__init__.py +0 -0
- tensorcode/language/domains/desktop.py +552 -0
- tensorcode/language/english.py +459 -0
- tensorcode/language/features.py +112 -0
- tensorcode/language/generate.py +574 -0
- tensorcode/language/grammar.py +893 -0
- tensorcode/language/semantics.py +349 -0
- tensorcode/learning/__init__.py +30 -0
- tensorcode/learning/certificate.py +148 -0
- tensorcode/learning/induce.py +304 -0
- tensorcode/learning/library.py +217 -0
- tensorcode/learning/literals.py +126 -0
- tensorcode/learning/verify.py +253 -0
- tensorcode/memory.py +303 -0
- tensorcode/metacognition.py +351 -0
- tensorcode/ops.py +207 -0
- tensorcode/outcomes.py +99 -0
- tensorcode/permanence.py +376 -0
- tensorcode/priming.py +191 -0
- tensorcode/py.typed +0 -0
- tensorcode/quantity.py +311 -0
- tensorcode/records.py +728 -0
- tensorcode/relation.py +771 -0
- tensorcode/runtime.py +471 -0
- tensorcode/semantics_bridge.py +308 -0
- tensorcode/social.py +380 -0
- tensorcode/temporal.py +189 -0
- tensorcode/wants.py +185 -0
- tensorcode-0.1.0a1.dist-info/METADATA +196 -0
- tensorcode-0.1.0a1.dist-info/RECORD +53 -0
- tensorcode-0.1.0a1.dist-info/WHEEL +4 -0
- tensorcode-0.1.0a1.dist-info/licenses/LICENSE +21 -0
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}"))
|