zeechimp commited on
Commit
5822f41
·
verified ·
1 Parent(s): 7cd1c24

Upload hv_split.py

Browse files
Files changed (1) hide show
  1. hv_split.py +871 -0
hv_split.py ADDED
@@ -0,0 +1,871 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ hv-split
3
+ ========
4
+
5
+ Bundle of interpretations, not one answer.
6
+
7
+ Every generative model picks one reading of an ambiguous query and
8
+ answers it. hv-split refuses to pick. Given a query, it returns a
9
+ *ranked bundle* of every interpretation it can detect — each with the
10
+ ambiguity source, the ambiguous span, the reading, and a prior.
11
+
12
+ This is a new output shape: not a label, not a completion, not a
13
+ ranking of documents, but a distribution over *readings of the same
14
+ query*.
15
+
16
+ Ambiguity sources detected:
17
+
18
+ referential pronouns with multiple candidate antecedents
19
+ lexical polysemous terms with multiple senses
20
+ scope negation scoping over/under a quantifier
21
+ presupposition "why did X" presupposes X happened
22
+ framing "in the language of Y" commits to a frame
23
+ temporal vague temporal references ("recently", "soon")
24
+
25
+ No dependencies. Pure stdlib. Runs anywhere Python 3.9+ runs.
26
+
27
+ Author: zeechimp
28
+ License: Apache-2.0
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import json
35
+ import math
36
+ import os
37
+ import re
38
+ import sys
39
+ from collections import Counter
40
+ from dataclasses import asdict, dataclass, field
41
+ from typing import Callable, Dict, List, Optional, Tuple
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Lexicon
46
+ # ---------------------------------------------------------------------------
47
+ DEFAULT_POLYSEMOUS: Dict[str, List[str]] = {
48
+ "bank": ["financial institution", "river edge",
49
+ "memory bank", "blood bank"],
50
+ "trust": ["confidence", "legal entity", "believe", "rely on"],
51
+ "frame": ["physical border", "viewpoint",
52
+ "incriminate falsely", "data unit"],
53
+ "run": ["execute program", "physical movement",
54
+ "operate", "flow"],
55
+ "set": ["collection", "to place", "configuration", "sun decline"],
56
+ "function": ["mathematical mapping", "role or purpose",
57
+ "working state", "subroutine"],
58
+ "model": ["mathematical object", "ML model",
59
+ "fashion subject", "small replica"],
60
+ "order": ["request or command", "sequence",
61
+ "mathematical order", "purchase"],
62
+ "memory": ["biological memory", "computer memory",
63
+ "recollection", "memorial"],
64
+ "state": ["political entity", "condition or mode",
65
+ "verb: to say", "configuration"],
66
+ "point": ["location", "argument", "score", "moment in time"],
67
+ "line": ["geometric line", "queue",
68
+ "text row", "connection"],
69
+ "sense": ["perception", "meaning",
70
+ "direction", "reason"],
71
+ "light": ["illumination", "not heavy", "not dark", "to ignite"],
72
+ "spring": ["season", "metal coil", "water source", "to jump"],
73
+ "kind": ["type", "benevolent", "species"],
74
+ "present": ["gift", "here now", "to show", "current time"],
75
+ "mean": ["average", "unkind", "intend", "signify"],
76
+ "note": ["musical note", "written message",
77
+ "observe", "distinction"],
78
+ "fine": ["good", "penalty", "thin", "precise"],
79
+ }
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Pronoun forms
84
+ # ---------------------------------------------------------------------------
85
+ PRONOUN_FORMS: Dict[str, List[str]] = {
86
+ "it": ["its"],
87
+ "this": ["this"],
88
+ "that": ["that"],
89
+ "they": ["them", "their"],
90
+ "these": ["these"],
91
+ "those": ["those"],
92
+ "he": ["him", "his"],
93
+ "she": ["her"],
94
+ }
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # English pronouns — never valid noun phrase candidates
99
+ # ---------------------------------------------------------------------------
100
+ ENGLISH_PRONOUNS = {
101
+ "i", "me", "my", "mine", "myself",
102
+ "you", "your", "yours", "yourself",
103
+ "he", "him", "his", "himself",
104
+ "she", "her", "hers", "herself",
105
+ "it", "its", "itself",
106
+ "we", "us", "our", "ours", "ourselves",
107
+ "they", "them", "their", "theirs", "themselves",
108
+ "this", "that", "these", "those",
109
+ "who", "whom", "whose", "which", "what",
110
+ "anyone", "someone", "everyone", "nobody",
111
+ }
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Second-word stop list for "the X Y" NP pattern
116
+ # ---------------------------------------------------------------------------
117
+ NP_SECOND_STOP = {
118
+ # auxiliaries / verbs
119
+ "is", "are", "was", "were", "be", "been", "being",
120
+ "has", "have", "had", "do", "does", "did",
121
+ "will", "would", "shall", "should", "can", "could",
122
+ "may", "might", "must", "ought",
123
+ # pronouns
124
+ "i", "you", "he", "she", "it", "we", "they",
125
+ "me", "him", "her", "us", "them",
126
+ # conjunctions / prepositions
127
+ "and", "or", "but", "nor", "so", "yet", "for",
128
+ "of", "in", "on", "at", "to", "by", "with", "from",
129
+ "into", "over", "under", "about", "through", "between",
130
+ # wh-words
131
+ "that", "which", "who", "whom", "whose", "where",
132
+ "when", "why", "how", "what", "whether", "if",
133
+ }
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Stop words that terminate a frame term
138
+ # ---------------------------------------------------------------------------
139
+ FRAME_STOP = {
140
+ "had", "has", "have", "is", "was", "were",
141
+ "will", "would", "should", "could", "can",
142
+ "may", "might", "must", "do", "does", "did",
143
+ "be", "been", "being",
144
+ "and", "or", "but", "that", "which", "where",
145
+ "when", "why", "how", "what", "who",
146
+ "the", "a", "an", "to", "for", "of", "in",
147
+ "on", "at", "by",
148
+ }
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Presupposition patterns
153
+ # ---------------------------------------------------------------------------
154
+ PRESUP_PATTERNS: List[Tuple[str, str]] = [
155
+ (r"\bwhy (?:did|does|is|are|was|were)\b",
156
+ "'why did X' presupposes X occurred or holds"),
157
+ (r"\bwhen (?:did|does|will)\b",
158
+ "'when did X' presupposes X occurs"),
159
+ (r"\bhow (?:did|does)\b",
160
+ "'how did X' presupposes X occurred"),
161
+ (r"\bhave you stopped\b",
162
+ "classical 'have you stopped X' presupposition trap"),
163
+ (r"\bthe current\b",
164
+ "'the current X' presupposes a unique current instance"),
165
+ (r"\bthe only\b",
166
+ "'the only X' presupposes uniqueness"),
167
+ (r"\bthe last\b",
168
+ "'the last X' presupposes a well-defined last occurrence"),
169
+ ]
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Framing patterns
174
+ # ---------------------------------------------------------------------------
175
+ FRAME_PATTERNS: List[str] = [
176
+ r"\bin the (?:language|idiom|vocabulary|style|spirit) of\s+([^.,;!?]+)",
177
+ r"\bfrom the (?:perspective|viewpoint|angle|standpoint) of\s+([^.,;!?]+)",
178
+ r"\bthrough the (?:lens|frame|prism) of\s+([^.,;!?]+)",
179
+ r"\bwithin the framework of\s+([^.,;!?]+)",
180
+ r"\bunder the (?:assumption|interpretation|reading|hypothesis) that\s+([^.,;!?]+)",
181
+ ]
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Temporal patterns
186
+ # ---------------------------------------------------------------------------
187
+ TEMPORAL_PATTERNS: Dict[str, str] = {
188
+ r"\brecently\b": "recently = this week, this year, or this decade?",
189
+ r"\bsoon\b": "soon = minutes, days, or years?",
190
+ r"\blately\b": "lately = since when, exactly?",
191
+ r"\bcurrently\b": "currently = as of now, as of this writing, or today?",
192
+ r"\bpreviously\b": "previously = before what?",
193
+ r"\bnow\b": "now = this instant, this era, or this moment in the argument?",
194
+ r"\bbefore the (?:change|update|shift)\b":
195
+ "which change/update/shift is meant?",
196
+ r"\bafter the (?:change|update|shift)\b":
197
+ "which change/update/shift is meant?",
198
+ }
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Scope pattern
203
+ # ---------------------------------------------------------------------------
204
+ SCOPE_RE = re.compile(
205
+ r"\b(all|every|each|any)\b.*?\b(not|n't|never|cannot|can't|"
206
+ r"won't|doesn't|don't|isn't|aren't)\b[^.!?]*",
207
+ re.IGNORECASE,
208
+ )
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Dataclasses
213
+ # ---------------------------------------------------------------------------
214
+ @dataclass
215
+ class Interpretation:
216
+ source: str
217
+ span: str
218
+ span_range: Tuple[int, int]
219
+ reading: str
220
+ prior: float
221
+ rationale: str
222
+
223
+ def to_dict(self) -> dict:
224
+ d = asdict(self)
225
+ d["span_range"] = list(self.span_range)
226
+ return d
227
+
228
+
229
+ @dataclass
230
+ class SplitBundle:
231
+ query: str
232
+ interpretations: List[Interpretation]
233
+ ambiguity_score: float
234
+ confidence: float
235
+ entropy: float
236
+ dominant_source: str
237
+ sources: List[str]
238
+ n_interpretations: int
239
+
240
+ def to_dict(self) -> dict:
241
+ return {
242
+ "query": self.query,
243
+ "interpretations": [i.to_dict() for i in self.interpretations],
244
+ "ambiguity_score": self.ambiguity_score,
245
+ "confidence": self.confidence,
246
+ "entropy": self.entropy,
247
+ "dominant_source": self.dominant_source,
248
+ "sources": self.sources,
249
+ "n_interpretations": self.n_interpretations,
250
+ }
251
+
252
+
253
+ @dataclass
254
+ class HVInterpretConfig:
255
+ max_interpretations: int = 16
256
+ max_per_source: int = 6
257
+ window_chars: int = 220
258
+ source_weights: Dict[str, float] = field(default_factory=lambda: {
259
+ "referential": 1.0,
260
+ "lexical": 1.0,
261
+ "scope": 1.0,
262
+ "presuppositional": 1.0,
263
+ "framing": 1.0,
264
+ "temporal": 1.0,
265
+ })
266
+ version: str = "0.1.1"
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # Model
271
+ # ---------------------------------------------------------------------------
272
+ class HVInterpret:
273
+ """Split an ambiguous query into a ranked bundle of interpretations."""
274
+
275
+ def __init__(
276
+ self,
277
+ lexicon: Optional[Dict[str, List[str]]] = None,
278
+ config: Optional[HVInterpretConfig] = None,
279
+ ):
280
+ self.lexicon = lexicon if lexicon is not None else DEFAULT_POLYSEMOUS
281
+ self.config = config or HVInterpretConfig()
282
+ self._obs = 0
283
+
284
+ def __repr__(self) -> str:
285
+ return (
286
+ f"HVInterpret(lexicon={len(self.lexicon)} words, "
287
+ f"max_interps={self.config.max_interpretations})"
288
+ )
289
+
290
+ # ------------------------------------------------------------------
291
+ # Public API
292
+ # ------------------------------------------------------------------
293
+ def split(self, query: str) -> SplitBundle:
294
+ """Return a bundle of interpretations for the query."""
295
+ if not query or not query.strip():
296
+ return self._empty_bundle(query)
297
+
298
+ per_source: Dict[str, List[Interpretation]] = {
299
+ "referential": self._detect_referential(query),
300
+ "lexical": self._detect_lexical(query),
301
+ "scope": self._detect_scope(query),
302
+ "presuppositional": self._detect_presuppositional(query),
303
+ "framing": self._detect_framing(query),
304
+ "temporal": self._detect_temporal(query),
305
+ }
306
+
307
+ # Within-source normalization, then apply source weights
308
+ all_interps: List[Interpretation] = []
309
+ for source, interps in per_source.items():
310
+ if not interps:
311
+ continue
312
+ interps = interps[: self.config.max_per_source]
313
+ s = sum(i.prior for i in interps) or 1.0
314
+ weight = self.config.source_weights.get(source, 1.0)
315
+ for i in interps:
316
+ i.prior = (i.prior / s) * weight
317
+ all_interps.extend(interps)
318
+
319
+ if not all_interps:
320
+ return self._empty_bundle(query)
321
+
322
+ # Global normalization to marginals
323
+ total = sum(i.prior for i in all_interps) or 1.0
324
+ for i in all_interps:
325
+ i.prior = i.prior / total
326
+
327
+ all_interps.sort(key=lambda x: -x.prior)
328
+ all_interps = all_interps[: self.config.max_interpretations]
329
+
330
+ # Re-normalize after the cap
331
+ total = sum(i.prior for i in all_interps) or 1.0
332
+ for i in all_interps:
333
+ i.prior = i.prior / total
334
+
335
+ priors = [i.prior for i in all_interps]
336
+ max_prior = max(priors)
337
+ entropy = -sum(p * math.log(p + 1e-12) for p in priors)
338
+ max_entropy = math.log(len(priors)) if len(priors) > 1 else 1.0
339
+ ambiguity = entropy / max_entropy if max_entropy > 0 else 0.0
340
+ if len(all_interps) == 1:
341
+ ambiguity = 0.0
342
+ confidence = 1.0
343
+ else:
344
+ confidence = max_prior
345
+
346
+ sources = sorted({i.source for i in all_interps})
347
+ dominant = all_interps[0].source
348
+
349
+ return SplitBundle(
350
+ query=query,
351
+ interpretations=all_interps,
352
+ ambiguity_score=float(ambiguity),
353
+ confidence=float(confidence),
354
+ entropy=float(entropy),
355
+ dominant_source=dominant,
356
+ sources=sources,
357
+ n_interpretations=len(all_interps),
358
+ )
359
+
360
+ def ambiguity(self, query: str) -> float:
361
+ return self.split(query).ambiguity_score
362
+
363
+ def most_likely(self, query: str) -> Optional[Interpretation]:
364
+ b = self.split(query)
365
+ return b.interpretations[0] if b.interpretations else None
366
+
367
+ def answer_each(
368
+ self,
369
+ query: str,
370
+ answer_fn: Callable[[str, Interpretation], str],
371
+ ) -> List[Tuple[Interpretation, str]]:
372
+ """Apply an answer callback to each interpretation.
373
+
374
+ `answer_fn(query, interpretation)` -> str.
375
+ Returns a list of (interpretation, answer) pairs.
376
+ """
377
+ b = self.split(query)
378
+ return [(i, answer_fn(b.query, i)) for i in b.interpretations]
379
+
380
+ # ------------------------------------------------------------------
381
+ # Render
382
+ # ------------------------------------------------------------------
383
+ def render(self, bundle: SplitBundle, mode: str = "text") -> str:
384
+ if mode == "markdown":
385
+ return self._render_markdown(bundle)
386
+ if mode == "json":
387
+ return json.dumps(bundle.to_dict(), indent=2)
388
+ return self._render_text(bundle)
389
+
390
+ # ------------------------------------------------------------------
391
+ # Detectors
392
+ # ------------------------------------------------------------------
393
+ def _detect_referential(self, query: str) -> List[Interpretation]:
394
+ nps = self._extract_noun_phrases(query)
395
+ results: List[Interpretation] = []
396
+ seen_pronouns: set = set()
397
+
398
+ for pronoun in PRONOUN_FORMS:
399
+ if pronoun in seen_pronouns:
400
+ continue
401
+ m = re.search(rf"\b{pronoun}\b", query, re.IGNORECASE)
402
+ if not m:
403
+ continue
404
+ prefix_start = max(0, m.start() - self.config.window_chars)
405
+ candidates = [
406
+ (text, start, end)
407
+ for text, start, end in nps
408
+ if end <= m.start() and start >= prefix_start
409
+ ]
410
+ # Deduplicate by text
411
+ seen_text = set()
412
+ unique: List[Tuple[str, int, int]] = []
413
+ for text, start, end in candidates:
414
+ key = text.lower()
415
+ if key in seen_text:
416
+ continue
417
+ seen_text.add(key)
418
+ unique.append((text, start, end))
419
+ # Cap at 3 most recent
420
+ unique = sorted(unique, key=lambda c: -c[2])[:3]
421
+ if len(unique) < 2:
422
+ continue
423
+
424
+ seen_pronouns.add(pronoun)
425
+ for text, start, end in unique:
426
+ results.append(Interpretation(
427
+ source="referential",
428
+ span=query[m.start():m.end()],
429
+ span_range=(m.start(), m.end()),
430
+ reading=f"'{pronoun}' refers to '{text}'",
431
+ prior=1.0 / len(unique),
432
+ rationale=(
433
+ f"pronoun '{pronoun}' has {len(unique)} candidate "
434
+ f"antecedents in the preceding window"
435
+ ),
436
+ ))
437
+
438
+ return results
439
+
440
+ def _detect_lexical(self, query: str) -> List[Interpretation]:
441
+ results: List[Interpretation] = []
442
+ seen: set = set()
443
+
444
+ for word, readings in self.lexicon.items():
445
+ if word in seen:
446
+ continue
447
+ m = re.search(rf"\b{re.escape(word)}\b", query, re.IGNORECASE)
448
+ if not m:
449
+ continue
450
+ seen.add(word)
451
+ for reading in readings:
452
+ results.append(Interpretation(
453
+ source="lexical",
454
+ span=m.group(),
455
+ span_range=(m.start(), m.end()),
456
+ reading=f"'{word}' = {reading}",
457
+ prior=1.0 / len(readings),
458
+ rationale=f"polysemous term with {len(readings)} readings",
459
+ ))
460
+ return results
461
+
462
+ def _detect_scope(self, query: str) -> List[Interpretation]:
463
+ m = SCOPE_RE.search(query)
464
+ if not m:
465
+ return []
466
+ span_text = m.group()
467
+ span_start = m.start()
468
+ span_end = m.end()
469
+ return [
470
+ Interpretation(
471
+ source="scope",
472
+ span=span_text,
473
+ span_range=(span_start, span_end),
474
+ reading="wide negation: NOT (all X Y)",
475
+ prior=0.5,
476
+ rationale="'not' can scope over the quantifier",
477
+ ),
478
+ Interpretation(
479
+ source="scope",
480
+ span=span_text,
481
+ span_range=(span_start, span_end),
482
+ reading="narrow negation: ALL X (NOT Y)",
483
+ prior=0.5,
484
+ rationale="'not' can scope under the quantifier",
485
+ ),
486
+ ]
487
+
488
+ def _detect_presuppositional(self, query: str) -> List[Interpretation]:
489
+ for pattern, rationale in PRESUP_PATTERNS:
490
+ m = re.search(pattern, query, re.IGNORECASE)
491
+ if not m:
492
+ continue
493
+ return [
494
+ Interpretation(
495
+ source="presuppositional",
496
+ span=m.group(),
497
+ span_range=(m.start(), m.end()),
498
+ reading="presupposition holds: the event or state is real",
499
+ prior=0.7,
500
+ rationale=rationale + " — default reading",
501
+ ),
502
+ Interpretation(
503
+ source="presuppositional",
504
+ span=m.group(),
505
+ span_range=(m.start(), m.end()),
506
+ reading="presupposition fails: the event or state may not hold",
507
+ prior=0.3,
508
+ rationale=rationale + " — can be challenged in a reply",
509
+ ),
510
+ ]
511
+ return []
512
+
513
+ def _detect_framing(self, query: str) -> List[Interpretation]:
514
+ for pattern in FRAME_PATTERNS:
515
+ m = re.search(pattern, query, re.IGNORECASE)
516
+ if not m:
517
+ continue
518
+ frame_term = (m.group(1).strip() if m.groups() else "the frame")
519
+
520
+ # Truncate the frame term at the first function-word boundary
521
+ tokens = frame_term.split()
522
+ kept = []
523
+ for tok in tokens:
524
+ if tok.lower() in FRAME_STOP:
525
+ break
526
+ kept.append(tok)
527
+ frame_term = " ".join(kept).strip()
528
+ if not frame_term:
529
+ frame_term = "the frame"
530
+
531
+ # Truncate the span to match the truncated frame term
532
+ span_text = m.group()
533
+ if m.groups():
534
+ original_term = m.group(1).strip()
535
+ if frame_term != original_term:
536
+ idx = span_text.find(frame_term)
537
+ if idx >= 0:
538
+ span_text = span_text[: idx + len(frame_term)]
539
+
540
+ return [
541
+ Interpretation(
542
+ source="framing",
543
+ span=span_text,
544
+ span_range=(m.start(), m.start() + len(span_text)),
545
+ reading=f"answer strictly within the frame of '{frame_term}'",
546
+ prior=0.7,
547
+ rationale=f"frame '{frame_term}' requested explicitly",
548
+ ),
549
+ Interpretation(
550
+ source="framing",
551
+ span=span_text,
552
+ span_range=(m.start(), m.start() + len(span_text)),
553
+ reading=(
554
+ f"answer outside the frame, noting that "
555
+ f"'{frame_term}' is one choice among many"
556
+ ),
557
+ prior=0.3,
558
+ rationale="frame may be a placeholder the user wants challenged",
559
+ ),
560
+ ]
561
+ return []
562
+
563
+ def _detect_temporal(self, query: str) -> List[Interpretation]:
564
+ results: List[Interpretation] = []
565
+ for pattern, note in TEMPORAL_PATTERNS.items():
566
+ m = re.search(pattern, query, re.IGNORECASE)
567
+ if not m:
568
+ continue
569
+ results.append(Interpretation(
570
+ source="temporal",
571
+ span=m.group(),
572
+ span_range=(m.start(), m.end()),
573
+ reading=f"'{m.group()}' takes a narrow reading",
574
+ prior=0.5,
575
+ rationale=note + " — narrow vs broad",
576
+ ))
577
+ results.append(Interpretation(
578
+ source="temporal",
579
+ span=m.group(),
580
+ span_range=(m.start(), m.end()),
581
+ reading=f"'{m.group()}' takes a broad reading",
582
+ prior=0.5,
583
+ rationale=note + " — broad vs narrow",
584
+ ))
585
+ break
586
+ return results
587
+
588
+ # ------------------------------------------------------------------
589
+ # Helpers
590
+ # ------------------------------------------------------------------
591
+ def _extract_noun_phrases(
592
+ self, text: str
593
+ ) -> List[Tuple[str, int, int]]:
594
+ """Extract plausible noun phrases, filtering out pronouns and
595
+ verb continuations."""
596
+ nps: List[Tuple[str, int, int]] = []
597
+
598
+ # 1. Capitalized sequences (proper nouns) — skip pronouns
599
+ for m in re.finditer(r"\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*\b", text):
600
+ captured = m.group()
601
+ if captured.lower() in ENGLISH_PRONOUNS:
602
+ continue
603
+ nps.append((captured, m.start(), m.end()))
604
+
605
+ # 2. Two-word NPs first: "the X Y" where Y is not a stop word
606
+ two_re = re.compile(
607
+ r"\b(?:the|a|an)\s+([a-z]+)\s+([a-z]+)\b", re.IGNORECASE
608
+ )
609
+ used_starts = set()
610
+ for m in two_re.finditer(text):
611
+ second = m.group(2).lower()
612
+ if second in NP_SECOND_STOP:
613
+ continue
614
+ captured = m.group(0)
615
+ if captured.lower() in ENGLISH_PRONOUNS:
616
+ continue
617
+ used_starts.add(m.start())
618
+ nps.append((captured, m.start(), m.end()))
619
+
620
+ # 3. Single-word NPs: "the X" / "a X" / "an X"
621
+ single_re = re.compile(
622
+ r"\b(?:the|a|an)\s+([a-z]+)\b", re.IGNORECASE
623
+ )
624
+ for m in single_re.finditer(text):
625
+ if m.start() in used_starts:
626
+ continue
627
+ captured = m.group(0)
628
+ if captured.lower() in ENGLISH_PRONOUNS:
629
+ continue
630
+ nps.append((captured, m.start(), m.end()))
631
+
632
+ # 4. Deduplicate by (text, start)
633
+ seen = set()
634
+ unique = []
635
+ for np_ in nps:
636
+ key = (np_[0].lower(), np_[1])
637
+ if key in seen:
638
+ continue
639
+ seen.add(key)
640
+ unique.append(np_)
641
+ return unique
642
+
643
+ def _empty_bundle(self, query: str) -> SplitBundle:
644
+ return SplitBundle(
645
+ query=query,
646
+ interpretations=[],
647
+ ambiguity_score=0.0,
648
+ confidence=1.0,
649
+ entropy=0.0,
650
+ dominant_source="none",
651
+ sources=[],
652
+ n_interpretations=0,
653
+ )
654
+
655
+ def _render_text(self, b: SplitBundle) -> str:
656
+ lines = []
657
+ lines.append("=" * 78)
658
+ lines.append(f"hv-split -- bundle for: {b.query!r}")
659
+ lines.append("=" * 78)
660
+ lines.append("")
661
+ lines.append(f"ambiguity_score : {b.ambiguity_score:.3f}")
662
+ lines.append(f"confidence : {b.confidence:.3f}")
663
+ lines.append(f"sources : {', '.join(b.sources) or '(none)'}")
664
+ lines.append(f"interpretations : {b.n_interpretations}")
665
+ lines.append("")
666
+ if not b.interpretations:
667
+ lines.append("no ambiguity detected.")
668
+ return "\n".join(lines)
669
+ lines.append("-" * 78)
670
+ for idx, i in enumerate(b.interpretations, 1):
671
+ lines.append(
672
+ f"#{idx:<3} prior {i.prior:.3f} [{i.source}] {i.reading}"
673
+ )
674
+ lines.append(f" span : {i.span!r}")
675
+ lines.append(f" rationale : {i.rationale}")
676
+ lines.append("")
677
+ return "\n".join(lines)
678
+
679
+ def _render_markdown(self, b: SplitBundle) -> str:
680
+ lines = [
681
+ f"# hv-split bundle",
682
+ "",
683
+ f"**Query:** `{b.query}`",
684
+ "",
685
+ f"- ambiguity_score: `{b.ambiguity_score:.3f}`",
686
+ f"- confidence: `{b.confidence:.3f}`",
687
+ f"- sources: `{', '.join(b.sources) or 'none'}`",
688
+ f"- interpretations: `{b.n_interpretations}`",
689
+ "",
690
+ ]
691
+ if not b.interpretations:
692
+ lines.append("_No ambiguity detected._")
693
+ return "\n".join(lines)
694
+ lines.append("| # | prior | source | reading |")
695
+ lines.append("|---:|---:|---|---|")
696
+ for idx, i in enumerate(b.interpretations, 1):
697
+ lines.append(
698
+ f"| {idx} | {i.prior:.3f} | `{i.source}` | {i.reading} |"
699
+ )
700
+ return "\n".join(lines)
701
+
702
+ # ------------------------------------------------------------------
703
+ # Persistence
704
+ # ------------------------------------------------------------------
705
+ def save_pretrained(self, save_dir: str) -> None:
706
+ os.makedirs(save_dir, exist_ok=True)
707
+ payload = {
708
+ "config": asdict(self.config),
709
+ "lexicon": self.lexicon,
710
+ "calibrated_on": self._obs,
711
+ }
712
+ with open(os.path.join(save_dir, "config.json"), "w") as f:
713
+ json.dump(payload, f, indent=2)
714
+
715
+ @classmethod
716
+ def from_pretrained(cls, save_dir: str) -> "HVInterpret":
717
+ with open(os.path.join(save_dir, "config.json"), "r") as f:
718
+ payload = json.load(f)
719
+ cfg = HVInterpretConfig(**payload.get("config", {}))
720
+ lexicon = payload.get("lexicon", DEFAULT_POLYSEMOUS)
721
+ obj = cls(lexicon=lexicon, config=cfg)
722
+ obj._obs = int(payload.get("calibrated_on", 0))
723
+ return obj
724
+
725
+
726
+ # ---------------------------------------------------------------------------
727
+ # Demo
728
+ # ---------------------------------------------------------------------------
729
+ SAMPLE_LEXICAL = "I need to access the bank"
730
+ SAMPLE_REFERENTIAL = "She told her that the manager had changed it, and it broke"
731
+ SAMPLE_SCOPE = "All that glitters is not gold"
732
+ SAMPLE_PRESUP = "Why did the CEO resign last week?"
733
+ SAMPLE_FRAMING = (
734
+ "in the language of category theory, what is an identity?"
735
+ )
736
+ SAMPLE_TEMPORAL = "recently, has the function changed?"
737
+ SAMPLE_COMPLEX = (
738
+ "why did the current bank say recently that the function she used "
739
+ "in the language of category theory had changed?"
740
+ )
741
+ SAMPLE_CLEAN = "compute 2 + 2"
742
+
743
+
744
+ def _demo(output_dir: str = "./hv_split_output") -> None:
745
+ os.makedirs(output_dir, exist_ok=True)
746
+ m = HVInterpret()
747
+
748
+ print("=" * 78)
749
+ print("DEMO 1 -- lexical ambiguity")
750
+ print("=" * 78)
751
+ print(m.render(m.split(SAMPLE_LEXICAL)))
752
+ print()
753
+
754
+ print("=" * 78)
755
+ print("DEMO 2 -- referential ambiguity")
756
+ print("=" * 78)
757
+ print(m.render(m.split(SAMPLE_REFERENTIAL)))
758
+ print()
759
+
760
+ print("=" * 78)
761
+ print("DEMO 3 -- scope ambiguity")
762
+ print("=" * 78)
763
+ print(m.render(m.split(SAMPLE_SCOPE)))
764
+ print()
765
+
766
+ print("=" * 78)
767
+ print("DEMO 4 -- presuppositional ambiguity")
768
+ print("=" * 78)
769
+ print(m.render(m.split(SAMPLE_PRESUP)))
770
+ print()
771
+
772
+ print("=" * 78)
773
+ print("DEMO 5 -- framing ambiguity")
774
+ print("=" * 78)
775
+ print(m.render(m.split(SAMPLE_FRAMING)))
776
+ print()
777
+
778
+ print("=" * 78)
779
+ print("DEMO 6 -- temporal ambiguity")
780
+ print("=" * 78)
781
+ print(m.render(m.split(SAMPLE_TEMPORAL)))
782
+ print()
783
+
784
+ print("=" * 78)
785
+ print("DEMO 7 -- multi-source (the full bundle)")
786
+ print("=" * 78)
787
+ print(m.render(m.split(SAMPLE_COMPLEX)))
788
+ print()
789
+
790
+ print("=" * 78)
791
+ print("DEMO 8 -- unambiguous query")
792
+ print("=" * 78)
793
+ print(m.render(m.split(SAMPLE_CLEAN)))
794
+ print()
795
+
796
+ print("=" * 78)
797
+ print("DEMO 9 -- answer_each (call a callback per interpretation)")
798
+ print("=" * 78)
799
+ b = m.split(SAMPLE_LEXICAL)
800
+ for interp, answer in m.answer_each(
801
+ SAMPLE_LEXICAL,
802
+ lambda q, i: f"[would answer as: {i.reading}]",
803
+ ):
804
+ print(f" prior {interp.prior:.3f} -> {answer}")
805
+ print()
806
+
807
+ print("=" * 78)
808
+ print("DEMO 10 -- JSON output")
809
+ print("=" * 78)
810
+ print(m.render(m.split(SAMPLE_PRESUP), mode="json"))
811
+ print()
812
+
813
+ print("=" * 78)
814
+ print("DEMO 11 -- save / load round trip")
815
+ print("=" * 78)
816
+ path = os.path.join(output_dir, "split_model")
817
+ m.save_pretrained(path)
818
+ m2 = HVInterpret.from_pretrained(path)
819
+ a = m.split(SAMPLE_COMPLEX).ambiguity_score
820
+ b = m2.split(SAMPLE_COMPLEX).ambiguity_score
821
+ print(f" saved to : {path}")
822
+ print(f" file written : config.json")
823
+ print(f" reloaded : {m2!r}")
824
+ print(f" ambiguity : {a:.6f} (reloaded: {b:.6f})")
825
+ print(f" identical : {abs(a - b) < 1e-9}")
826
+
827
+ print()
828
+ print("all demos complete.")
829
+
830
+
831
+ # ---------------------------------------------------------------------------
832
+ # CLI
833
+ # ---------------------------------------------------------------------------
834
+ def _cli() -> None:
835
+ p = argparse.ArgumentParser(
836
+ description="hv-split: bundle interpretations of an ambiguous query."
837
+ )
838
+ p.add_argument("--query", type=str, default="",
839
+ help="query to split (or '-' to read from stdin)")
840
+ p.add_argument("--mode", type=str, default="text",
841
+ choices=["text", "markdown", "json"],
842
+ help="output format")
843
+ p.add_argument("--ambiguity", action="store_true",
844
+ help="print only the ambiguity score")
845
+ p.add_argument("--save-to", type=str, default="",
846
+ help="save the model to this directory")
847
+ p.add_argument("--outdir", type=str, default="./hv_split_output",
848
+ help="directory for --save-to and demo output")
849
+ args = p.parse_args()
850
+
851
+ query = sys.stdin.read() if args.query == "-" else args.query
852
+ if not query:
853
+ _demo(args.outdir)
854
+ return
855
+
856
+ m = HVInterpret()
857
+
858
+ if args.save_to:
859
+ m.save_pretrained(args.save_to)
860
+ print(f"saved to {args.save_to}", file=sys.stderr)
861
+
862
+ if args.ambiguity:
863
+ print(f"{m.ambiguity(query):.4f}")
864
+ return
865
+
866
+ b = m.split(query)
867
+ print(m.render(b, mode=args.mode))
868
+
869
+
870
+ if __name__ == "__main__":
871
+ _cli()