File size: 22,634 Bytes
1f71c7d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
"""PALIMPSESTE — Evolution layer: closing the gap with transformer LLMs.

5 cognitive upgrades that make PALIMPSESTE competitive on reasoning quality:

1. **ResponseSynthesizer**: when multiple facts are retrieved, combine them
   into a novel, coherent answer — not just echo the closest match.

2. **EntityTracker**: tracks entities mentioned in conversation and resolves
   pronouns ("it", "that", "this thing") across turns.

3. **QueryRouter**: classifies intent (factual, comparison, how-to, opinion,
   code) and routes to the best retrieval strategy.

4. **CodePatternBank**: stores and retrieves code snippets indexed by
   language + task.

5. **ConfidenceCalibrator**: estimates uncertainty from retrieval quality
   and hedges appropriately ("I think...", "I'm not sure but...").

All operate within the HV paradigm. No transformer. No gradient.
"""

from __future__ import annotations

import re
import time
from dataclasses import dataclass, field
from typing import Optional
import numpy as np

__all__ = [
    "ResponseSynthesizer",
    "EntityTracker",
    "QueryRouter",
    "CodePatternBank",
    "ConfidenceCalibrator",
    "SynthesisResult",
    "Entity",
    "QueryIntent",
    "CodePattern",
    "CalibrationResult",
]


# ================================================================
# 1. RESPONSE SYNTHESIZER
# ================================================================

@dataclass
class SynthesisResult:
    """Result of multi-fact synthesis."""
    answer: str
    n_facts_used: int
    facts_used: list[str]
    synthesis_type: str  # "single" | "merge" | "compare" | "compose"
    confidence: float


class ResponseSynthesizer:
    """Combine multiple retrieved facts into novel answers.

    When the user asks a question that touches multiple topics (e.g., "tell
    me about Python and how it compares to Java"), a simple retrieval returns
    one fact. This synthesizer:

    1. Decomposes the query into key topics
    2. Retrieves facts for each topic independently
    3. Combines them using template-based synthesis

    This is what GPT-4 does naturally via attention over its context.
    PALIMPSESTE does it via explicit multi-query retrieval + template merge.
    """

    # Synthesis templates for combining facts
    TEMPLATES = {
        'compare': "regarding {topic_a}: {fact_a}. regarding {topic_b}: {fact_b}.",
        'merge': "{fact_a} additionally, {fact_b}.",
        'compose': "{fact_a} this means that {fact_b}.",
        'single': "{fact_a}",
    }

    def __init__(self, conv) -> None:
        """conv: a Conversation object with respond() and _fuzzy_match()."""
        self.conv = conv

    def synthesize(self, question: str, max_new_tokens: int = 100) -> SynthesisResult:
        """Synthesize a response from potentially multiple facts.

        1. Try direct retrieval first.
        2. If the question mentions multiple topics, retrieve each separately.
        3. Combine using the appropriate template.
        """
        # Step 1: try direct retrieval
        self.conv.reset()
        direct = self.conv.respond(question, max_new_tokens=max_new_tokens,
                                    temperature=0.0, seed=0)

        from .chat import FALLBACK_RESPONSE
        if direct and direct != FALLBACK_RESPONSE and direct.strip():
            return SynthesisResult(
                answer=direct,
                n_facts_used=1,
                facts_used=[direct],
                synthesis_type='single',
                confidence=1.0,
            )

        # Step 2: decompose into topics
        topics = self._extract_topics(question)
        if len(topics) <= 1:
            return SynthesisResult(
                answer=direct if direct != FALLBACK_RESPONSE else "",
                n_facts_used=0,
                facts_used=[],
                synthesis_type='single',
                confidence=0.0,
            )

        # Step 3: retrieve facts for each topic
        facts: dict[str, str] = {}
        for topic in topics:
            self.conv.reset()
            topic_q = self._make_question(topic, question)
            fact = self.conv.respond(topic_q, max_new_tokens=60,
                                      temperature=0.0, seed=0)
            if fact and fact != FALLBACK_RESPONSE and fact.strip():
                facts[topic] = fact.strip()

        if not facts:
            return SynthesisResult(
                answer="",
                n_facts_used=0,
                facts_used=[],
                synthesis_type='single',
                confidence=0.0,
            )

        # Step 4: synthesize
        if len(facts) == 1:
            fact = list(facts.values())[0]
            return SynthesisResult(
                answer=fact, n_facts_used=1, facts_used=[fact],
                synthesis_type='single', confidence=0.8,
            )

        # Multiple facts — choose template
        question_lower = question.lower()
        if any(w in question_lower for w in ['compare', 'difference', 'vs', 'versus', 'or']):
            synth_type = 'compare'
        elif any(w in question_lower for w in ['and', 'also', 'both', 'plus']):
            synth_type = 'merge'
        else:
            synth_type = 'compose'

        fact_list = list(facts.values())
        topic_list = list(facts.keys())

        template = self.TEMPLATES[synth_type]
        if synth_type == 'compare' and len(topic_list) >= 2:
            answer = template.format(
                topic_a=topic_list[0], fact_a=fact_list[0],
                topic_b=topic_list[1], fact_b=fact_list[1],
            )
        else:
            # Merge facts sequentially
            parts = []
            for i, (topic, fact) in enumerate(facts.items()):
                if i == 0:
                    parts.append(fact)
                else:
                    parts.append(fact.lower() if fact[0:1].isupper() else fact)
            answer = '. '.join(parts) + '.'

        return SynthesisResult(
            answer=answer,
            n_facts_used=len(facts),
            facts_used=list(facts.values()),
            synthesis_type=synth_type,
            confidence=0.7,
        )

    def _extract_topics(self, question: str) -> list[str]:
        """Extract key topics from a multi-topic question."""
        q = question.lower().strip().rstrip('?')

        # Pattern: "X and Y", "X vs Y", "X or Y", "compare X and Y"
        for sep in [' vs ', ' versus ', ' or ']:
            if sep in q:
                parts = q.split(sep)
                if len(parts) >= 2:
                    topics = []
                    for p in parts:
                        p = p.strip()
                        p = re.sub(r'^(what is|what are|tell me about|explain)\s+', '', p)
                        p = re.sub(r'^(compare|difference between)\s+', '', p)
                        topics.append(p.strip())
                    return [t for t in topics if t]

        if ' and ' in q:
            parts = q.split(' and ')
            if len(parts) >= 2:
                topics = []
                for p in parts:
                    p = re.sub(r'^(what is|what are|tell me about|explain)\s+', '', p.strip())
                    topics.append(p.strip())
                return [t for t in topics if t]

        # Pattern: "X's Y" → topic = X
        m = re.match(r"what\s+is\s+(.+?)'s\s+(.+)", q)
        if m:
            return [m.group(1).strip()]

        return []

    def _make_question(self, topic: str, original: str) -> str:
        """Make a retrieval question for a single topic."""
        topic = topic.strip().rstrip('.?,!')
        # Simple "what is X" format works best for associative retrieval
        return f"what is {topic}"


# ================================================================
# 2. ENTITY TRACKER
# ================================================================

@dataclass
class Entity:
    """A tracked entity in conversation."""
    name: str
    first_mentioned_turn: int
    last_mentioned_turn: int
    mentions: int = 1
    attributes: dict[str, str] = field(default_factory=dict)


class EntityTracker:
    """Track entities and resolve pronouns across conversation turns.

    When the user says "what about its speed?" after talking about Python,
    the tracker knows "its" = Python and rewrites the query.

    GPT-4 does this implicitly via attention. PALIMPSESTE does it
    explicitly via entity tracking — more transparent and debuggable.
    """

    PRONOUNS = {'it', 'its', 'this', 'that', 'these', 'those', 'he', 'she',
                'his', 'her', 'they', 'their', 'them', 'one'}

    def __init__(self) -> None:
        self._entities: dict[str, Entity] = {}
        self._turn: int = 0
        self._last_entity: str | None = None

    def update(self, user_text: str) -> str:
        """Process user input: extract entities and resolve pronouns.

        Returns the (possibly rewritten) user text with pronouns replaced.
        """
        self._turn += 1
        words = user_text.split()

        # Check for pronouns BEFORE extracting new entities
        has_pronoun = any(p in user_text.lower().split() for p in self.PRONOUNS)
        pronoun_to_resolve = self._last_entity if (has_pronoun and self._last_entity) else None

        # Extract entities: meaningful words that aren't pronouns or stop words
        stop_words = self.PRONOUNS | {'the', 'a', 'an', 'is', 'are',
                'was', 'were', 'what', 'who', 'how', 'why', 'when', 'where',
                'do', 'does', 'did', 'can', 'could', 'would', 'should',
                'will', 'about', 'tell', 'me', 'and', 'or', 'but', 'to',
                'of', 'in', 'on', 'at', 'for', 'with', 'from', 'by', 'its'}
        new_entities_this_turn: list[str] = []
        for word in words:
            clean = word.strip('.,!?;:"\'()[]').lower()
            if len(clean) < 3 or clean in stop_words:
                continue
            if clean in self._entities:
                ent = self._entities[clean]
                ent.last_mentioned_turn = self._turn
                ent.mentions += 1
                new_entities_this_turn.append(clean)
            else:
                self._entities[clean] = Entity(
                    name=clean,
                    first_mentioned_turn=self._turn,
                    last_mentioned_turn=self._turn,
                )
                new_entities_this_turn.append(clean)

        # Update last entity only if we found new ones this turn
        if new_entities_this_turn:
            self._last_entity = new_entities_this_turn[-1]

        # Resolve pronouns using the PREVIOUS entity (before this turn's extraction)
        resolved = user_text
        if pronoun_to_resolve:
            for pronoun in ['its', 'it', 'this', 'that', 'these', 'those']:
                resolved = re.sub(
                    rf'\b{pronoun}\b',
                    pronoun_to_resolve,
                    resolved,
                    count=1,
                    flags=re.IGNORECASE,
                )

        return resolved

    def get_entity(self, name: str) -> Entity | None:
        return self._entities.get(name.lower())

    def get_last_entity(self) -> str | None:
        return self._last_entity

    @property
    def n_entities(self) -> int:
        return len(self._entities)

    def all_entities(self) -> list[Entity]:
        return sorted(self._entities.values(),
                       key=lambda e: e.last_mentioned_turn, reverse=True)

    def reset(self) -> None:
        self._entities.clear()
        self._turn = 0
        self._last_entity = None


# ================================================================
# 3. QUERY ROUTER
# ================================================================

@dataclass
class QueryIntent:
    """Classified intent of a user query."""
    intent: str  # "factual" | "comparison" | "howto" | "opinion" | "code" | "definition" | "greeting"
    topics: list[str]
    confidence: float
    strategy: str  # recommended retrieval strategy


class QueryRouter:
    """Classify intent and route to the best retrieval strategy.

    Different question types need different approaches:
    - Factual: direct retrieval
    - Comparison: multi-topic synthesis
    - How-to: step-by-step retrieval
    - Code: code pattern bank
    - Opinion: template-based with hedging
    - Definition: direct retrieval with template

    GPT-4 handles all these via the same attention mechanism.
    PALIMPSESTE routes explicitly — more efficient and transparent.
    """

    INTENT_PATTERNS = [
        ('greeting', re.compile(r'\b(hello|hi|hey|good morning|good evening|howdy|sup)\b', re.I), 'direct'),
        ('comparison', re.compile(r'\b(compare|difference|vs|versus|better than|worse than|or)\b', re.I), 'synthesize'),
        ('howto', re.compile(r'\b(how (do|to|can)|steps? to|way to|guide)\b', re.I), 'stepwise'),
        ('code', re.compile(r'\b(code|function|program|script|write|implement|algorithm|debug)\b', re.I), 'code'),
        ('opinion', re.compile(r'\b(think|opinion|believe|feel|should i)\b', re.I), 'hedge'),
        ('definition', re.compile(r'\b(what is|what are|define|definition|explain|meaning)\b', re.I), 'direct'),
        ('factual', re.compile(r'\b(who|where|when|why|which|whose)\b', re.I), 'direct'),
    ]

    def __init__(self) -> None:
        pass

    def classify(self, question: str) -> QueryIntent:
        """Classify the intent of a question."""
        q_lower = question.lower().strip()

        # Try each pattern in order
        for intent, pattern, strategy in self.INTENT_PATTERNS:
            if pattern.search(q_lower):
                topics = self._extract_topics(question)
                return QueryIntent(
                    intent=intent,
                    topics=topics,
                    confidence=0.85,
                    strategy=strategy,
                )

        # Default: factual
        return QueryIntent(
            intent='factual',
            topics=[],
            confidence=0.5,
            strategy='direct',
        )

    def _extract_topics(self, question: str) -> list[str]:
        """Extract key topic words from the question."""
        q = question.lower()
        # Remove question words and stop words
        stop = {'what', 'is', 'are', 'was', 'were', 'the', 'a', 'an',
                'who', 'where', 'when', 'why', 'how', 'do', 'does', 'did',
                'can', 'could', 'would', 'should', 'will', 'tell', 'me',
                'about', 'of', 'in', 'on', 'at', 'for', 'with', 'and', 'or',
                'to', 'from', 'by', 'this', 'that', 'it', 'its'}
        words = re.findall(r'[a-z]+', q)
        topics = [w for w in words if w not in stop and len(w) > 2]
        return topics[:5]


# ================================================================
# 4. CODE PATTERN BANK
# ================================================================

@dataclass
class CodePattern:
    """A stored code pattern."""
    id: int
    language: str
    task: str
    code: str
    description: str
    keywords: list[str]


class CodePatternBank:
    """Store and retrieve code snippets.

    A dedicated memory for code patterns, indexed by language and task.
    When the user asks "how to read a file in Python", the bank retrieves
    the most relevant code pattern.

    GPT-4 has code in its training data mixed with everything else.
    PALIMPSESTE has a dedicated code bank — faster and more precise.
    """

    def __init__(self) -> None:
        self._patterns: list[CodePattern] = []
        self._next_id: int = 0

    def store(self, language: str, task: str, code: str,
              description: str = "", keywords: list[str] | None = None) -> CodePattern:
        """Store a code pattern."""
        pattern = CodePattern(
            id=self._next_id,
            language=language.lower(),
            task=task.lower(),
            code=code,
            description=description,
            keywords=keywords or [],
        )
        self._next_id += 1
        self._patterns.append(pattern)
        return pattern

    def retrieve(self, query: str, language: str | None = None,
                 top_k: int = 3) -> list[tuple[CodePattern, float]]:
        """Retrieve code patterns matching a query.

        Uses keyword overlap scoring (simple but effective for code).
        """
        query_lower = query.lower()
        query_words = set(re.findall(r'[a-z_]+', query_lower))

        scored: list[tuple[CodePattern, float]] = []
        for pattern in self._patterns:
            if language and pattern.language != language.lower():
                continue

            # Score by keyword overlap
            pattern_words = set(pattern.keywords) | {pattern.task} | set(pattern.language.split())
            code_words = set(re.findall(r'[a-z_]+', pattern.code.lower()))
            pattern_words |= code_words

            overlap = len(query_words & pattern_words)
            total = len(query_words)
            score = overlap / max(total, 1)

            # Boost for task match
            if pattern.task in query_lower:
                score += 0.3

            if score > 0:
                scored.append((pattern, min(score, 1.0)))

        scored.sort(key=lambda x: x[1], reverse=True)
        return scored[:top_k]

    def load_defaults(self) -> None:
        """Load a set of common code patterns."""
        defaults = [
            ('python', 'read file', 'with open("file.txt") as f:\n    content = f.read()',
             'Read a text file', ['open', 'read', 'file']),
            ('python', 'write file', 'with open("file.txt", "w") as f:\n    f.write("hello")',
             'Write to a text file', ['write', 'file']),
            ('python', 'function', 'def square(x):\n    return x * x',
             'Define a function', ['def', 'function', 'return']),
            ('python', 'class', 'class Dog:\n    def __init__(self, name):\n        self.name = name',
             'Define a class', ['class', 'init', 'self']),
            ('python', 'loop', 'for i in range(10):\n    print(i)',
             'For loop', ['for', 'range', 'loop']),
            ('python', 'list comprehension', 'squares = [x*x for x in range(10)]',
             'List comprehension', ['list', 'comprehension']),
            ('python', 'dictionary', 'd = {"key": "value"}\nprint(d["key"])',
             'Dictionary operations', ['dict', 'dictionary', 'key']),
            ('python', 'try except', 'try:\n    result = 1 / 0\nexcept ZeroDivisionError:\n    print("error")',
             'Error handling', ['try', 'except', 'error']),
            ('python', 'sort list', 'sorted_list = sorted(my_list, key=lambda x: x.name)',
             'Sort a list', ['sort', 'sorted']),
            ('python', 'json parse', 'import json\ndata = json.loads(\'{"key": "value"}\')',
             'Parse JSON', ['json', 'parse', 'loads']),
        ]
        for lang, task, code, desc, kw in defaults:
            self.store(lang, task, code, desc, kw)

    @property
    def n_patterns(self) -> int:
        return len(self._patterns)


# ================================================================
# 5. CONFIDENCE CALIBRATOR
# ================================================================

@dataclass
class CalibrationResult:
    """Result of confidence calibration."""
    text: str
    raw_confidence: float
    calibrated_confidence: float
    hedged: bool
    hedge_phrase: str | None


class ConfidenceCalibrator:
    """Calibrate confidence and hedge appropriately.

    The raw confidence from Hamming similarity is often overconfident
    (sim=1.0 for exact matches, even if the answer is wrong). This
    calibrator adjusts:

    1. Checks if the retrieved answer is actually relevant to the question
    2. Applies hedging for uncertain answers
    3. Adds "I think" / "I'm not sure" when appropriate

    GPT-4 calibrates via RLHF. PALIMPSESTE calibrates via retrieval quality.
    """

    HEDGE_PHRASES = {
        'low': "i'm not sure, but ",
        'medium': "i think ",
        'high': "",  # no hedge for high confidence
    }

    def __init__(self, low_threshold: float = 0.3, high_threshold: float = 0.7) -> None:
        self.low_threshold = low_threshold
        self.high_threshold = high_threshold

    def calibrate(self, text: str, raw_confidence: float,
                  question: str = "", answer: str = "") -> CalibrationResult:
        """Calibrate confidence and apply hedging.

        Parameters
        ----------
        text : str
            The response text to potentially hedge.
        raw_confidence : float
            Raw confidence from retrieval (0-1).
        question : str
            The original question (for relevance check).
        answer : str
            The retrieved answer (for length check).

        Returns
        -------
        CalibrationResult
        """
        calibrated = raw_confidence

        # Factor 1: answer length — very short answers are slightly less reliable
        if answer and len(answer.strip()) < 5:
            calibrated *= 0.8

        # Factor 2: question-answer relevance (word overlap)
        if question and answer:
            q_words = set(re.findall(r'[a-z]+', question.lower()))
            a_words = set(re.findall(r'[a-z]+', answer.lower()))
            stop = {'what', 'is', 'the', 'a', 'an', 'of', 'to', 'in', 'for',
                    'and', 'or', 'how', 'who', 'why', 'when', 'where'}
            q_meaningful = q_words - stop
            a_meaningful = a_words - stop
            if q_meaningful:
                overlap = len(q_meaningful & a_meaningful) / len(q_meaningful)
                # Blend: 70% original confidence + 30% relevance
                calibrated = 0.7 * raw_confidence + 0.3 * overlap

        # Determine hedge level
        if calibrated < self.low_threshold:
            hedge_level = 'low'
        elif calibrated < self.high_threshold:
            hedge_level = 'medium'
        else:
            hedge_level = 'high'

        hedge = self.HEDGE_PHRASES[hedge_level]
        hedged_text = text
        hedged = False

        if hedge and text.strip():
            # Don't double-hedge
            if not text.lower().startswith(("i'm not sure", "i think", "i believe")):
                hedged_text = hedge + text
                hedged = True

        return CalibrationResult(
            text=hedged_text,
            raw_confidence=raw_confidence,
            calibrated_confidence=calibrated,
            hedged=hedged,
            hedge_phrase=hedge if hedge else None,
        )