tensorcode 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tensorcode/__init__.py +84 -0
- tensorcode/actions.py +137 -0
- tensorcode/answer_type.py +222 -0
- tensorcode/awareness.py +344 -0
- tensorcode/backends/__init__.py +0 -0
- tensorcode/backends/builtin.py +167 -0
- tensorcode/backends/hf_local.py +89 -0
- tensorcode/backends/linear.py +133 -0
- tensorcode/backends/neural.py +361 -0
- tensorcode/causal.py +262 -0
- tensorcode/change.py +566 -0
- tensorcode/chunking.py +195 -0
- tensorcode/cognition.py +311 -0
- tensorcode/context.py +97 -0
- tensorcode/control.py +291 -0
- tensorcode/cues.py +192 -0
- tensorcode/expectation.py +270 -0
- tensorcode/frames.py +232 -0
- tensorcode/language/__init__.py +36 -0
- tensorcode/language/chart.py +558 -0
- tensorcode/language/discourse.py +132 -0
- tensorcode/language/domains/__init__.py +0 -0
- tensorcode/language/domains/desktop.py +552 -0
- tensorcode/language/english.py +459 -0
- tensorcode/language/features.py +112 -0
- tensorcode/language/generate.py +574 -0
- tensorcode/language/grammar.py +893 -0
- tensorcode/language/semantics.py +349 -0
- tensorcode/learning/__init__.py +30 -0
- tensorcode/learning/certificate.py +148 -0
- tensorcode/learning/induce.py +304 -0
- tensorcode/learning/library.py +217 -0
- tensorcode/learning/literals.py +126 -0
- tensorcode/learning/verify.py +253 -0
- tensorcode/memory.py +303 -0
- tensorcode/metacognition.py +351 -0
- tensorcode/ops.py +207 -0
- tensorcode/outcomes.py +99 -0
- tensorcode/permanence.py +376 -0
- tensorcode/priming.py +191 -0
- tensorcode/py.typed +0 -0
- tensorcode/quantity.py +311 -0
- tensorcode/records.py +728 -0
- tensorcode/relation.py +771 -0
- tensorcode/runtime.py +471 -0
- tensorcode/semantics_bridge.py +308 -0
- tensorcode/social.py +380 -0
- tensorcode/temporal.py +189 -0
- tensorcode/wants.py +185 -0
- tensorcode-0.1.0a1.dist-info/METADATA +196 -0
- tensorcode-0.1.0a1.dist-info/RECORD +53 -0
- tensorcode-0.1.0a1.dist-info/WHEEL +4 -0
- tensorcode-0.1.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""From a parsed sentence to the structures that make it usable.
|
|
2
|
+
|
|
3
|
+
The grammar already recovers more than the projection keeps. "Anem has 12 sheep" parses
|
|
4
|
+
with ``count=Entity(number, '12')`` inside the noun's features, and
|
|
5
|
+
:func:`tensorcode.language.to_claims` then emits ``Anem have sheep`` — the number is
|
|
6
|
+
dropped on the floor. Conditionals and causal connectives fare worse: both clauses parse,
|
|
7
|
+
but they come back as two unrelated readings with the connective gone, so "if I press Send
|
|
8
|
+
an announcement appears" is indistinguishable from two separate remarks.
|
|
9
|
+
|
|
10
|
+
This module is the projection those structures deserve:
|
|
11
|
+
|
|
12
|
+
* :func:`quantities_in` lifts numbers and their units out of the parse into
|
|
13
|
+
:class:`~tensorcode.quantity.Quantity` values;
|
|
14
|
+
* :func:`link_in` recovers the connective from the surface string and pairs the readings it
|
|
15
|
+
joined, giving a conditional, a causal or a temporal link;
|
|
16
|
+
* :func:`probability_in` reads adverbs of likelihood as an *uncalibrated* score, because
|
|
17
|
+
"probably" is not a calibrated number and must not pretend to be.
|
|
18
|
+
|
|
19
|
+
Where it reads the surface string rather than the parse, that is a workaround for a gap in
|
|
20
|
+
the grammar, not a design choice; ``needed_from_grammar`` lists them so the gaps stay
|
|
21
|
+
visible instead of becoming permanent.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from typing import Any, Iterable, Mapping, Sequence
|
|
30
|
+
|
|
31
|
+
from .outcomes import Score, Unknown
|
|
32
|
+
from .quantity import Quantity, Unit, normalize_unit
|
|
33
|
+
from .records import Claim, Evidence, Ref, Store
|
|
34
|
+
from .temporal import CONNECTIVES
|
|
35
|
+
|
|
36
|
+
#: what the grammar drops and this module recovers from the surface string instead
|
|
37
|
+
needed_from_grammar = (
|
|
38
|
+
"numerals are kept only as a `count` feature on the noun; to_claims discards them",
|
|
39
|
+
"conditionals return two unlinked readings; `if`/`then` is not in the parse",
|
|
40
|
+
"causal connectives (`because`, `so`, `caused ... to`) parse as a noun or a plain verb",
|
|
41
|
+
"temporal connectives (`before`, `after`, `while`) collapse the clauses into one frame",
|
|
42
|
+
"comparatives (`twice as much as`) drop below full coverage and lose the multiplier",
|
|
43
|
+
"adverbs of likelihood (`probably`) can become the predicate",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
WORD_NUMBERS = {
|
|
47
|
+
"zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7,
|
|
48
|
+
"eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12, "dozen": 12, "twenty": 20,
|
|
49
|
+
"thirty": 30, "forty": 40, "fifty": 50, "hundred": 100, "thousand": 1000,
|
|
50
|
+
"half": 0.5, "quarter": 0.25, "twice": 2, "double": 2, "triple": 3,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
LIKELIHOOD = {
|
|
54
|
+
"certainly": 0.95, "definitely": 0.95, "surely": 0.9, "undoubtedly": 0.95,
|
|
55
|
+
"probably": 0.75, "likely": 0.75, "presumably": 0.7, "apparently": 0.6,
|
|
56
|
+
"maybe": 0.5, "perhaps": 0.5, "possibly": 0.4, "might": 0.4, "could": 0.4,
|
|
57
|
+
"unlikely": 0.2, "doubtfully": 0.15, "never": 0.02,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
CAUSAL_CUES = {"because": "effect_first", "since": "effect_first", "as": "effect_first",
|
|
61
|
+
"so": "cause_first", "therefore": "cause_first", "thus": "cause_first",
|
|
62
|
+
"caused": "cause_first", "causes": "cause_first", "cause": "cause_first"}
|
|
63
|
+
|
|
64
|
+
MONEY = {"$": "dollar", "€": "euro", "£": "pound_sterling"}
|
|
65
|
+
_NUMBER = re.compile(r"(?P<sym>[$€£])?\s*(?P<num>\d+(?:[.,]\d+)?)\s*(?P<pct>%)?\s*(?P<unit>[a-zA-Z][a-zA-Z_-]*)?")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Mention:
|
|
70
|
+
"""A quantity as it was said, with whatever the sentence attached it to."""
|
|
71
|
+
|
|
72
|
+
quantity: Quantity
|
|
73
|
+
of: str # the noun it counted or measured, as written
|
|
74
|
+
owner: str | None = None # the subject it was predicated of, when there is one
|
|
75
|
+
predicate: str | None = None
|
|
76
|
+
per: str | None = None # the unit it was stated per, for rates
|
|
77
|
+
|
|
78
|
+
def describe(self) -> str:
|
|
79
|
+
head = f"{self.quantity}"
|
|
80
|
+
of = "" if normalize_unit(self.of) in str(self.quantity.unit) else f" of {self.of}"
|
|
81
|
+
return head + of + (f" ({self.owner} {self.predicate})" if self.owner else "")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class Link:
|
|
86
|
+
"""Two clauses and the relation the connective asserted between them."""
|
|
87
|
+
|
|
88
|
+
kind: str # "conditional" | "causal" | "temporal"
|
|
89
|
+
relation: str # "if_then" | "causes" | "before" | "after" | "during"
|
|
90
|
+
antecedent: Any # a Frame (the if-side, the cause, the earlier event)
|
|
91
|
+
consequent: Any # a Frame (the then-side, the effect, the later event)
|
|
92
|
+
cue: str
|
|
93
|
+
|
|
94
|
+
def describe(self) -> str:
|
|
95
|
+
left = getattr(self.antecedent, "describe", lambda: str(self.antecedent))()
|
|
96
|
+
right = getattr(self.consequent, "describe", lambda: str(self.consequent))()
|
|
97
|
+
return f"{self.kind}({self.relation}): {left} ⇒ {right} [“{self.cue}”]"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ----------------------------------------------------------------- quantities
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _number_of(entity: Any) -> float | None:
|
|
104
|
+
text = getattr(entity, "text", None)
|
|
105
|
+
if text is None:
|
|
106
|
+
return None
|
|
107
|
+
word = str(text).strip().lower()
|
|
108
|
+
if word in WORD_NUMBERS:
|
|
109
|
+
return float(WORD_NUMBERS[word])
|
|
110
|
+
try:
|
|
111
|
+
return float(word.replace(",", ""))
|
|
112
|
+
except ValueError:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def quantities_in(frame: Any) -> list[Mention]:
|
|
117
|
+
"""Every quantity the parse carries, with its unit and what it was said about.
|
|
118
|
+
|
|
119
|
+
Reads the ``count`` feature the grammar puts on a noun — the thing ``to_claims`` throws
|
|
120
|
+
away — and the ``per`` phrasing that makes a rate.
|
|
121
|
+
"""
|
|
122
|
+
out: list[Mention] = []
|
|
123
|
+
for sub in frame.walk() if hasattr(frame, "walk") else [frame]:
|
|
124
|
+
subject = sub.roles.get("subject") if hasattr(sub, "roles") else None
|
|
125
|
+
owner = getattr(subject, "text", None)
|
|
126
|
+
for role, value in (sub.roles.items() if hasattr(sub, "roles") else ()):
|
|
127
|
+
for entity in _entities(value):
|
|
128
|
+
count = entity.features.get("count") if hasattr(entity, "features") else None
|
|
129
|
+
amount = _number_of(count) if count is not None else None
|
|
130
|
+
if amount is None:
|
|
131
|
+
continue
|
|
132
|
+
# the noun feature carries the head word; the surface text carries a rate
|
|
133
|
+
# phrase ("coins per bushel"), which is the part that fixes the unit
|
|
134
|
+
written = str(entity.text or "")
|
|
135
|
+
noun = written if re.search(r"\bper\b|/", written) else str(entity.features.get("noun") or written)
|
|
136
|
+
head, per = _split_rate(noun)
|
|
137
|
+
unit = Unit.of(head)
|
|
138
|
+
if per:
|
|
139
|
+
unit = unit / Unit.of(per)
|
|
140
|
+
out.append(Mention(Quantity(amount, unit), of=head, owner=owner if role != "subject" else None,
|
|
141
|
+
predicate=getattr(sub, "predicate", None), per=per))
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _entities(value: Any, depth: int = 0) -> Iterable[Any]:
|
|
146
|
+
if depth > 6:
|
|
147
|
+
return
|
|
148
|
+
if hasattr(value, "features") and hasattr(value, "text"):
|
|
149
|
+
yield value
|
|
150
|
+
for inner in value.features.values():
|
|
151
|
+
yield from _entities(inner, depth + 1)
|
|
152
|
+
elif isinstance(value, tuple):
|
|
153
|
+
for item in value:
|
|
154
|
+
yield from _entities(item, depth + 1)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _split_rate(noun: str) -> tuple[str, str | None]:
|
|
158
|
+
parts = re.split(r"\s+per\s+|\s*/\s*", noun.strip(), maxsplit=1)
|
|
159
|
+
if len(parts) == 2:
|
|
160
|
+
return parts[0].strip(), parts[1].strip()
|
|
161
|
+
return noun.strip(), None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def quantities_in_text(text: str) -> list[Mention]:
|
|
165
|
+
"""Quantities read straight off the string, for sentences the grammar does not cover.
|
|
166
|
+
|
|
167
|
+
Used by the arithmetic evaluation, where coverage matters more than structure. It is a
|
|
168
|
+
weaker reader than :func:`quantities_in` — it does not know what owns what.
|
|
169
|
+
"""
|
|
170
|
+
out: list[Mention] = []
|
|
171
|
+
for match in _NUMBER.finditer(text):
|
|
172
|
+
raw = match.group("num").replace(",", "")
|
|
173
|
+
try:
|
|
174
|
+
amount = float(raw)
|
|
175
|
+
except ValueError:
|
|
176
|
+
continue
|
|
177
|
+
if match.group("pct"):
|
|
178
|
+
out.append(Mention(Quantity(amount, Unit.of("percent")), of="percent"))
|
|
179
|
+
continue
|
|
180
|
+
symbol, word = match.group("sym"), match.group("unit")
|
|
181
|
+
if symbol:
|
|
182
|
+
out.append(Mention(Quantity(amount, Unit.of(MONEY[symbol])), of=MONEY[symbol]))
|
|
183
|
+
continue
|
|
184
|
+
noun = normalize_unit(word) if word else "item"
|
|
185
|
+
out.append(Mention(Quantity(amount, Unit.of(noun)), of=noun))
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------- links
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _overlap(frame: Any, span: str) -> int:
|
|
193
|
+
"""How much of a frame's wording appears in a stretch of the sentence."""
|
|
194
|
+
words = set(re.findall(r"[a-z0-9]+", span.lower()))
|
|
195
|
+
score = 0
|
|
196
|
+
predicate = str(getattr(frame, "predicate", "") or "").lower()
|
|
197
|
+
if predicate and any(w.startswith(predicate[:4]) for w in words if len(predicate) >= 4):
|
|
198
|
+
score += 2
|
|
199
|
+
for entity in getattr(frame, "entities", lambda: ())():
|
|
200
|
+
text = str(getattr(entity, "text", "")).lower()
|
|
201
|
+
if text and text in words:
|
|
202
|
+
score += 1
|
|
203
|
+
return score
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def link_in(text: str, meanings: Sequence[Any]) -> Link | Unknown:
|
|
207
|
+
"""The relation a connective asserted, and which reading sits on each side.
|
|
208
|
+
|
|
209
|
+
The connective comes from the string because the grammar drops it. Which clause is
|
|
210
|
+
which comes from matching each reading's wording against the two halves of the
|
|
211
|
+
sentence, so the direction of "because" is read, not assumed.
|
|
212
|
+
"""
|
|
213
|
+
frames = [m for m in meanings if hasattr(m, "predicate")]
|
|
214
|
+
if len(frames) < 2:
|
|
215
|
+
return Unknown("one_reading", "a link needs two clauses; the parse gave fewer")
|
|
216
|
+
lowered = text.lower()
|
|
217
|
+
|
|
218
|
+
for cue in ("if",):
|
|
219
|
+
match = re.search(rf"\b{cue}\b", lowered)
|
|
220
|
+
if match:
|
|
221
|
+
left, right = _sides(text, match.end(), r"\bthen\b")
|
|
222
|
+
a, b = _assign(frames, left, right)
|
|
223
|
+
return Link("conditional", "if_then", a, b, cue)
|
|
224
|
+
|
|
225
|
+
for cue, orientation in CAUSAL_CUES.items():
|
|
226
|
+
match = re.search(rf"\b{cue}\b", lowered)
|
|
227
|
+
if match:
|
|
228
|
+
before_cue, after_cue = text[: match.start()], text[match.end():]
|
|
229
|
+
first, second = _assign(frames, before_cue, after_cue)
|
|
230
|
+
cause, effect = (second, first) if orientation == "effect_first" else (first, second)
|
|
231
|
+
return Link("causal", "causes", cause, effect, cue)
|
|
232
|
+
|
|
233
|
+
for cue in CONNECTIVES:
|
|
234
|
+
match = re.search(rf"\b{cue}\b", lowered)
|
|
235
|
+
if match:
|
|
236
|
+
relation = CONNECTIVES[cue]
|
|
237
|
+
before_cue, after_cue = text[: match.start()], text[match.end():]
|
|
238
|
+
first, second = _assign(frames, before_cue, after_cue)
|
|
239
|
+
if relation == "before":
|
|
240
|
+
return Link("temporal", "before", first, second, cue)
|
|
241
|
+
if relation == "after":
|
|
242
|
+
return Link("temporal", "before", second, first, cue)
|
|
243
|
+
return Link("temporal", "during", first, second, cue)
|
|
244
|
+
return Unknown("no_connective", "no linking word found in the sentence")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _sides(text: str, start: int, closer: str) -> tuple[str, str]:
|
|
248
|
+
rest = text[start:]
|
|
249
|
+
match = re.search(closer, rest.lower())
|
|
250
|
+
return (rest[: match.start()], rest[match.end():]) if match else (rest, text[:start])
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _assign(frames: Sequence[Any], left: str, right: str) -> tuple[Any, Any]:
|
|
254
|
+
"""Put the reading that matches the left span on the left, the other on the right."""
|
|
255
|
+
best_left = max(frames, key=lambda f: (_overlap(f, left), -_overlap(f, right)))
|
|
256
|
+
rest = [f for f in frames if f is not best_left] or list(frames)
|
|
257
|
+
best_right = max(rest, key=lambda f: _overlap(f, right))
|
|
258
|
+
return best_left, best_right
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ---------------------------------------------------------------- likelihood
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def probability_in(text: str) -> Score | Unknown:
|
|
265
|
+
"""An adverb of likelihood as an uncalibrated score, never a calibrated probability."""
|
|
266
|
+
for word in re.findall(r"[a-z]+", text.lower()):
|
|
267
|
+
if word in LIKELIHOOD:
|
|
268
|
+
return Score(LIKELIHOOD[word], "uncalibrated", basis="")
|
|
269
|
+
return Unknown("no_likelihood_adverb", "nothing in the sentence states how likely it is")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# -------------------------------------------------------------------- claims
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def tell_mentions(mind: Store, mentions: Iterable[Mention], *, source: Ref, subject: Ref | None = None,
|
|
276
|
+
observed_at: datetime | None = None, method: str = "bridge:quantity") -> list[Claim]:
|
|
277
|
+
"""Record quantities as claims, keeping each number with its unit."""
|
|
278
|
+
at = observed_at or datetime.now(timezone.utc)
|
|
279
|
+
out: list[Claim] = []
|
|
280
|
+
for mention in mentions:
|
|
281
|
+
owner = subject or (Ref(f"entity:{mention.owner}") if mention.owner else Ref(f"entity:{mention.of}"))
|
|
282
|
+
predicate = mention.predicate or "amount"
|
|
283
|
+
claim = Claim(owner, predicate, mention.quantity)
|
|
284
|
+
mind.tell(claim, Evidence(source=source, observed_at=at, method=method))
|
|
285
|
+
out.append(claim)
|
|
286
|
+
return out
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def tell_link(mind: Store, link: Link, *, source: Ref, observed_at: datetime | None = None) -> Ref:
|
|
290
|
+
"""Record a link between clauses as a claim about the relation itself.
|
|
291
|
+
|
|
292
|
+
A conditional is *not* asserted as its consequent: "if I press Send an announcement
|
|
293
|
+
appears" must never enter the world as "an announcement appears".
|
|
294
|
+
"""
|
|
295
|
+
from .language.semantics import _mint # the same event identity the projection uses
|
|
296
|
+
|
|
297
|
+
at = observed_at or datetime.now(timezone.utc)
|
|
298
|
+
evidence = Evidence(source=source, observed_at=at, method=f"bridge:{link.kind}")
|
|
299
|
+
antecedent, consequent = _mint(link.antecedent), _mint(link.consequent)
|
|
300
|
+
ref = Ref(f"link:{antecedent.id.split(':')[1]}-{consequent.id.split(':')[1]}")
|
|
301
|
+
mind.tell(Claim(ref, "is_a", link.kind), evidence)
|
|
302
|
+
mind.tell(Claim(ref, "relation", link.relation), evidence)
|
|
303
|
+
mind.tell(Claim(ref, "antecedent", antecedent), evidence)
|
|
304
|
+
mind.tell(Claim(ref, "consequent", consequent), evidence)
|
|
305
|
+
mind.tell(Claim(ref, "cue", link.cue), evidence)
|
|
306
|
+
mind.tell(Claim(antecedent, "is_a", getattr(link.antecedent, "predicate", "event")), evidence)
|
|
307
|
+
mind.tell(Claim(consequent, "is_a", getattr(link.consequent, "predicate", "event")), evidence)
|
|
308
|
+
return ref
|
tensorcode/social.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Social cognition: what two minds share, what the other one believes, and when to ask.
|
|
2
|
+
|
|
3
|
+
Four structures, each answering a question the flat claim store cannot:
|
|
4
|
+
|
|
5
|
+
* :class:`CommonGround` — what is *mutually manifest*: I said it, you said it, or we both
|
|
6
|
+
saw it. A reply can then mark what is new and lean on what is already shared, instead of
|
|
7
|
+
re-explaining. Grounding is itself claims, so ``explain`` reaches it.
|
|
8
|
+
* :class:`OtherMind` — what I take *you* to believe, including where I think you are wrong.
|
|
9
|
+
A request presupposes things ("delete the report" presupposes a report); a presupposition
|
|
10
|
+
I can check and find false is a belief worth correcting, not just a fact to report.
|
|
11
|
+
* :func:`ask_or_act` — clarification as a decision, not a reflex: ask only when the expected
|
|
12
|
+
cost of acting on the wrong reading exceeds the cost of a question. Both failure modes are
|
|
13
|
+
represented, because an assistant that always asks is as useless as one that never does.
|
|
14
|
+
* :func:`indirect_reading` — intention inference gated by *affordance*. A statement or
|
|
15
|
+
question may imply a request, but only if its object is something I could act on: "I can't
|
|
16
|
+
find my invoice" is a request to look, "I can't find my keys" is not, and the difference is
|
|
17
|
+
not in the grammar.
|
|
18
|
+
|
|
19
|
+
Nothing here calls a model. Everything is deterministic and carries provenance.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import math
|
|
26
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
|
|
30
|
+
from .cognition import Fragment, Thought, integrate
|
|
31
|
+
from .outcomes import Score, Unknown
|
|
32
|
+
from .records import Claim, Ref, Store
|
|
33
|
+
|
|
34
|
+
AGENT = Ref("agent:self")
|
|
35
|
+
OTHER = Ref("person:user")
|
|
36
|
+
|
|
37
|
+
#: how a claim came to be shared
|
|
38
|
+
I_SAID = "i_said"
|
|
39
|
+
YOU_SAID = "you_said"
|
|
40
|
+
BOTH_SAW = "both_saw"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _now() -> datetime:
|
|
44
|
+
return datetime.now(timezone.utc)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _key(about: str, how: str) -> Ref:
|
|
48
|
+
return Ref(f"ground:{hashlib.sha256(f'{about}|{how}'.encode()).hexdigest()[:12]}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Shared:
|
|
53
|
+
"""One piece of common ground: what, how it became shared, when, how often mentioned."""
|
|
54
|
+
|
|
55
|
+
about: str # the claim id it grounds
|
|
56
|
+
how: str # I_SAID | YOU_SAID | BOTH_SAW
|
|
57
|
+
turn: int
|
|
58
|
+
mentions: int
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def mine(self) -> bool:
|
|
62
|
+
return self.how == I_SAID
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CommonGround:
|
|
66
|
+
"""The shared state between two minds, kept as claims so it can be explained.
|
|
67
|
+
|
|
68
|
+
Mutual manifestness is not symmetry of storage: a fact I hold is not shared until it was
|
|
69
|
+
said or jointly seen. ``status`` answers "does the other mind already have this, and how",
|
|
70
|
+
which is what lets a reply say "as I mentioned" rather than repeating itself.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, store: Store, *, me: Ref = AGENT, other: Ref = OTHER) -> None:
|
|
74
|
+
self.store, self.me, self.other = store, me, other
|
|
75
|
+
|
|
76
|
+
def add(self, about: str, how: str, turn: int, *, source: str | None = None) -> Thought:
|
|
77
|
+
"""Record that a claim (by id) became shared, or was mentioned again."""
|
|
78
|
+
ref = _key(about, how)
|
|
79
|
+
before = self.mentions(about, how)
|
|
80
|
+
claims = [
|
|
81
|
+
(Claim(ref, "grounds", about), None),
|
|
82
|
+
(Claim(ref, "shared_via", how), None),
|
|
83
|
+
(Claim(ref, "mentioned", before + 1), None),
|
|
84
|
+
(Claim(ref, "turn", turn), None),
|
|
85
|
+
]
|
|
86
|
+
if before: # a repeat supersedes the old count rather than piling up
|
|
87
|
+
self.store.forget([r.id for r in self.store.claims(ref, "mentioned")])
|
|
88
|
+
self.store.forget([r.id for r in self.store.claims(ref, "turn")])
|
|
89
|
+
frag = Fragment(Ref(source or f"utterance:{turn}"), tuple(claims), method="grounding", observed_at=_now())
|
|
90
|
+
return integrate(self.store, frag)
|
|
91
|
+
|
|
92
|
+
def mentions(self, about: str, how: str | None = None) -> int:
|
|
93
|
+
hows = [how] if how else [I_SAID, YOU_SAID, BOTH_SAW]
|
|
94
|
+
total = 0
|
|
95
|
+
for h in hows:
|
|
96
|
+
found = self.store.claims(_key(about, h), "mentioned")
|
|
97
|
+
total += max((int(r.claim.object) for r in found), default=0)
|
|
98
|
+
return total
|
|
99
|
+
|
|
100
|
+
def status(self, about: str) -> Shared | None:
|
|
101
|
+
"""How this claim is shared, preferring the strongest ground (jointly seen > said)."""
|
|
102
|
+
for how in (BOTH_SAW, YOU_SAID, I_SAID):
|
|
103
|
+
ref = _key(about, how)
|
|
104
|
+
said = self.store.claims(ref, "mentioned")
|
|
105
|
+
if said:
|
|
106
|
+
turn = max((int(r.claim.object) for r in self.store.claims(ref, "turn")), default=0)
|
|
107
|
+
return Shared(about, how, turn, max(int(r.claim.object) for r in said))
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def is_shared(self, about: str) -> bool:
|
|
111
|
+
return self.status(about) is not None
|
|
112
|
+
|
|
113
|
+
def new_to_other(self, about: str) -> bool:
|
|
114
|
+
"""True when the other mind has not been given this, so a reply should state it plainly."""
|
|
115
|
+
return not self.is_shared(about)
|
|
116
|
+
|
|
117
|
+
def again(self, about: str) -> bool:
|
|
118
|
+
"""True when I am about to say something I have already said: worth marking, not repeating.
|
|
119
|
+
|
|
120
|
+
Asks my own ground specifically — that you told me a thing is not a reason for me to
|
|
121
|
+
say "as I mentioned", and the strongest ground is not the relevant one here.
|
|
122
|
+
"""
|
|
123
|
+
return self.mentions(about, I_SAID) >= 1
|
|
124
|
+
|
|
125
|
+
def told_me(self, turn_from: int = 0) -> list[Shared]:
|
|
126
|
+
"""What the other mind told me, oldest first — the answer to "what did I tell you"."""
|
|
127
|
+
out = []
|
|
128
|
+
for rec in self.store.claims(predicate="shared_via", object=YOU_SAID):
|
|
129
|
+
about = next((r.claim.object for r in self.store.claims(rec.claim.subject, "grounds")), None)
|
|
130
|
+
st = self.status(str(about)) if about else None
|
|
131
|
+
if st and st.turn >= turn_from:
|
|
132
|
+
out.append(st)
|
|
133
|
+
return sorted(out, key=lambda s: s.turn)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --------------------------------------------------------------- other minds
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class Presupposition:
|
|
141
|
+
"""Something a request takes for granted, and can therefore be wrong about."""
|
|
142
|
+
|
|
143
|
+
kind: str # "exists" | "location" | "state" | "capability"
|
|
144
|
+
subject: str
|
|
145
|
+
detail: str = ""
|
|
146
|
+
|
|
147
|
+
def __str__(self) -> str:
|
|
148
|
+
return f"{self.kind}({self.subject}{', ' + self.detail if self.detail else ''})"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class FalseBelief:
|
|
153
|
+
"""A presupposition I checked and found false, with what to say instead."""
|
|
154
|
+
|
|
155
|
+
presupposition: Presupposition
|
|
156
|
+
truth: str
|
|
157
|
+
near: tuple[str, ...] = ()
|
|
158
|
+
|
|
159
|
+
def correction(self) -> str:
|
|
160
|
+
"""A correction of the belief, not a bare report of absence."""
|
|
161
|
+
base = f"there's no {self.presupposition.subject}"
|
|
162
|
+
if self.presupposition.detail:
|
|
163
|
+
base += f" {self.presupposition.detail}"
|
|
164
|
+
if self.near:
|
|
165
|
+
joined = self.near[0] if len(self.near) == 1 else ", ".join(self.near[:-1]) + f" or {self.near[-1]}"
|
|
166
|
+
return f"{base} — did you mean {joined}?"
|
|
167
|
+
return f"{base}; {self.truth}" if self.truth else base
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class OtherMind:
|
|
171
|
+
"""What I take the other mind to believe, see, and take for granted.
|
|
172
|
+
|
|
173
|
+
Two asymmetries matter. What they told me, they believe (until they say otherwise). What
|
|
174
|
+
is on a screen we both look at, they can see — but what sits inside a folder they never
|
|
175
|
+
opened, they cannot, so it is not shared context and a reply should not assume it.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def __init__(self, store: Store, *, who: Ref = OTHER, visible: Callable[[str], bool] | None = None) -> None:
|
|
179
|
+
self.store, self.who = store, who
|
|
180
|
+
self._visible = visible or (lambda _thing: False)
|
|
181
|
+
|
|
182
|
+
def believes(self, predicate: str) -> object | None:
|
|
183
|
+
found = self.store.claims(self.who, predicate)
|
|
184
|
+
return found[0].claim.object if found else None
|
|
185
|
+
|
|
186
|
+
def beliefs(self) -> dict[str, object]:
|
|
187
|
+
return {r.claim.predicate: r.claim.object for r in self.store.claims(self.who)}
|
|
188
|
+
|
|
189
|
+
def can_see(self, thing: str) -> bool:
|
|
190
|
+
return self._visible(thing)
|
|
191
|
+
|
|
192
|
+
def presupposes(self, slots: Mapping[str, object], rules: Mapping[str, str]) -> list[Presupposition]:
|
|
193
|
+
"""What a request takes for granted, given which slots presuppose what.
|
|
194
|
+
|
|
195
|
+
``rules`` maps a slot name to a presupposition kind, so the domain decides: a
|
|
196
|
+
``target`` slot presupposes existence, a ``place`` slot presupposes a location.
|
|
197
|
+
"""
|
|
198
|
+
out = []
|
|
199
|
+
for slot, kind in rules.items():
|
|
200
|
+
value = slots.get(slot)
|
|
201
|
+
if isinstance(value, str) and value and not value.startswith("@"):
|
|
202
|
+
detail = ""
|
|
203
|
+
if kind == "exists" and isinstance(slots.get("place"), str) and str(slots["place"]).startswith("~"):
|
|
204
|
+
detail = f"in {slots['place']}"
|
|
205
|
+
out.append(Presupposition(kind, value, detail))
|
|
206
|
+
return out
|
|
207
|
+
|
|
208
|
+
def check(self, presupposition: Presupposition, *, exists: Callable[[str], bool],
|
|
209
|
+
neighbours: Callable[[str], Sequence[str]] = lambda _s: ()) -> FalseBelief | None:
|
|
210
|
+
"""A presupposition I can check: return a false belief when it does not hold."""
|
|
211
|
+
if presupposition.kind not in ("exists", "location"):
|
|
212
|
+
return None
|
|
213
|
+
if exists(presupposition.subject):
|
|
214
|
+
return None
|
|
215
|
+
near = tuple(neighbours(presupposition.subject))[:3]
|
|
216
|
+
return FalseBelief(presupposition, truth="", near=near)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def near_names(name: str, candidates: Iterable[str], *, limit: int = 3) -> list[str]:
|
|
220
|
+
"""Names close enough that the other mind plausibly meant one of them.
|
|
221
|
+
|
|
222
|
+
Cognitively this is why a correction beats a bare "not found": a wrong name is usually a
|
|
223
|
+
near miss, and naming the near miss repairs the belief instead of ending the exchange.
|
|
224
|
+
"""
|
|
225
|
+
target = name.lower()
|
|
226
|
+
stem = target.rsplit(".", 1)[0]
|
|
227
|
+
scored: list[tuple[float, str]] = []
|
|
228
|
+
for cand in candidates:
|
|
229
|
+
c = cand.lower().rstrip("/")
|
|
230
|
+
if c == target:
|
|
231
|
+
continue
|
|
232
|
+
c_stem = c.rsplit(".", 1)[0]
|
|
233
|
+
score = max(_ratio(target, c), _ratio(stem, c_stem))
|
|
234
|
+
if stem and (stem in c_stem or c_stem in stem):
|
|
235
|
+
score = max(score, 0.75)
|
|
236
|
+
if score >= 0.6:
|
|
237
|
+
scored.append((score, cand))
|
|
238
|
+
scored.sort(key=lambda s: (-s[0], s[1]))
|
|
239
|
+
return [c for _, c in scored[:limit]]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _ratio(a: str, b: str) -> float:
|
|
243
|
+
"""Similarity by edit distance, normalized — small and dependency-free on purpose."""
|
|
244
|
+
if not a or not b:
|
|
245
|
+
return 0.0
|
|
246
|
+
prev = list(range(len(b) + 1))
|
|
247
|
+
for i, ca in enumerate(a, 1):
|
|
248
|
+
cur = [i]
|
|
249
|
+
for j, cb in enumerate(b, 1):
|
|
250
|
+
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
|
|
251
|
+
prev = cur
|
|
252
|
+
return 1.0 - prev[-1] / max(len(a), len(b))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ------------------------------------------------------------- clarification
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@dataclass(frozen=True)
|
|
259
|
+
class Reading:
|
|
260
|
+
"""One way to take an utterance, with how likely it is and what acting on it would cost."""
|
|
261
|
+
|
|
262
|
+
name: str
|
|
263
|
+
probability: float
|
|
264
|
+
description: str = ""
|
|
265
|
+
cost_if_wrong: float = 1.0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@dataclass(frozen=True)
|
|
269
|
+
class Clarification:
|
|
270
|
+
"""A question worth asking, with the options it puts to the other mind."""
|
|
271
|
+
|
|
272
|
+
question: str
|
|
273
|
+
options: tuple[str, ...]
|
|
274
|
+
expected_gain: float
|
|
275
|
+
about: str = ""
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@dataclass(frozen=True)
|
|
279
|
+
class Decision:
|
|
280
|
+
"""Ask, act, or admit — and why, in numbers a reader can check."""
|
|
281
|
+
|
|
282
|
+
choose: str # "act" | "ask" | "admit"
|
|
283
|
+
reading: Reading | None = None
|
|
284
|
+
clarification: Clarification | None = None
|
|
285
|
+
expected_gain: float = 0.0
|
|
286
|
+
reason: str = ""
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def uncertainty(readings: Sequence[Reading]) -> float:
|
|
290
|
+
"""Entropy over readings, in bits: how undetermined the goal is."""
|
|
291
|
+
total = sum(max(r.probability, 0.0) for r in readings) or 1.0
|
|
292
|
+
bits = 0.0
|
|
293
|
+
for r in readings:
|
|
294
|
+
p = max(r.probability, 0.0) / total
|
|
295
|
+
if p > 0:
|
|
296
|
+
bits -= p * math.log2(p)
|
|
297
|
+
return bits
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def ask_or_act(readings: Sequence[Reading], *, ask_cost: float = 0.25, confident: float = 0.8,
|
|
301
|
+
question: str | None = None, about: str = "") -> Decision:
|
|
302
|
+
"""Decide between acting on the best reading, asking, and admitting the goal is unclear.
|
|
303
|
+
|
|
304
|
+
The two failure modes are both priced: acting on the wrong reading costs ``cost_if_wrong``,
|
|
305
|
+
asking costs ``ask_cost`` (a turn of the other mind's patience). Asking wins only when the
|
|
306
|
+
expected cost it avoids exceeds that, which is what keeps a clarifying assistant from
|
|
307
|
+
becoming a tiresome one.
|
|
308
|
+
"""
|
|
309
|
+
live = [r for r in readings if r.probability > 0]
|
|
310
|
+
if not live:
|
|
311
|
+
return Decision("admit", reason="no reading at all")
|
|
312
|
+
best = max(live, key=lambda r: r.probability)
|
|
313
|
+
total = sum(r.probability for r in live) or 1.0
|
|
314
|
+
p_best = best.probability / total
|
|
315
|
+
if p_best >= confident or len(live) == 1:
|
|
316
|
+
return Decision("act", reading=best, expected_gain=0.0, reason=f"one reading dominates (p={p_best:.2f})")
|
|
317
|
+
expected_loss = sum((r.probability / total) * r.cost_if_wrong for r in live if r is not best)
|
|
318
|
+
gain = expected_loss - ask_cost
|
|
319
|
+
if gain <= 0:
|
|
320
|
+
return Decision("act", reading=best, expected_gain=gain,
|
|
321
|
+
reason=f"asking costs more than the risk ({expected_loss:.2f} vs {ask_cost:.2f})")
|
|
322
|
+
options = tuple(r.description or r.name for r in sorted(live, key=lambda r: -r.probability))
|
|
323
|
+
text = question or "Which did you mean?"
|
|
324
|
+
return Decision("ask", reading=best,
|
|
325
|
+
clarification=Clarification(text, options, gain, about),
|
|
326
|
+
expected_gain=gain, reason=f"{uncertainty(live):.2f} bits undetermined, {len(live)} readings")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ------------------------------------------------------ indirect and implied
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@dataclass(frozen=True)
|
|
333
|
+
class Indirect:
|
|
334
|
+
"""A request recovered from an utterance whose surface form is something else."""
|
|
335
|
+
|
|
336
|
+
surface: str # "statement" | "question" | "complaint" | "wish"
|
|
337
|
+
act: str
|
|
338
|
+
slots: dict
|
|
339
|
+
strength: float
|
|
340
|
+
object_word: str = ""
|
|
341
|
+
|
|
342
|
+
def score(self) -> Score:
|
|
343
|
+
return Score(self.strength, "implicature", basis=f"{self.surface} form implying {self.act}")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@dataclass(frozen=True)
|
|
347
|
+
class Implication:
|
|
348
|
+
"""A surface form that may imply an act, and what it needs to be believed."""
|
|
349
|
+
|
|
350
|
+
pattern: str # a regex with an ``obj`` group where the thing acted on appears
|
|
351
|
+
surface: str
|
|
352
|
+
act: str
|
|
353
|
+
strength: float
|
|
354
|
+
slot: str = "target"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def indirect_reading(text: str, implications: Sequence[Implication], *,
|
|
358
|
+
in_domain: Callable[[str], bool], floor: float = 0.5) -> Indirect | Unknown:
|
|
359
|
+
"""Read an indirect request, but only where the object is something I could act on.
|
|
360
|
+
|
|
361
|
+
The gate is the point. Form alone cannot tell "I can't find my invoice" (a request to
|
|
362
|
+
look) from "I can't find my keys" (not my business); what separates them is whether the
|
|
363
|
+
object falls inside what I can act on at all. So intention inference is constrained by
|
|
364
|
+
affordance, and an utterance about the world beyond my reach stays a remark.
|
|
365
|
+
"""
|
|
366
|
+
import re
|
|
367
|
+
|
|
368
|
+
for imp in implications:
|
|
369
|
+
m = re.search(imp.pattern, text, re.I)
|
|
370
|
+
if not m:
|
|
371
|
+
continue
|
|
372
|
+
obj = (m.groupdict().get("obj") or "").strip(" .!?\"'")
|
|
373
|
+
if not obj:
|
|
374
|
+
continue
|
|
375
|
+
if not in_domain(obj):
|
|
376
|
+
return Unknown("not_my_domain", f"“{obj}” is not something I can act on, so I read that as a remark")
|
|
377
|
+
if imp.strength < floor:
|
|
378
|
+
continue
|
|
379
|
+
return Indirect(imp.surface, imp.act, {imp.slot: obj}, imp.strength, obj)
|
|
380
|
+
return Unknown("no_implicature", "no indirect reading fits this utterance")
|