tensorcode 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. tensorcode/__init__.py +84 -0
  2. tensorcode/actions.py +137 -0
  3. tensorcode/answer_type.py +222 -0
  4. tensorcode/awareness.py +344 -0
  5. tensorcode/backends/__init__.py +0 -0
  6. tensorcode/backends/builtin.py +167 -0
  7. tensorcode/backends/hf_local.py +89 -0
  8. tensorcode/backends/linear.py +133 -0
  9. tensorcode/backends/neural.py +361 -0
  10. tensorcode/causal.py +262 -0
  11. tensorcode/change.py +566 -0
  12. tensorcode/chunking.py +195 -0
  13. tensorcode/cognition.py +311 -0
  14. tensorcode/context.py +97 -0
  15. tensorcode/control.py +291 -0
  16. tensorcode/cues.py +192 -0
  17. tensorcode/expectation.py +270 -0
  18. tensorcode/frames.py +232 -0
  19. tensorcode/language/__init__.py +36 -0
  20. tensorcode/language/chart.py +558 -0
  21. tensorcode/language/discourse.py +132 -0
  22. tensorcode/language/domains/__init__.py +0 -0
  23. tensorcode/language/domains/desktop.py +552 -0
  24. tensorcode/language/english.py +459 -0
  25. tensorcode/language/features.py +112 -0
  26. tensorcode/language/generate.py +574 -0
  27. tensorcode/language/grammar.py +893 -0
  28. tensorcode/language/semantics.py +349 -0
  29. tensorcode/learning/__init__.py +30 -0
  30. tensorcode/learning/certificate.py +148 -0
  31. tensorcode/learning/induce.py +304 -0
  32. tensorcode/learning/library.py +217 -0
  33. tensorcode/learning/literals.py +126 -0
  34. tensorcode/learning/verify.py +253 -0
  35. tensorcode/memory.py +303 -0
  36. tensorcode/metacognition.py +351 -0
  37. tensorcode/ops.py +207 -0
  38. tensorcode/outcomes.py +99 -0
  39. tensorcode/permanence.py +376 -0
  40. tensorcode/priming.py +191 -0
  41. tensorcode/py.typed +0 -0
  42. tensorcode/quantity.py +311 -0
  43. tensorcode/records.py +728 -0
  44. tensorcode/relation.py +771 -0
  45. tensorcode/runtime.py +471 -0
  46. tensorcode/semantics_bridge.py +308 -0
  47. tensorcode/social.py +380 -0
  48. tensorcode/temporal.py +189 -0
  49. tensorcode/wants.py +185 -0
  50. tensorcode-0.1.0a1.dist-info/METADATA +196 -0
  51. tensorcode-0.1.0a1.dist-info/RECORD +53 -0
  52. tensorcode-0.1.0a1.dist-info/WHEEL +4 -0
  53. tensorcode-0.1.0a1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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")