File size: 7,712 Bytes
b2931f4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""End-to-end smoke test for retrieval.

Runs a fixed canary set of questions through `retrieval.vector.search` and
flags structural breakage (zero results, filter violations, suspiciously
low scores). Does NOT grade answer quality β€” that's Day 4's eval harness.

Usage:
    uv run python -m finrag.eval.smoke

Exits with code 0 on full pass, 1 if any case fails. Useful in CI later.
"""

from __future__ import annotations

import json
import sys
import time
from pathlib import Path
from typing import Any

from pydantic import BaseModel

from finrag.retrieval.rerank import rerank_search
from finrag.retrieval.vector import RetrievedChunk

# parse.py-style root resolution
REPO_ROOT = Path(__file__).resolve().parents[4]
RESULTS_PATH = REPO_ROOT / "data" / "smoke_results.json"

# Smoke now hits rerank_search β€” same path as /query. Score is Cohere
# Rerank v3's relevance_score in [0, 1]. A relevant top-1 is typically
# > 0.5 for well-formed queries. < 0.1 means the reranker thinks none of
# the candidates actually answer the query β€” usually a sign of retrieval
# upstream returning irrelevant candidates.
SCORE_FLOOR = 0.10


# ── Canary cases ─────────────────────────────────────────────────────────
class Case(BaseModel):
    name: str
    question: str
    top_k: int = 5
    ticker: str | None = None
    fiscal_year: int | None = None
    chunk_type: str | None = None
    # Optional soft expectation β€” we don't fail the case if this doesn't
    # match, but we surface it in output so you eyeball whether the right
    # company is showing up in the top results.
    expect_ticker_in_top: str | None = None


CASES: list[Case] = [
    Case(
        name="01_aapl_services_revenue",
        question="How did Apple's services revenue change in 2023?",
        expect_ticker_in_top="AAPL",
    ),
    Case(
        name="02_tsla_rnd_spend",
        question="How much did Tesla spend on research and development?",
        expect_ticker_in_top="TSLA",
    ),
    Case(
        name="03_supply_chain_risks",
        question="What are the risks related to supply chain disruptions?",
    ),
    Case(
        name="04_jpm_net_interest_income",
        question="What was JPMorgan's net interest income?",
        expect_ticker_in_top="JPM",
    ),
    Case(
        name="05_aapl_2024_filter",
        question="total revenue",
        ticker="AAPL",
        fiscal_year=2024,
        expect_ticker_in_top="AAPL",
    ),
    Case(
        name="06_tables_only_filter",
        question="income statement",
        chunk_type="table",
    ),
    Case(
        name="07_tsla_deliveries_multi_year",
        question="How have Tesla vehicle deliveries changed year over year?",
        expect_ticker_in_top="TSLA",
        top_k=8,
    ),
    Case(
        name="08_cross_company_ai",
        question="risks related to artificial intelligence",
        top_k=8,
    ),
]


# ── Execution ─────────────────────────────────────────────────────────────
class CaseResult(BaseModel):
    name: str
    passed: bool
    reasons: list[str]
    n_chunks: int
    top_score: float | None
    top_ticker: str | None
    top_fiscal_year: int | None
    duration_ms: int


def _check_case(case: Case, chunks: list[RetrievedChunk]) -> CaseResult:
    """Apply pass/fail rules to a case's results."""
    reasons: list[str] = []

    if not chunks:
        reasons.append("returned zero chunks")
        return CaseResult(
            name=case.name,
            passed=False,
            reasons=reasons,
            n_chunks=0,
            top_score=None,
            top_ticker=None,
            top_fiscal_year=None,
            duration_ms=0,  # filled in by caller
        )

    top = chunks[0]

    # Score floor β€” catches embedder mismatches (e.g. wrong input_type).
    if top.score < SCORE_FLOOR:
        reasons.append(f"top score {top.score:.3f} below floor {SCORE_FLOOR}")

    # Filter compliance β€” every returned chunk must satisfy any filter we set.
    if case.ticker:
        bad = [c for c in chunks if c.ticker != case.ticker]
        if bad:
            reasons.append(
                f"ticker filter violated: {len(bad)}/{len(chunks)} chunks have "
                f"ticker != {case.ticker}"
            )
    if case.fiscal_year:
        bad = [c for c in chunks if c.fiscal_year != case.fiscal_year]
        if bad:
            reasons.append(
                f"fiscal_year filter violated: {len(bad)}/{len(chunks)} chunks have "
                f"fiscal_year != {case.fiscal_year}"
            )
    if case.chunk_type:
        bad = [c for c in chunks if c.chunk_type != case.chunk_type]
        if bad:
            reasons.append(
                f"chunk_type filter violated: {len(bad)}/{len(chunks)} chunks have "
                f"chunk_type != {case.chunk_type}"
            )

    # Soft expectation β€” log only, don't fail
    if case.expect_ticker_in_top:
        top_tickers = {c.ticker for c in chunks[:3]}
        if case.expect_ticker_in_top not in top_tickers:
            reasons.append(
                f"⚠ soft: expected {case.expect_ticker_in_top} in top-3 tickers, "
                f"got {sorted(top_tickers)}"
            )

    # Only hard failures (filter violations, empty results, score floor)
    # count toward `passed`. Soft warnings start with "⚠".
    hard_failures = [r for r in reasons if not r.startswith("⚠")]

    return CaseResult(
        name=case.name,
        passed=not hard_failures,
        reasons=reasons,
        n_chunks=len(chunks),
        top_score=top.score,
        top_ticker=top.ticker,
        top_fiscal_year=top.fiscal_year,
        duration_ms=0,
    )


def run_case(case: Case) -> CaseResult:
    t0 = time.perf_counter()
    chunks = rerank_search(
        question=case.question,
        top_k=case.top_k,
        ticker=case.ticker,
        fiscal_year=case.fiscal_year,
        chunk_type=case.chunk_type,
    )
    elapsed_ms = int((time.perf_counter() - t0) * 1000)
    result = _check_case(case, chunks)
    result.duration_ms = elapsed_ms
    return result


# ── CLI ───────────────────────────────────────────────────────────────────
def main() -> int:
    print(f"Running {len(CASES)} smoke cases against retrieval.search\n")

    results: list[CaseResult] = []
    for case in CASES:
        r = run_case(case)
        results.append(r)

        status = "PASS" if r.passed else "FAIL"
        top = f"{r.top_ticker} FY{r.top_fiscal_year} @ {r.top_score:.3f}" if r.top_score else "β€”"
        print(f"  [{status}] {r.name:35s}  n={r.n_chunks}  top={top:24s}  {r.duration_ms}ms")
        for reason in r.reasons:
            print(f"          {reason}")

    n_pass = sum(1 for r in results if r.passed)
    n_total = len(results)
    print(f"\n{n_pass}/{n_total} cases passed.")

    # Persist results for future diffing / regression tracking
    payload: dict[str, Any] = {
        "summary": {
            "passed": n_pass,
            "total": n_total,
            "all_passed": n_pass == n_total,
        },
        "cases": [r.model_dump() for r in results],
    }
    RESULTS_PATH.parent.mkdir(parents=True, exist_ok=True)
    RESULTS_PATH.write_text(json.dumps(payload, indent=2))
    print(f"Wrote {RESULTS_PATH}")

    return 0 if n_pass == n_total else 1


if __name__ == "__main__":
    sys.exit(main())