Skip to content

Commit 9ffb279

Browse files
committed
feat: config-driven model swapping (registries, self-describing embedders, index provenance)
Make embedder / search-backend / reranker / CoT swaps config-only, with no code edits. - Non-lossy config load: overlay the validated dump onto the raw config so undeclared knobs reach their `.get()` consumers instead of being dropped by model_dump; a new runtime knob no longer needs a pydantic field. Revives rag/query_expansion.no_think. - Plugin registry (trialmatchai.plugins) with build_search_backend / build_embedder factories; retires by-name construction across 6 call sites; selection Literals widen to str with a fail-fast "registered: [...]" error. - Self-describing embedders (dim / native_metric / is_asymmetric / pooling / fingerprint); the vector metric now follows the embedder (vector_metric defaults to None); fixes the asymmetric embed_queries path dropping query_instruction. - Index embedder-provenance sidecar (_embedder.json) + auto-reembed on an embedder swap; pre-provenance indexes are trusted (no rebuild). - Reranker builds through the shared vLLM loader, inheriting quantization / kv_cache_dtype / max_model_len / LoRA-fallback and the shared engine cache; scoring is unchanged. - Model catalog: `embedder: {model: "medcpt"}` one-line swap resolves the full spec. - Tighten verbose comments across the package. Adds 27 tests; full suite green; ruff clean; existing configs and persisted indexes resolve identically (no behavior drift). Bumps version 0.5.0 -> 0.6.0.
1 parent cdef1c3 commit 9ffb279

46 files changed

Lines changed: 1122 additions & 282 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
55

66
[project]
77
name = "trialmatchai"
8-
version = "0.5.0"
8+
version = "0.6.0"
99
description = "AI-driven patient-to-clinical-trial matching: hybrid retrieval + LLM eligibility reasoning."
1010
readme = "README.md"
1111
requires-python = ">=3.11,<3.12"

scripts/benchmark_embedder.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,13 @@ def main() -> None:
116116
ap.add_argument("--out", required=True)
117117
ap.add_argument("--reuse-index", action="store_true", help="Reuse an existing per-track index instead of rebuilding.")
118118
ap.add_argument("--vector-weight", type=float, default=0.5, help="Hybrid blend: score = (1-w)*text + w*vector (default 0.5).")
119+
ap.add_argument("--cutoffs", default=None, help="Comma-separated recall@k cutoffs (default 100,200,300,500,700,1000,2000).")
119120
args = ap.parse_args()
120121

122+
if args.cutoffs:
123+
global KS
124+
KS = [int(x) for x in args.cutoffs.split(",")]
125+
121126
registry = json.loads(Path(args.registry).read_text())
122127
if args.embedder not in registry:
123128
raise SystemExit(f"Unknown embedder '{args.embedder}'. Known: {[k for k in registry if not k.startswith('_')]}")

src/trialmatchai/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
from __future__ import annotations
22

3-
__version__ = "0.5.0"
3+
__version__ = "0.6.0"

src/trialmatchai/cli/build.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,7 @@ def main() -> int:
7676
return 1
7777

7878
# Build the concept store BEFORE prepare/index so build_system can link entities
79-
# between prepare and index, giving the search tables concept IDs instead of
80-
# leaving every entity at concept_store_unavailable.
79+
# in between, giving search tables concept IDs instead of concept_store_unavailable.
8180
link_concepts = bool(args.concepts or args.concepts_csv)
8281
if link_concepts:
8382
logger.info("=== build: concepts stage ===")

src/trialmatchai/cli/build_concepts.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,8 @@ def _concepts_fingerprint(
117117
embedder_revision: str | None = None,
118118
skip_embeddings: bool = False,
119119
) -> str:
120-
# Embedder identity must be in the fingerprint: stored vectors must share the
121-
# query-time embedding space, so a model swap (or toggling embeddings) invalidates
122-
# the store. Mirrors _prepare_signature in orchestration.py.
120+
# Embedder identity must be in the fingerprint: stored vectors must match the
121+
# query-time embedding space. Mirrors _prepare_signature in orchestration.py.
123122
return digest(
124123
_CONCEPTS_STATE_VERSION,
125124
sources,
@@ -179,9 +178,8 @@ def run_build_concepts(
179178
if not force and not concept_csv:
180179
ready, rows_present = _concept_table_ready(db_path, table_name)
181180
recorded = _read_concepts_fingerprint(db_path)
182-
# Backward-compatible: a pre-marker store (recorded is None) still skips on
183-
# presence; only an explicit fingerprint mismatch rebuilds, so a large existing
184-
# store is never needlessly re-embedded.
181+
# Backward-compatible: a pre-marker store (recorded is None) skips on presence;
182+
# only an explicit fingerprint mismatch rebuilds, sparing a needless re-embed.
185183
if ready and (recorded is None or recorded == concepts_fp):
186184
logger.info(
187185
"Concept store already present at %s/%s (%s concepts); skipping. "

src/trialmatchai/cli/healthcheck.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import sys
55

66
from trialmatchai.config.config_loader import load_config
7-
from trialmatchai.search import LanceDBSearchBackend
7+
from trialmatchai.search import build_search_backend
88
from trialmatchai.services.preflight import run_preflight_checks
99
from trialmatchai.utils.logging_config import setup_logging
1010

@@ -37,7 +37,7 @@ def main() -> int:
3737

3838
config = load_config(args.config)
3939
issues = 0
40-
search_backend = LanceDBSearchBackend.from_config(config)
40+
search_backend = build_search_backend(config)
4141

4242
preflight_issues = run_preflight_checks(
4343
config,

src/trialmatchai/cli/update_registry.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
RegistryUpdater,
1818
normalize_keywords,
1919
)
20-
from trialmatchai.search import InMemorySearchBackend, LanceDBSearchBackend
20+
from trialmatchai.search import InMemorySearchBackend, build_search_backend
2121
from trialmatchai.utils.logging_config import setup_logging
2222

2323
logger = setup_logging(__name__)
@@ -118,7 +118,7 @@ def main() -> int:
118118
timeout=float(registry_cfg.get("request_timeout", 30.0)),
119119
rate_limit_per_second=float(registry_cfg.get("rate_limit_per_second", 2.0)),
120120
)
121-
backend = InMemorySearchBackend() if args.dry_run else LanceDBSearchBackend.from_config(config)
121+
backend = InMemorySearchBackend() if args.dry_run else build_search_backend(config)
122122
embedder = _NullEmbedder() if args.dry_run else _build_embedder(config)
123123
entity_annotator = None if args.dry_run else _build_entity_annotator(config, embedder)
124124

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"bge-m3": {
3+
"backend": "hf",
4+
"model_name": "BAAI/bge-m3",
5+
"pooling": "cls",
6+
"normalize": true,
7+
"native_metric": "cosine",
8+
"max_length": 512
9+
},
10+
"medcpt": {
11+
"backend": "hf",
12+
"model_name": "ncbi/MedCPT-Article-Encoder",
13+
"query_model_name": "ncbi/MedCPT-Query-Encoder",
14+
"pooling": "cls",
15+
"normalize": false,
16+
"native_metric": "dot",
17+
"max_length": 512,
18+
"query_max_length": 64
19+
}
20+
}

src/trialmatchai/config/config_loader.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,106 @@ def load_config(config_path: str | os.PathLike[str] | None = None) -> Dict[str,
3535
with resolved_config.open("r", encoding="utf-8") as f:
3636
raw: Dict[str, Any] = json.load(f)
3737
raw = apply_env_overrides(deepcopy(raw))
38+
raw = _expand_catalog(raw)
3839
settings = TrialMatchSettings.model_validate(raw)
39-
cfg = settings.to_dict()
40+
# Non-lossy load: overlay the validated dump ONTO raw rather than replacing it, so declared
41+
# fields take validated values (coercions + defaults win) while undeclared knobs survive to
42+
# `.get()` consumers instead of being dropped by model_dump (a pure-config knob needs no field).
43+
cfg = _deep_merge(raw, settings.to_dict())
44+
_warn_unknown_config_keys(raw, settings)
4045
cfg = normalize_config_paths(cfg, resolved_config)
4146
return cfg
4247

4348

49+
_CATALOGS = {"embedder": "embedders.json"}
50+
51+
52+
def _load_catalog(filename: str) -> Dict[str, Any]:
53+
path = Path(__file__).resolve().parent / "catalog" / filename
54+
if not path.exists():
55+
return {}
56+
try:
57+
return json.loads(path.read_text(encoding="utf-8"))
58+
except Exception:
59+
logger.warning("Could not read model catalog %s (ignoring).", path)
60+
return {}
61+
62+
63+
def _expand_catalog(raw: Dict[str, Any]) -> Dict[str, Any]:
64+
"""Expand a ``model: "<name>"`` shorthand into the full catalog spec (e.g.
65+
``embedder: {model: "medcpt"}`` resolves dim/metric/pooling/templates).
66+
67+
Resolution order: inline > catalog > code defaults. An unknown name falls back to
68+
``model_name`` so the raw-path form (``{model_name: "org/model"}``) keeps working. Runs before
69+
validation and the non-lossy merge, so downstream sees only the expanded section.
70+
"""
71+
for section, catalog_file in _CATALOGS.items():
72+
block = raw.get(section)
73+
if not isinstance(block, dict):
74+
continue
75+
name = block.get("model")
76+
if not name:
77+
continue
78+
entry = _load_catalog(catalog_file).get(name)
79+
if entry is None:
80+
logger.warning(
81+
"config[%s].model=%r not in catalog; using it as a raw model_name.", section, name
82+
)
83+
block.setdefault("model_name", name)
84+
block.pop("model", None)
85+
continue
86+
merged = dict(entry)
87+
for key, value in block.items():
88+
if key != "model":
89+
merged[key] = value # inline wins over catalog
90+
raw[section] = merged
91+
return raw
92+
93+
94+
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
95+
"""Recursively merge ``override`` onto ``base``; ``override`` wins on leaf conflicts.
96+
97+
Overlays the validated dump onto raw config so validated values win for declared keys while
98+
undeclared keys survive. Only dict/dict pairs recurse; other types (lists included) are
99+
replaced wholesale by ``override``'s value.
100+
"""
101+
merged = dict(base)
102+
for key, over_val in override.items():
103+
base_val = merged.get(key)
104+
if isinstance(base_val, dict) and isinstance(over_val, dict):
105+
merged[key] = _deep_merge(base_val, over_val)
106+
else:
107+
merged[key] = over_val
108+
return merged
109+
110+
111+
def _warn_unknown_config_keys(raw: Dict[str, Any], settings: TrialMatchSettings) -> None:
112+
"""Log config keys that survive passthrough but aren't in the schema, so a typo is visible.
113+
114+
Non-lossy loading keeps mistyped keys rather than dropping them; this warning replaces the
115+
previously silent drop and never removes anything.
116+
"""
117+
fields = type(settings).model_fields
118+
# A section is "known" under its field name OR its serialization alias (e.g. ``global_`` is
119+
# written ``global`` in JSON), so an aliased section isn't mis-flagged as a typo.
120+
known_sections = set(fields) | {f.alias for f in fields.values() if f.alias}
121+
for section in raw:
122+
if section not in known_sections:
123+
logger.warning(
124+
"config: unknown top-level section %r (kept but not schema-validated — check for a typo)",
125+
section,
126+
)
127+
for name in fields:
128+
sub = getattr(settings, name, None)
129+
extra = getattr(sub, "model_extra", None)
130+
if extra:
131+
logger.warning(
132+
"config[%s]: passthrough keys not in schema (kept): %s",
133+
name,
134+
", ".join(sorted(extra)),
135+
)
136+
137+
44138
def resolve_config_path(
45139
config_path: str | os.PathLike[str] | None = None,
46140
) -> Path:

src/trialmatchai/config/settings.py

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ class ModelQuantizationSettings(BaseModel):
6666
class ModelSettings(BaseModel):
6767
base_model: str
6868
quantization: ModelQuantizationSettings
69-
cot_adapter_path: str
69+
# None runs the base CoT model with no LoRA adapter (e.g. MedGemma, already instruction-tuned).
70+
cot_adapter_path: str | None = None
7071
reranker_model_path: str
7172
reranker_adapter_path: str
7273
trust_remote_code: bool = False
@@ -84,16 +85,25 @@ class GlobalSettings(BaseModel):
8485

8586

8687
class SearchBackendSettings(BaseModel):
87-
backend: Literal["lancedb"] = "lancedb"
88+
# Resolved via the plugin registry at build_search_backend (raises on a typo), so a plain str
89+
# not a Literal that needs widening per backend. ``type`` preferred; ``backend`` is legacy.
90+
type: str | None = None
91+
backend: str = "lancedb"
8892
db_path: str = "data/search"
8993
trials_table: str = "trials"
9094
criteria_table: str = "criteria"
9195
candidate_limit: int = Field(1000, ge=1)
92-
# Vector similarity for the ANN index and re-ranker. "cosine" (default) suits normalized
93-
# embedders (bge-m3); "dot" suits inner-product dual-encoders (e.g. MedCPT).
94-
vector_metric: Literal["cosine", "dot"] = "cosine"
96+
# ANN index/re-ranker similarity. None follows the embedder (cosine if normalized, dot if not,
97+
# e.g. MedCPT); set explicitly to pin it regardless of the embedder.
98+
vector_metric: Literal["cosine", "dot"] | None = None
9599
# Hybrid blend of the first-level score: (1 - vector_weight) * text + vector_weight * vector.
96100
vector_weight: float = Field(0.5, ge=0.0, le=1.0)
101+
# Re-embed the corpus with the config embedder at index-build time (vs reusing prepare-time
102+
# vectors). Required to switch embedders: otherwise the new-model query dim-mismatches the old
103+
# index vectors and silently falls back to BM25.
104+
reembed_index: bool = False
105+
# Keep undeclared backend knobs; they reach the tolerant .get() consumer via the non-lossy loader.
106+
model_config = ConfigDict(extra="allow")
97107

98108

99109
class RegistrySettings(BaseModel):
@@ -111,7 +121,10 @@ class RegistrySettings(BaseModel):
111121

112122

113123
class EmbedderSettings(BaseModel):
114-
backend: Literal["hf", "hashing"] = "hf"
124+
# Embedder family resolved via the plugin registry at build_embedder; plain str (not a Literal)
125+
# so a new family needs no schema edit. ``type`` preferred; ``backend`` is legacy (hf | hashing).
126+
type: str | None = None
127+
backend: str = "hf"
115128
model_name: str = "BAAI/bge-m3"
116129
# A distinct query_model_name selects an asymmetric dual-encoder (separate document/query
117130
# encoders sharing one space, e.g. MedCPT's article/query encoders); None = symmetric.
@@ -128,6 +141,11 @@ class EmbedderSettings(BaseModel):
128141
hashing_dimensions: int = Field(64, ge=1)
129142
# Instruction prepended to queries only, for instruction-tuned embedders (e.g. Qwen3-Embedding).
130143
query_instruction: str | None = None
144+
# Vector metric this embedder's space is trained for; None derives from ``normalize`` (cosine
145+
# if normalized, dot if not). The search backend reads it so the metric follows the embedder.
146+
native_metric: Literal["cosine", "dot"] | None = None
147+
# Keep undeclared embedder knobs (they reach the tolerant .get() consumer via the non-lossy loader).
148+
model_config = ConfigDict(extra="allow")
131149

132150
@field_validator("pooling")
133151
@classmethod
@@ -163,11 +181,9 @@ class SearchSettings(BaseModel):
163181
max_trials_second_level: int = Field(100, ge=1)
164182
# Keep the top 1/N of reranked second-level trials before CoT (N=1 keeps all).
165183
second_level_keep_divisor: int = Field(3, ge=1)
166-
# How the second-level shortlist combines the first-level (retrieval) and second-level
167-
# (reranker) rankings. "rrf" fuses the two by rank, so a strong retrieval hit keeps a
168-
# floor and is not evicted when the reranker fails to score its criteria; "score_sum"
169-
# is the earlier behaviour of adding the two raw scores (whose scales differ, so one
170-
# signal dominates and can drop retrieval-ranked trials).
184+
# How the shortlist fuses first-level (retrieval) and second-level (reranker) rankings. "rrf"
185+
# fuses by rank, so a strong retrieval hit isn't evicted when the reranker fails to score it;
186+
# "score_sum" adds raw scores (mismatched scales let one signal dominate and drop retrieval hits).
171187
shortlist_fusion: Literal["rrf", "score_sum"] = "rrf"
172188
shortlist_rrf_k: int = Field(60, ge=1)
173189
shortlist_first_level_weight: float = Field(1.0, ge=0.0)
@@ -208,6 +224,10 @@ class RagSettings(BaseModel):
208224
backend: Literal["vllm", "transformers"] = "vllm"
209225
batch_size: int = Field(4, ge=1)
210226
max_trials_rag: int = Field(20, ge=1)
227+
# Suppress chain-of-thought <think> in the eligibility stage for reasoning models (Qwen3):
228+
# sends enable_thinking=False / a /no_think prefix and strips residual think tags.
229+
no_think: bool = False
230+
model_config = ConfigDict(extra="allow")
211231

212232

213233
class VllmSettings(BaseModel):
@@ -220,10 +240,24 @@ class VllmSettings(BaseModel):
220240
gpu_memory_utilization: float = Field(0.5, gt=0.0, le=1.0)
221241
max_model_len: int = Field(8192, ge=256)
222242
tensor_parallel_size: int = Field(1, ge=1)
243+
# In-flight vLLM weight quantization (e.g. "bitsandbytes" NF4 4-bit) so a large base model (32B
244+
# CoT) fits one card without a pre-quantized checkpoint. "" = none (bf16). Must be a field or
245+
# it is dropped before the loader.
246+
quantization: str = ""
247+
# Disable vLLM's custom all-reduce kernel. Required for tensor_parallel_size>1 on multi-GPU
248+
# nodes WITHOUT NVLink (A40/L40 over PCIe), where it dies with CUDA 'invalid argument'; falls
249+
# back to NCCL all-reduce (pair with NCCL_P2P_DISABLE=1).
250+
disable_custom_all_reduce: bool = False
251+
# Skip CUDA graph capture, freeing ~6-7GB per GPU for KV cache. Useful when a large model
252+
# (e.g. a 27B tensor-parallel across 48GB cards) would otherwise starve the KV cache.
253+
enforce_eager: bool = False
223254
# fp8 KV cache halves KV memory so a large window (e.g. 8192) fits on one 48GB card;
224255
# max_num_seqs caps concurrency so that tight KV budget does not thrash.
225256
kv_cache_dtype: Literal["auto", "fp8", "fp8_e4m3", "fp8_e5m2"] | None = None
226257
max_num_seqs: int | None = Field(None, ge=1)
258+
# Keep undeclared vLLM runtime knobs (e.g. swap_space, enable_prefix_caching) — they reach
259+
# load_vllm_engine's tolerant .get() via the non-lossy loader, no new field required.
260+
model_config = ConfigDict(extra="allow")
227261

228262

229263
class CotSettings(BaseModel):
@@ -238,6 +272,7 @@ class LLMRerankerSettings(BaseModel):
238272
# to fit both engines on a smaller card (e.g. 48GB A40/L40).
239273
gpu_memory_utilization: float = Field(0.4, gt=0.0, le=1.0)
240274
tensor_parallel_size: int = Field(1, ge=1)
275+
model_config = ConfigDict(extra="allow")
241276

242277

243278
class QueryExpansionSettings(BaseModel):
@@ -251,6 +286,9 @@ class QueryExpansionSettings(BaseModel):
251286
max_main_conditions: int = Field(11, ge=1)
252287
max_other_conditions: int = Field(50, ge=1)
253288
trust_remote_code: bool = False
289+
# Suppress <think> during expansion for reasoning models (read at query_expansion.py:_resolve_settings).
290+
no_think: bool = False
291+
model_config = ConfigDict(extra="allow")
254292

255293

256294
class ReportingSettings(BaseModel):

0 commit comments

Comments
 (0)