File size: 20,768 Bytes
1ddeb51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Domain-embedding experiment: probe models β†’ re-index β†’ eval hybrid fusion.

Compares BM25-only vs k-NN vs hybrid RRF / normalization on
``data/pediatric_eval.json`` after swapping the SentenceTransformer model.

Usage (from ``backend/``, OpenSearch up, index already populated once)::

    # Probe dims without touching the index
    PYTHONPATH=. python -m app.embed_experiment --probe

    # Eval current configured model (no re-embed)
    PYTHONPATH=. python -m app.embed_experiment --eval-only

    # Swap one model, recreate index if dim changes, force re-embed, eval
    PYTHONPATH=. python -m app.embed_experiment \\
        --model BAAI/bge-base-en-v1.5 --reindex

    # Sweep shortlist (slow β€” downloads + full re-embed per model)
    PYTHONPATH=. python -m app.embed_experiment --compare --reindex

    # Write JSON report
    PYTHONPATH=. python -m app.embed_experiment --eval-only -o /tmp/embed_eval.json

Switch production only if hybrid beats the current BM25 baseline
(top-3 β‰ˆ 0.64 / MRR β‰ˆ 0.62). Then set EMBEDDING_MODEL / EMBEDDING_DIM /
FUSION_TECHNIQUE in ``.env`` and force-refresh once.
"""
from __future__ import annotations

import argparse
import json
import logging
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any

from .config import settings
from .eval_retrieval import run as run_eval
from .opensearch_client import ensure_index, get_client, index_mapping

logger = logging.getLogger(__name__)

# Refuse accidental wipe of the live Aiven codes index.
_PROD_HOST_MARKERS = ("aivencloud.com",)
_PROD_INDEX_NAMES = frozenset({"ohip_codes"})

# Candidate models that load cleanly via sentence-transformers.
# expected_dim is a hint; --probe / load measures the real size.
CANDIDATES: list[dict[str, Any]] = [
    {
        "id": "Alibaba-NLP/gte-multilingual-base",
        "expected_dim": 768,
        "notes": "Current default (general multilingual)",
        "tier": "baseline",
    },
    {
        "id": "BAAI/bge-base-en-v1.5",
        "expected_dim": 768,
        "notes": "Strong general English bi-encoder",
        "tier": "general",
    },
    {
        "id": "BAAI/bge-small-en-v1.5",
        "expected_dim": 384,
        "notes": "Faster / smaller; requires index dim change",
        "tier": "general",
    },
    {
        "id": "NeuML/pubmedbert-base-embeddings",
        "expected_dim": 768,
        "notes": "PubMedBERT medical embeddings",
        "tier": "medical",
    },
    {
        "id": "pritamdeka/S-PubMedBert-MS-MARCO",
        "expected_dim": 768,
        "notes": "PubMedBERT fine-tuned for retrieval (MS MARCO)",
        "tier": "medical",
    },
    {
        "id": "sentence-transformers/all-MiniLM-L6-v2",
        "expected_dim": 384,
        "notes": "Cheap sanity check (not domain-tuned)",
        "tier": "sanity",
    },
]

# Historical BM25-only bar on the 50-case pediatric set (README).
# Promote hybrid only if pooled metrics clear this bar AND beat this-run BM25,
# with no specialty top3 drop > MAX_SPECIALTY_DROP vs BM25 on the same run.
BM25_BASELINE = {"top3": 0.64, "mrr": 0.62}
MAX_SPECIALTY_DROP = 0.05


@dataclass
class ProbeResult:
    model_id: str
    dim: int
    expected_dim: int | None
    ok: bool
    error: str | None = None


def _clear_embed_cache() -> None:
    from . import embeddings  # noqa: PLC0415

    embeddings._model.cache_clear()


def configure_embedding_model(model_id: str, dim: int) -> None:
    """Mutate runtime settings and drop the cached SentenceTransformer."""
    settings.embedding_model = model_id
    settings.embedding_dim = dim
    _clear_embed_cache()
    logger.info("Configured EMBEDDING_MODEL=%s EMBEDDING_DIM=%s", model_id, dim)


def probe_model(model_id: str, expected_dim: int | None = None) -> ProbeResult:
    """Load a model once and measure embedding dimension."""
    try:
        from sentence_transformers import SentenceTransformer  # noqa: PLC0415

        logger.info("Probing %s …", model_id)
        model = SentenceTransformer(model_id, trust_remote_code=True)
        vec = model.encode(
            ["OHIP paediatric consultation fee schedule"],
            normalize_embeddings=True,
            convert_to_numpy=True,
        )[0]
        dim = int(vec.shape[-1])
        return ProbeResult(
            model_id=model_id,
            dim=dim,
            expected_dim=expected_dim,
            ok=expected_dim is None or dim == expected_dim,
        )
    except Exception as exc:  # noqa: BLE001
        return ProbeResult(
            model_id=model_id,
            dim=0,
            expected_dim=expected_dim,
            ok=False,
            error=str(exc),
        )


def index_vector_dim(client=None) -> int | None:
    """Return current ``code_vector`` dimension, or None if index missing."""
    client = client or get_client()
    idx = settings.opensearch_index
    if not client.indices.exists(index=idx):
        return None
    mapping = client.indices.get_mapping(index=idx)
    props = mapping[idx]["mappings"]["properties"]
    vec = props.get("code_vector") or {}
    return vec.get("dimension")


def assert_reindex_allowed(*, allow_prod: bool = False) -> None:
    """Block --reindex against production Aiven ``ohip_codes`` unless forced."""
    url = (settings.opensearch_url or "").lower()
    idx = settings.opensearch_index or ""
    is_prod_host = any(m in url for m in _PROD_HOST_MARKERS)
    is_prod_index = idx in _PROD_INDEX_NAMES
    if is_prod_host and is_prod_index and not allow_prod:
        raise SystemExit(
            "REFUSING --reindex: OPENSEARCH_URL looks like production Aiven "
            f"and OPENSEARCH_INDEX={idx!r}.\n"
            "Use a staging index (e.g. OPENSEARCH_INDEX=ohip_codes_staging) "
            "or local Docker OpenSearch.\n"
            "To override intentionally: pass --allow-prod-reindex "
            "(destructive; recreates live codes index)."
        )


def recreate_codes_index(*, confirm: bool = True) -> None:
    """Delete and recreate ``ohip_codes`` with the current embedding_dim."""
    client = get_client()
    idx = settings.opensearch_index
    if client.indices.exists(index=idx):
        if confirm:
            logger.warning("Deleting index '%s' to apply dim=%s", idx, settings.embedding_dim)
        client.indices.delete(index=idx)
    # ensure_index creates mapping from settings.embedding_dim
    ensure_index(client)
    # Belt-and-suspenders: if index somehow existed with wrong dim, recreate.
    current = index_vector_dim(client)
    if current != settings.embedding_dim:
        client.indices.delete(index=idx)
        client.indices.create(index=idx, body=index_mapping())
        logger.info("Recreated '%s' with dimension %s", idx, settings.embedding_dim)


def force_reembed() -> dict:
    from .ingestion import ingest  # noqa: PLC0415

    summary = ingest(force=True)
    logger.info("Force re-embed complete: %s", summary)
    return summary


def _best_hybrid(results: dict[str, dict]) -> tuple[str, dict] | None:
    hybrids = {k: v for k, v in results.items() if k.startswith("hybrid_")}
    if not hybrids:
        return None
    best_k = max(hybrids, key=lambda k: (hybrids[k]["top3"], hybrids[k]["mrr"]))
    return best_k, hybrids[best_k]


def _specialty_drop_ok(bm25: dict, hybrid: dict) -> tuple[bool, list[str]]:
    """True if no specialty loses more than MAX_SPECIALTY_DROP top3 vs BM25."""
    notes: list[str] = []
    bm25_specs = bm25.get("by_specialty") or {}
    hyb_specs = hybrid.get("by_specialty") or {}
    ok = True
    for spec, b in bm25_specs.items():
        h = hyb_specs.get(spec) or {}
        b3 = float(b.get("top3") or 0)
        h3 = float(h.get("top3") or 0)
        drop = b3 - h3
        notes.append(
            f"  specialty {spec}: bm25_top3={b3} hybrid_top3={h3} drop={drop:.3f}"
        )
        if drop > MAX_SPECIALTY_DROP + 1e-9:
            ok = False
    return ok, notes


def recommend(eval_out: dict) -> str:
    results = eval_out["results"]
    bm25 = results.get("bm25_only") or {}
    knn = results.get("knn_only") or {}
    best = _best_hybrid(results)
    sets = eval_out.get("sets") or {}
    lines = [
        f"Model: {settings.embedding_model} (dim={settings.embedding_dim})",
        f"Cases: n={eval_out.get('n_cases')} sets={sets}"
        + (f" smoke={eval_out['smoke']}" if eval_out.get("smoke") else ""),
        f"Index dim: {eval_out.get('index_dim')}  vectors_ok={eval_out.get('vectors_ok')}",
        f"BM25-only:  top3={bm25.get('top3')}  mrr={bm25.get('mrr')}",
        f"k-NN-only:  top3={knn.get('top3')}  mrr={knn.get('mrr')}",
    ]
    for note in eval_out.get("notes") or []:
        lines.append(f"NOTE: {note}")
    if eval_out.get("vectors_ok") is False:
        lines.append(
            "KEEP: FUSION_TECHNIQUE=bm25 β€” k-NN/hybrid skipped "
            "(embed dim β‰  index dim). Re-embed on staging before promoting hybrid."
        )
        return "\n".join(lines)
    if best:
        name, m = best
        lines.append(f"Best hybrid: {name}  top3={m['top3']}  mrr={m['mrr']}")
        drop_ok, drop_notes = _specialty_drop_ok(bm25, m)
        lines.extend(drop_notes)
        beats_bm25 = (m["top3"], m["mrr"]) > (
            bm25.get("top3", 0),
            bm25.get("mrr", 0),
        )
        beats_hist = (m["top3"], m["mrr"]) >= (
            BM25_BASELINE["top3"],
            BM25_BASELINE["mrr"],
        )
        if beats_bm25 and beats_hist and drop_ok:
            lines.append(
                "RECOMMEND: switch production to this model + "
                f"FUSION_TECHNIQUE matching '{name}' "
                "(hybrid beats BM25, clears historical bar, specialty drops OK)."
            )
        elif beats_bm25 and not drop_ok:
            lines.append(
                "KEEP: FUSION_TECHNIQUE=bm25 β€” hybrid beats pooled BM25 but "
                f"drops a specialty by more than {MAX_SPECIALTY_DROP:.0%} top3."
            )
        elif beats_bm25:
            lines.append(
                "CAUTION: hybrid beats BM25 on this run but is still below the "
                f"historical bar (top3β‰₯{BM25_BASELINE['top3']}, "
                f"mrrβ‰₯{BM25_BASELINE['mrr']}). Keep investigating."
            )
        else:
            lines.append(
                "KEEP: FUSION_TECHNIQUE=bm25 β€” hybrid does not beat BM25-only yet."
            )
    return "\n".join(lines)


def print_table(title: str, eval_out: dict) -> None:
    print(f"\n{title}")
    print(f"n_cases={eval_out['n_cases']}  model={settings.embedding_model}  "
          f"dim={settings.embedding_dim}")
    print(f"{'config':<28} {'top1':>6} {'top3':>6} {'mrr':>6}")
    print("-" * 50)
    for cfg, m in eval_out["results"].items():
        print(f"{cfg:<28} {m['top1']:>6} {m['top3']:>6} {m['mrr']:>6}")
    print()
    print(recommend(eval_out))
    print()


def run_for_model(
    model_id: str,
    *,
    reindex: bool,
    expected_dim: int | None = None,
    eval_sets: list[str] | None = None,
    smoke: int | None = None,
    bm25_only: bool = False,
    allow_prod_reindex: bool = False,
) -> dict[str, Any]:
    probe = probe_model(model_id, expected_dim)
    if probe.error or probe.dim <= 0:
        return {
            "model_id": model_id,
            "probe": asdict(probe),
            "error": probe.error or "probe failed",
        }

    configure_embedding_model(model_id, probe.dim)
    ingest_summary = None
    if reindex:
        assert_reindex_allowed(allow_prod=allow_prod_reindex)
        current_dim = index_vector_dim()
        if current_dim != probe.dim:
            recreate_codes_index()
        else:
            # Same dim: still force re-embed so vectors match the new model.
            logger.info(
                "Index dim already %s β€” force re-embedding without delete",
                current_dim,
            )
            ensure_index(get_client())
        ingest_summary = force_reembed()
    else:
        current_dim = index_vector_dim()
        if current_dim is not None and current_dim != probe.dim:
            raise RuntimeError(
                f"Index dim is {current_dim} but model produces {probe.dim}. "
                "Re-run with --reindex."
            )

    eval_out = run_eval(sets=eval_sets, smoke=smoke, bm25_only=bm25_only)
    return {
        "model_id": model_id,
        "probe": asdict(probe),
        "ingest": ingest_summary,
        "eval": eval_out,
        "recommendation": recommend(eval_out),
    }


def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    p = argparse.ArgumentParser(
        description="Domain embedding experiment + hybrid fusion re-eval"
    )
    p.add_argument(
        "--probe",
        action="store_true",
        help="Probe candidate model dimensions (no index changes)",
    )
    p.add_argument(
        "--eval-only",
        action="store_true",
        help="Run fusion eval with the currently configured embedding model",
    )
    p.add_argument(
        "--model",
        type=str,
        default=None,
        help="HuggingFace / local SentenceTransformer id to evaluate",
    )
    p.add_argument(
        "--compare",
        action="store_true",
        help="Sweep shortlisted candidates (use with --reindex)",
    )
    p.add_argument(
        "--tier",
        choices=("baseline", "general", "medical", "sanity", "all"),
        default="all",
        help="Filter --probe / --compare shortlist (default: all)",
    )
    p.add_argument(
        "--reindex",
        action="store_true",
        help="Recreate index if dim changes and force re-embed every code",
    )
    p.add_argument(
        "--allow-prod-reindex",
        action="store_true",
        help="Override safety check that blocks --reindex on Aiven ohip_codes",
    )
    p.add_argument(
        "--sets",
        type=str,
        default="pediatric,family",
        help="Comma-separated eval sets (default: pediatric,family)",
    )
    p.add_argument(
        "--smoke",
        type=int,
        default=None,
        help="Evaluate only N cases (round-robin across sets) for cheap iteration",
    )
    p.add_argument(
        "--bm25-only",
        action="store_true",
        help="Skip k-NN / hybrid eval (BM25 metrics only)",
    )
    p.add_argument(
        "-o",
        "--output",
        type=str,
        default=None,
        help="Write full JSON report to this path",
    )
    p.add_argument("-v", "--verbose", action="store_true")
    return p.parse_args(argv)


def _eval_sets_from_args(args: argparse.Namespace) -> list[str]:
    return [s.strip() for s in (args.sets or "").split(",") if s.strip()]


def _filtered_candidates(tier: str) -> list[dict[str, Any]]:
    if tier == "all":
        return list(CANDIDATES)
    return [c for c in CANDIDATES if c["tier"] == tier]


def main(argv: list[str] | None = None) -> int:
    args = _parse_args(argv)
    logging.basicConfig(
        level=logging.INFO if args.verbose else logging.WARNING,
        format="%(levelname)s %(name)s: %(message)s",
    )

    eval_sets = _eval_sets_from_args(args)
    report: dict[str, Any] = {
        "bm25_historical_baseline": BM25_BASELINE,
        "max_specialty_drop": MAX_SPECIALTY_DROP,
        "eval_sets": eval_sets,
        "smoke": args.smoke,
        "current_settings": {
            "embedding_model": settings.embedding_model,
            "embedding_dim": settings.embedding_dim,
            "fusion_technique": settings.fusion_technique,
            "index_dim": index_vector_dim(),
        },
    }

    if args.probe:
        rows = []
        print(f"\n{'model':<45} {'exp':>5} {'got':>5} {'ok':>4}")
        print("-" * 65)
        for c in _filtered_candidates(args.tier):
            pr = probe_model(c["id"], c.get("expected_dim"))
            rows.append(asdict(pr) | {"notes": c.get("notes"), "tier": c.get("tier")})
            status = "yes" if pr.ok and not pr.error else "NO"
            print(
                f"{c['id']:<45} {c.get('expected_dim') or '-':>5} "
                f"{pr.dim or '-':>5} {status:>4}"
            )
            if pr.error:
                print(f"  error: {pr.error[:200]}")
        report["probes"] = rows
        if args.output:
            Path(args.output).write_text(json.dumps(report, indent=2))
            print(f"\nWrote {args.output}")
        return 0

    if args.reindex:
        try:
            assert_reindex_allowed(allow_prod=args.allow_prod_reindex)
        except SystemExit as exc:
            print(str(exc), file=sys.stderr)
            return 2

    if args.eval_only:
        out = run_eval(sets=eval_sets, smoke=args.smoke, bm25_only=args.bm25_only)
        print_table("Retrieval eval (current model)", out)
        report["runs"] = [
            {
                "model_id": settings.embedding_model,
                "eval": out,
                "recommendation": recommend(out),
            }
        ]
        if args.output:
            Path(args.output).write_text(json.dumps(report, indent=2))
            print(f"Wrote {args.output}")
        return 0

    if args.compare:
        if not args.reindex:
            print(
                "WARNING: --compare without --reindex only works if every "
                "model shares the current index dim and vectors already match "
                "that model (unlikely). Prefer --compare --reindex.",
                file=sys.stderr,
            )
        runs = []
        for c in _filtered_candidates(args.tier):
            print(f"\n=== {c['id']} ({c['tier']}) ===")
            try:
                result = run_for_model(
                    c["id"],
                    reindex=args.reindex,
                    expected_dim=c.get("expected_dim"),
                    eval_sets=eval_sets,
                    smoke=args.smoke,
                    bm25_only=args.bm25_only,
                    allow_prod_reindex=args.allow_prod_reindex,
                )
            except Exception as exc:  # noqa: BLE001
                result = {"model_id": c["id"], "error": str(exc)}
                logger.exception("Failed on %s", c["id"])
            runs.append(result)
            if "eval" in result:
                print_table(f"Eval β€” {c['id']}", result["eval"])
            elif result.get("error"):
                print(f"ERROR: {result['error']}")

        # Summary leaderboard
        print("\n=== Leaderboard (by best hybrid top3, then mrr) ===")
        print(f"{'model':<45} {'bm25_t3':>8} {'best_hyb':>18} {'hyb_t3':>7} {'hyb_mrr':>7}")
        print("-" * 90)
        board = []
        for r in runs:
            if "eval" not in r:
                continue
            res = r["eval"]["results"]
            bm25_t3 = res.get("bm25_only", {}).get("top3")
            best = _best_hybrid(res)
            if not best:
                continue
            name, m = best
            board.append((r["model_id"], bm25_t3, name, m["top3"], m["mrr"]))
        board.sort(key=lambda t: (t[3], t[4]), reverse=True)
        for model_id, bm25_t3, name, t3, mrr in board:
            short = name.replace("hybrid_", "")
            print(
                f"{model_id:<45} {bm25_t3:>8} {short:>18} {t3:>7} {mrr:>7}"
            )

        report["runs"] = runs
        if args.output:
            Path(args.output).write_text(json.dumps(report, indent=2))
            print(f"\nWrote {args.output}")
        return 0

    if args.model:
        result = run_for_model(
            args.model,
            reindex=args.reindex,
            eval_sets=eval_sets,
            smoke=args.smoke,
            bm25_only=args.bm25_only,
            allow_prod_reindex=args.allow_prod_reindex,
        )
        if result.get("error"):
            print(f"ERROR: {result['error']}", file=sys.stderr)
            return 1
        print_table(f"Eval β€” {args.model}", result["eval"])
        report["runs"] = [result]
        if args.output:
            Path(args.output).write_text(json.dumps(report, indent=2))
            print(f"Wrote {args.output}")
        print(
            "\nTo keep this model in production, set in .env:\n"
            f"  EMBEDDING_MODEL={settings.embedding_model}\n"
            f"  EMBEDDING_DIM={settings.embedding_dim}\n"
            "  FUSION_TECHNIQUE=rrf   # or normalization / bm25 per recommendation\n"
            "then: curl -X POST 'http://localhost:8080/admin/refresh?force=true'\n"
            "Also recreate ohip_feedback / ohip_remittance if EMBEDDING_DIM changed."
        )
        return 0

    print(
        "Specify one of: --probe | --eval-only | --model ID | --compare\n"
        "See: python -m app.embed_experiment -h",
        file=sys.stderr,
    )
    return 2


if __name__ == "__main__":
    raise SystemExit(main())