File size: 11,789 Bytes
f109bf8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Hybrid evidence retrieval — dense + lexical + metadata + provenance.

In-memory stand-in for sql/001_agent_and_evidence_indexes.sql.
Production SQL uses vector(1536); tests use fixed dim-8 vectors (no live DB).

Merge / rerank rule (deterministic):
  score = dense_cosine + lexical_overlap + source_authority - penalties
  sort by (-score, evidence_id)
  penalties: missing required parent_evidence when policy requires it

Hybrid note: a chunk with weak dense but strong lexical can still rank
if lexical_overlap compensates (no hard dense floor).
"""

from __future__ import annotations

import hashlib
import math
import re
from dataclasses import dataclass, field
from typing import Any, Iterable, Sequence


# Production pgvector column is vector(1536); test corpus uses tiny fixed vectors.
EMBEDDING_DIM_TEST = 8
EMBEDDING_DIM_PRODUCTION = 1536


@dataclass
class EvidenceRecord:
    """Fields mirror the SQL ``evidence`` table."""

    evidence_id: str
    source_id: str
    source_type: str
    document_id: str | None
    location: str | None
    content: str
    content_hash: str
    embedding: list[float] | None
    source_authority: float = 0.5
    parent_evidence: tuple[str, ...] = ()
    created_at: str | None = None


def _content_hash(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]


def _tokenize(text: str) -> set[str]:
    return {t for t in re.findall(r"[a-z0-9]+", text.lower()) if len(t) > 1}


def cosine_similarity(a: Sequence[float], b: Sequence[float]) -> float:
    if not a or not b or len(a) != len(b):
        return 0.0
    dot = sum(x * y for x, y in zip(a, b))
    na = math.sqrt(sum(x * x for x in a))
    nb = math.sqrt(sum(y * y for y in b))
    if na == 0.0 or nb == 0.0:
        return 0.0
    return dot / (na * nb)


def lexical_score(query: str, content: str) -> float:
    """Token overlap stand-in for ``content_tsv @@ plainto_tsquery``."""
    q = _tokenize(query)
    if not q:
        return 0.0
    c = _tokenize(content)
    if not c:
        return 0.0
    return len(q & c) / len(q)


def empty_bundle() -> dict[str, Any]:
    """EvidenceBundle-shaped dict with no hits (contracts.md)."""
    return {
        "facts": [],
        "sources": [],
        "provenance": [],
        "retrieval_metadata": [],
    }


def seed_cobol_corpus() -> list[EvidenceRecord]:
    """Fixed COBOL-modernization corpus (dim-8 embeddings for tests).

    Axes (approx): [cobol, balance, payment, schema, pricing, vol, greek, junk]
    """
    # Strong COBOL / balance / payment cluster
    copybook = EvidenceRecord(
        evidence_id="ev_copybook_01",
        source_id="src_copybook_acct",
        source_type="copybook",
        document_id="ACCTREC.cpy",
        location="01-ACCOUNT-BALANCE",
        content=(
            "01 ACCOUNT-RECORD. "
            "05 ACCOUNT-BALANCE PIC S9(9)V99. "
            "05 PAYMENT-AMOUNT PIC S9(7)V99. "
            "05 LAST-PAYMENT-DATE PIC X(8)."
        ),
        content_hash="",
        embedding=[0.95, 0.90, 0.85, 0.20, 0.05, 0.0, 0.0, 0.0],
        source_authority=0.95,
        parent_evidence=("ev_provenance_root",),
        created_at="2026-01-01T00:00:00Z",
    )
    cobol_src = EvidenceRecord(
        evidence_id="ev_cobol_src_01",
        source_id="src_cobol_pay",
        source_type="cobol_source",
        document_id="PAYROLL.cbl",
        location="PROCEDURE DIVISION / CALC-BALANCE",
        content=(
            "COMPUTE ACCOUNT-BALANCE = ACCOUNT-BALANCE - PAYMENT-AMOUNT. "
            "IF ACCOUNT-BALANCE < ZERO THEN PERFORM OVERDRAFT-CHECK. "
            "MOVE PAYMENT-AMOUNT TO WS-LAST-PAYMENT."
        ),
        content_hash="",
        embedding=[0.92, 0.88, 0.90, 0.15, 0.05, 0.0, 0.0, 0.0],
        source_authority=0.90,
        parent_evidence=("ev_provenance_root",),
        created_at="2026-01-01T00:00:00Z",
    )
    db2 = EvidenceRecord(
        evidence_id="ev_db2_schema_01",
        source_id="src_db2_acct",
        source_type="db2_schema",
        document_id="ACCT.DDL",
        location="TABLE ACCOUNT_BALANCES",
        content=(
            "CREATE TABLE ACCOUNT_BALANCES ("
            "ACCT_ID CHAR(12), BALANCE DECIMAL(11,2), "
            "LAST_PAYMENT DECIMAL(9,2), UPDATED_TS TIMESTAMP)."
        ),
        content_hash="",
        embedding=[0.70, 0.80, 0.55, 0.95, 0.05, 0.0, 0.0, 0.0],
        source_authority=0.85,
        parent_evidence=("ev_provenance_root",),
        created_at="2026-01-01T00:00:00Z",
    )
    # Should NOT win on a COBOL balance query (pricing / quant cluster)
    quant = EvidenceRecord(
        evidence_id="ev_quant_pricing_01",
        source_id="src_quant_report",
        source_type="quant_report",
        document_id="PRICING_Q3.pdf",
        location="section:monte_carlo",
        content=(
            "Monte Carlo pricing of exotic options. "
            "Implied volatility surface and greek sensitivities. "
            "No COBOL or account balance fields."
        ),
        content_hash="",
        embedding=[0.05, 0.05, 0.05, 0.05, 0.95, 0.90, 0.85, 0.10],
        source_authority=0.80,
        parent_evidence=("ev_provenance_root",),
        created_at="2026-01-01T00:00:00Z",
    )
    # Low-authority junk for provenance filter tests
    junk = EvidenceRecord(
        evidence_id="ev_junk_lowauth_01",
        source_id="src_web_scrape",
        source_type="web_scrape",
        document_id="random-blog.html",
        location="body",
        content=(
            "Someone said COBOL payment balance might work like a spreadsheet. "
            "Unverified forum post."
        ),
        content_hash="",
        embedding=[0.60, 0.55, 0.50, 0.10, 0.10, 0.0, 0.0, 0.9],
        source_authority=0.15,
        parent_evidence=(),
        created_at="2026-01-01T00:00:00Z",
    )
    # Weak dense (far from COBOL cluster) but strong lexical on balance/payment/cobol
    # — hybrid: lexical can lift it into results when dense alone would not.
    lexical_lift = EvidenceRecord(
        evidence_id="ev_lexical_lift_01",
        source_id="src_glossary",
        source_type="glossary",
        document_id="LEGACY-GLOSSARY.txt",
        location="entry:PAYMENT-BALANCE",
        content=(
            "cobol payment balance glossary entry: "
            "PAYMENT and BALANCE fields on ACCOUNT records in legacy batch."
        ),
        content_hash="",
        # Oriented away from COBOL cluster → weak cosine vs COBOL query
        embedding=[0.10, 0.15, 0.10, 0.05, 0.40, 0.35, 0.30, 0.20],
        source_authority=0.70,
        parent_evidence=("ev_provenance_root",),
        created_at="2026-01-01T00:00:00Z",
    )

    records = [copybook, cobol_src, db2, quant, junk, lexical_lift]
    for r in records:
        r.content_hash = _content_hash(r.content)
    return records


# Canonical COBOL payment/balance query embedding (dim 8)
COBOL_BALANCE_QUERY = "cobol account payment balance CALC-BALANCE"
COBOL_BALANCE_QUERY_VEC = [0.93, 0.91, 0.88, 0.25, 0.05, 0.0, 0.0, 0.0]


@dataclass
class EvidenceIndex:
    """In-memory evidence index (no Postgres required)."""

    records: list[EvidenceRecord] = field(default_factory=list)

    def __post_init__(self) -> None:
        if not self.records:
            self.records = seed_cobol_corpus()
        self._by_id = {r.evidence_id: r for r in self.records}

    def add(self, record: EvidenceRecord) -> None:
        if not record.content_hash:
            record.content_hash = _content_hash(record.content)
        self.records.append(record)
        self._by_id[record.evidence_id] = record


def retrieve(
    query: str,
    *,
    query_embedding: Sequence[float] | None = None,
    index: EvidenceIndex | None = None,
    source_types: Iterable[str] | None = None,
    document_ids: Iterable[str] | None = None,
    min_authority: float = 0.0,
    require_parent_evidence: bool = False,
    top_k: int = 10,
) -> dict[str, Any]:
    """Hybrid retrieve → EvidenceBundle-shaped dict (facts/sources/provenance/…).

    Agents never receive bare text without provenance fields on each source.
    """
    if not query or not query.strip():
        return empty_bundle()

    idx = index or EvidenceIndex()
    st_filter = set(source_types) if source_types is not None else None
    doc_filter = set(document_ids) if document_ids is not None else None
    q_emb = list(query_embedding) if query_embedding is not None else None

    scored: list[tuple[float, EvidenceRecord, dict[str, float]]] = []
    for rec in idx.records:
        # Metadata filters
        if st_filter is not None and rec.source_type not in st_filter:
            continue
        if doc_filter is not None and rec.document_id not in doc_filter:
            continue
        # Provenance / authority
        if rec.source_authority < min_authority:
            continue
        if require_parent_evidence and not rec.parent_evidence:
            continue

        dense = 0.0
        if q_emb is not None and rec.embedding is not None:
            dense = cosine_similarity(q_emb, rec.embedding)
        lex = lexical_score(query, rec.content)
        authority = float(rec.source_authority)
        penalty = 0.0
        if require_parent_evidence and not rec.parent_evidence:
            penalty += 1.0  # defensive; already filtered above

        score = dense + lex + authority - penalty
        parts = {
            "dense": dense,
            "lexical": lex,
            "source_authority": authority,
            "penalty": penalty,
            "score": score,
        }
        scored.append((score, rec, parts))

    # Deterministic rerank: score desc, then evidence_id asc
    scored.sort(key=lambda t: (-t[0], t[1].evidence_id))
    top = scored[:top_k]

    if not top:
        return empty_bundle()

    facts: list[dict[str, Any]] = []
    sources: list[dict[str, Any]] = []
    provenance: list[dict[str, Any]] = []
    retrieval_metadata: list[dict[str, Any]] = []

    for score, rec, parts in top:
        facts.append(
            {
                "evidence_id": rec.evidence_id,
                "summary": rec.content[:160],
                "source_type": rec.source_type,
            }
        )
        sources.append(
            {
                "evidence_id": rec.evidence_id,
                "source_id": rec.source_id,
                "source_type": rec.source_type,
                "document_id": rec.document_id,
                "location": rec.location,
                "content_hash": rec.content_hash,
                "source_authority": rec.source_authority,
                "parent_evidence": list(rec.parent_evidence),
                "content": rec.content,
                "retrieval_score": score,
            }
        )
        provenance.append(
            {
                "evidence_id": rec.evidence_id,
                "source_id": rec.source_id,
                "content_hash": rec.content_hash,
                "parent_evidence": list(rec.parent_evidence),
                "source_authority": rec.source_authority,
            }
        )
        retrieval_metadata.append(
            {
                "evidence_id": rec.evidence_id,
                "dense": parts["dense"],
                "lexical": parts["lexical"],
                "source_authority": parts["source_authority"],
                "penalty": parts["penalty"],
                "score": parts["score"],
                "embedding_dim_note": (
                    f"test vectors dim={EMBEDDING_DIM_TEST}; "
                    f"production SQL uses vector({EMBEDDING_DIM_PRODUCTION})"
                ),
            }
        )

    return {
        "facts": facts,
        "sources": sources,
        "provenance": provenance,
        "retrieval_metadata": retrieval_metadata,
    }