reranker / src /config.py
Hemprasad Badgujar
Add bm25 prefilter stage; unify candidates cache; validate reasoning quality
cd5f9d4
Raw History Blame Contribute Delete
20.2 kB
"""Central configuration β€” ONE place for every tunable in the engine.
Values load from ``config/settings.yaml`` (anchored to the repo root, so it resolves from the CLI, the
Streamlit app, or a container alike); env vars ``RERANKER_*`` OVERRIDE the YAML (e.g.
``RERANKER_DEBUG__CHECKPOINTS=1`` beats whatever settings.yaml says), via ``YamlConfigSettingsSource`` in
``settings_customise_sources`` below β€” NOT by reading the YAML and passing it as constructor kwargs, which would
make it pydantic-settings' highest-priority source and silently shadow env vars for every nested field (the
actual bug this replaced: ``RERANKER_HARD_FILTER__INACTIVE_DAYS=90`` was a no-op). Stage settings classes are
added here as each phase lands β€” this is the single source of magic numbers. Access via ``load_settings()``
(cached for the process).
"""
from __future__ import annotations
from functools import lru_cache
from pydantic import BaseModel
from pydantic_settings import (
BaseSettings,
PydanticBaseSettingsSource,
SettingsConfigDict,
YamlConfigSettingsSource,
)
from common.paths import REPO_ROOT
SETTINGS_PATH = REPO_ROOT / "config" / "settings.yaml"
class DebugSettings(BaseModel):
"""Debug/inspection toggles. All default OFF β†’ zero overhead, byte-identical runs."""
# Persist each phase's output frame to a Parquet checkpoint for inspection (K13). OFF in normal runs.
checkpoints: bool = False
# Where checkpoints are written (relative paths resolve under the repo root).
checkpoint_dir: str = "data/reports/debug"
class ComputeSettings(BaseModel):
"""CPU thread budget shared by numpy/polars/tokenizers (env vars), ONNX (intra-op threads) and the
llama.cpp client (``models.n_threads``, when that itself is left at its own auto default of 0).
``cpu_threads: 0`` (default) = auto β€” every core this process can actually use, cgroup-aware on Linux (see
``common.runtime.resolve_cpu_threads``). Set a positive value to cap the whole engine below that, e.g. to
leave headroom for other processes on a shared host.
"""
cpu_threads: int = 0
class QualitySettings(BaseModel):
"""Weights for the 7 query-independent quality pillars; their blend is ``candidate_quality`` (β‰ˆsum 1.0)."""
skills: float = 0.22
experience: float = 0.20
reliability: float = 0.14
reputation: float = 0.12
engagement: float = 0.12
verification: float = 0.10
education: float = 0.10
class ModelSettings(BaseModel):
"""Model repos + GGUF sampling. JD parse (+ T1 reasoning polish, when enabled) uses Qwen3-1.7B (Q4 GGUF,
llama.cpp); cached under data/."""
index_gen_llm_repo: str = "lmstudio-community/Qwen3-1.7B-GGUF" # JD-parse LLM (~29s gen; benchmarked vs
index_gen_llm_gguf: str = "Qwen3-1.7B-Q4_K_M.gguf" # Qwen3.5-2B/Qwen3-4B/Qwen3-0.6B/Gemma-3 β€” see README
embedder_broad: str = "minishlab/potion-base-32M" # static broad embedder (Model2Vec); A/B winner
embedder_precision: str = "onnx-community/granite-embedding-english-r2-ONNX" # granite-r2 768-d ONNX
reranker: str = "keisuke-miyako/granite-embedding-reranker-english-r2-onnx-int8" # cross-encoder (int8)
# ^ potion-base beat potion-retrieval on OUR pool (gap 0.117 vs 0.08, 91.6% vs 81% AI-relevant in top-500)
# because ideal_summary is profile-register → symmetric similarity, not asymmetric query→doc retrieval.
n_ctx: int = 6144 # objective JD-parse prompt (~3.4K tokens) + max_tokens 1536 left only ~600 tokens of
# headroom at 4096 β€” too tight for a longer JD; Qwen3-1.7B supports up to 32768 natively
n_threads: int = 0 # 0 β†’ llama.cpp auto (all cores)
temperature: float = 0.8 # default (HyDE prose); objective parse β†’ 0.0
top_p: float = 0.95
max_tokens: int = 1024
class JdSettings(BaseModel):
"""JD-parse inputs/outputs. The unified extraction JSON is generated live each run (no committed fallback)."""
path: str = "problem/job_description.md" # raw JD prose (live-parse input)
extraction_path: str = "data/jd_extraction.json" # ONE combined artifact: objective + subjective + summary
# NOT data/artifacts/ β€” that dir is gitignored/dockerignored (model cache only), so this demo-critical
# artifact would never ship with the repo or image. data/ itself IS tracked (candidates.jsonl lives there).
# Objective-extraction recall. With temperature>0, draw up to ``parse_attempts`` samples and keep the
# RICHEST (best-of-N), stopping early once total skills reach ``min_skills``. temperature=0.0 β‡’ a single
# greedy/deterministic pass.
parse_temperature: float = 0.5 # sampling temperature for the objective parse
parse_attempts: int = 1 # samples to draw when temperature>0; keep the most skills
# A prior attempts=2 test measured a 10/32/24-skill swing across 3 same-prompt trials at temperature=0.5,
# i.e. attempts=1 CAN land an unlucky single sample below min_skills with no retry. Mitigated (not
# eliminated) by dropping target_industries/exclude_companies from the prompt and sharpening the
# must_have_skills/job_titles instructions β€” freed prompt/output budget for the two fields that matter.
min_skills: int = 14 # early-stop target for the best-of-N sampling path
parse_max_tokens: int = 1024 # objective-parse generation budget (raise if JSON truncates)
parse_retry_on_failure: int = 2 # extra tries on a HARD failure (unparseable JSON,
# or valid JSON with no titles AND no skills) β€” separate from parse_attempts (which samples for RICHNESS
# even on success). Measured: ~1/9 single-attempt trials failed outright with no recovery before this.
# Was 1536: never actually hit by any observed run β€” the richest good response seen (32 skills) used ~800
# tokens. 1024 keeps ~28% headroom above that while bounding the worst-case cost of a rare runaway/verbose
# generation that never emits a natural stop token (unbounded on a 2-vCPU box, this is real wall-clock).
class HardFilterSettings(BaseModel):
"""Candidate-only hard-filter toggles (the funnel entry). Each is independent; default-OFF unless noted."""
honeypot: bool = True # drop validation_failed (planted bad profiles)
inactive: bool = True # drop inactive: dormant (days_since_active>N) OR active_validated==0
inactive_days: int = 180 # days_since_active threshold for "dormant"
# granular integrity checks (already OR'd into validation_failed; individually opt-in for diagnosis)
career_dates_invalid: bool = False
yoe_impossible: bool = False
date_order_invalid: bool = False
salary_incoherent: bool = False
class Bm25PrefilterSettings(BaseModel):
"""Step 04.5 β€” NEW early stage, right after hard_filter and BEFORE potion_broad/soft_filter. A cheap
lexical BM25 pre-cut so the expensive potion embedding only has to touch ``top_n`` survivors instead of
the full hard_filter pool (~74K rows). Location hard-drop also moves here (was in composite_narrow).
This does NOT replace composite_narrow's later multi-signal blend (potion+soft+bm25) β€” that still runs
once potion_score/soft_score exist, for the real pre-granite cut. This stage only prunes obviously
irrelevant candidates early using JD-keyword overlap alone. Falls back to a full passthrough if the JD
spec has zero lexical signal anywhere (empty/failed parse) rather than doing an arbitrary id-order cut.
"""
enabled: bool = True
top_n: int = 10000 # keep the top N by lexical (BM25) relevance
class BroadRetrievalSettings(BaseModel):
"""Step 05 potion broad β€” narrow the (now bm25-prefiltered) pool by cosine vs the JD ideal_summary."""
enabled: bool = True
top_n: int = 500 # keep the top N by potion cosine β€” was top_pct=0.20;
# a fixed count matches composite_narrow/precision (both already fixed counts) and stays predictable
# regardless of how many rows survive hard_filter/bm25_prefilter.
class SoftFilterSettings(BaseModel):
"""Step 06 soft_filter β€” two parameter types: JD-JSON hard FILTERS (drops) + PENALTY score, keep top-%.
filter_parameters DROP rows; penalty_parameters (z-legs + JD soft signals + multi-signal combos) SCORE.
"""
top_pct: float = 0.50 # keep top 50% by soft_score
# filter_parameters (hard drops) come from the JD spec (acceptable_countries) β€” no static config here
# penalty_parameters β€” base single-signal z-legs
weight_engagement: float = 0.40 # z(score_engagement)
weight_company: float = 0.267 # z(company_size_rank)
weight_production: float = 0.20 # z(score_skills)
weight_years: float = 0.133 # z(years-band proximity)
# penalty_parameters β€” JD soft signals
industry_boost: float = 0.10 # + when current_industry ∈ target_industries
company_penalty: float = 0.20 # βˆ’ when current_company ∈ exclude_companies
# penalty_parameters β€” multi-signal combos (product of two signals β†’ strong on BOTH)
combo_seniority_skills: float = 0.15 # senior title Γ— z(skills)
combo_recency_engagement: float = 0.15 # recently-active Γ— z(engagement)
combo_verification_experience: float = 0.10 # z(verification) Γ— z(experience)
recency_days: int = 90 # "recently active" threshold for the recency combo
z_clip: float = 3.0 # clip each z-leg to Β±this
class CompositeNarrowSettings(BaseModel):
"""Step 07 β€” field-scoped BM25 (lexical) + composite blend (potion + soft + bm25) β†’ keep top_n for embedding."""
top_n: int = 200 # survivors granite will embed (embed only what we keep)
# Was 500 -> 250 -> 200: at n_threads=2 (HF Space / free Colab), granite_precision costs ~0.74s/row β€” 500
# rows took 372s of a 300s total wall-clock budget on its own. Tightened further alongside the new
# bm25_prefilter/potion_broad top_n stages, still leaving headroom above the final ensemble.top_n=100 cut.
weight_potion: float = 1.0 # z(potion_score) weight in the blend
weight_soft: float = 1.0 # z(soft_score) weight
weight_bm25: float = 1.0 # z(bm25_score) weight (bm25_score = the field blend below)
bm25_k1: float = 1.5 # BM25 term-frequency saturation
bm25_b: float = 0.75 # BM25 length normalization
# Field-scoped BM25: each JD keyword bag is matched against the candidate text section it targets, then the
# per-field z-scores are blended with these weights (skills/titles carry more lexical signal than the rest).
bm25_weight_skills: float = 1.0 # must_have_skills + nice_to_have β†’ core/familiar_skills_text
bm25_weight_titles: float = 1.0 # job_titles β†’ identity_text + experience_text
bm25_weight_education: float = 0.5 # education_fields β†’ education_text
bm25_weight_locations: float = 0.5 # locations_preferred + acceptable β†’ candidate location
bm25_weight_industries: float = 0.5 # target_industries β†’ industries_text
z_clip: float = 3.0 # clip each z-signal to Β±this
class PrecisionSettings(BaseModel):
"""Step 08 β€” granite precision embedding (ONNX) over the composite-narrow survivors."""
onnx_file: str = "onnx/model_quantized.onnx" # int8 (3.6x faster on CPU); "onnx/model.onnx" = fp32
batch_size: int = 32 # ONNX inference batch
max_length: int = 128 # truncate (texts p50=308; signal is early) β€” O(nΒ²) win
top_n: int = 125 # keep top N by granite_score for the cross-encoder
# Was 300 -> 150 -> 125: at n_threads=2, cross_encoder costs ~0.6s/row β€” 300 rows took 180s. Tightened in
# step with composite_narrow.top_n (still 25 rows of headroom above ensemble.top_n=100).
class RerankSettings(BaseModel):
"""Step 09 β€” cross-encoder rerank (granite-reranker-r2 ONNX) over the granite survivors."""
onnx_file: str = "model_quantized.onnx" # int8, self-contained
batch_size: int = 32
max_length: int = 128 # query+doc pair length (O(nΒ²) β€” halves vs 256)
class EnsembleSettings(BaseModel):
"""Step 10 β€” final fusion: weighted z-blend of the signal tiers β†’ final_score β†’ top_n (the submission set)."""
top_n: int = 100 # final shortlist size (top-100 submission)
weight_reranker: float = 0.50 # cross-encoder (most precise)
weight_granite: float = 0.30 # granite bi-encoder precision
weight_composite: float = 0.20 # broad blend (potion + soft + bm25)
z_clip: float = 3.0
class ReasoningSettings(BaseModel):
"""Step 11 β€” multi-band JD-grounded explainability per finalist.
``mode``: ``template`` (current default) = pure deterministic NLG (no LLM, any tier); ``llm`` = NLG +
T1-only polish (reuses the Qwen3-1.7B JD-parse model). T2 also got LLM polish at one point β€” first
LFM2.5-350M (3/15 grounded, dropped), then the reused JD-parse model (better, but 25 sequential rows spent
~90s of the ~95s budget for modest gain over just the top 10) β€” narrowed back to T1 alone for a small,
predictable wall-clock cost, then ``template`` turned LLM polish off entirely. The ``explanation`` column +
artifact are ALWAYS LLM-free for all 100 β€” only the T1 CSV ``reasoning`` line is ever model-polished, and
only when ``mode == "llm"``.
"""
mode: str = "template" # "template" (pure NLG, fast, DEFAULT) | "llm" (T1-only polish)
recent_days: int = 90 # last-activity window for the "active this quarter" fact
max_tokens: int = 96 # polish one-liner cap (T1)
explain_llm_top_n: int = 20 # legacy; superseded by the tier ranges below
# Rank-tier bands (non-overlapping, cover 1..100). T1β†’LLM polish (reused, only if mode=="llm"); T2/T3/T4 pure NLG.
tier1_top: int = 10 # ranks 1..tier1_top = T1 (4 sentences, no gap, LLM if enabled)
tier2_top: int = 25 # ranks ..tier2_top = T2 (3-4 sent, no gap, pure NLG)
tier3_top: int = 75 # ranks ..tier3_top = T3 (3-4 sent, ≀1 gap, pure NLG)
sentences_top: int = 4 # T1 sentence count
sentences_other: int = 4 # T2/T3/T4 max sentence count (min 3)
max_neg_in_reasoning: int = 1 # ≀1 soft gap in the CSV reasoning (T3/T4 only)
variant_select: str = "potion_topk" # "potion_topk" (grounded) | "random" (blind)
variant_topk: int = 3 # sample among the top-k closest variants
llm_exec: str = "sequential" # "sequential" | "batched"
llm_budget_seconds: float = 95.0 # wall-clock cap on the Step-11 LLM; over it β†’ rows keep NLG
# (graceful degradation so 2-vCPU stays under the 5-min budget)
# Hard per-CALL timeout (not the same as llm_budget_seconds, which only gates the NEXT call β€” it can't stop
# one already in flight). A single generation was observed taking 1996s on real deployment hardware with
# nothing to interrupt it; this bounds any one row to a sane ceiling regardless of what the model does.
llm_call_timeout_seconds: float = 45.0
explanations_path: str = "data/reports/explanations.json" # full per-candidate breakdown artifact
class SubmissionSettings(BaseModel):
"""Step 12 β€” write submission.csv (contract: candidate_id, rank, score, reasoning; 100 rows)."""
path: str = "submission.csv" # default output path
top_n: int = 100 # exactly 100 data rows (challenge rule)
score_decimals: int = 6 # round the normalized score
class Settings(BaseSettings):
"""Root settings. Direct construction (``Settings()``, ``Settings(debug=...)``, ``.model_copy(...)``) uses
ONLY class defaults + ``RERANKER_*`` env vars β€” no YAML β€” so tests and internal overrides stay disk-
independent and predictable. For the real, YAML-backed config use ``load_settings()``.
``extra="forbid"`` is deliberate β€” an unknown key in settings.yaml is a typo, not silently ignored.
Add a field here whenever you add a key to the YAML.
"""
model_config = SettingsConfigDict(env_prefix="RERANKER_", env_nested_delimiter="__", extra="forbid")
debug: DebugSettings = DebugSettings()
compute: ComputeSettings = ComputeSettings()
quality: QualitySettings = QualitySettings()
models: ModelSettings = ModelSettings()
jd: JdSettings = JdSettings()
hard_filter: HardFilterSettings = HardFilterSettings()
bm25_prefilter: Bm25PrefilterSettings = Bm25PrefilterSettings()
broad_retrieval: BroadRetrievalSettings = BroadRetrievalSettings()
soft_filter: SoftFilterSettings = SoftFilterSettings()
composite_narrow: CompositeNarrowSettings = CompositeNarrowSettings()
precision: PrecisionSettings = PrecisionSettings()
rerank: RerankSettings = RerankSettings()
ensemble: EnsembleSettings = EnsembleSettings()
reasoning: ReasoningSettings = ReasoningSettings()
submission: SubmissionSettings = SubmissionSettings()
class _YamlBackedSettings(Settings):
"""Internal β€” adds ``settings.yaml`` as a source, ranked BELOW env vars and ABOVE class defaults. Used only
by ``load_settings()``; never construct this directly (construct ``Settings`` for disk-independent defaults).
"""
model_config = SettingsConfigDict(yaml_file=SETTINGS_PATH, yaml_file_encoding="utf-8") # merges with Settings'
@classmethod
def settings_customise_sources(
cls, settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
"""Source priority (highest first): explicit constructor kwargs > ``RERANKER_*`` env vars >
``settings.yaml`` > ``.env`` > secrets. ``YamlConfigSettingsSource`` (a proper settings source, not a
hand-parsed dict passed as init kwargs) is what lets env-var overrides reach nested fields β€” a plain
dict given as init kwargs would outrank ``env_settings`` and silently shadow it for every field the
YAML touches (the bug this replaced: ``RERANKER_HARD_FILTER__INACTIVE_DAYS=90`` used to be a no-op)."""
return (
init_settings, env_settings, YamlConfigSettingsSource(settings_cls), dotenv_settings,
file_secret_settings,
)
@lru_cache(maxsize=1)
def load_settings() -> Settings:
"""Load ``Settings`` from config/settings.yaml (defaults if absent), with ``RERANKER_*`` env vars taking
priority over the YAML. Cached for the process."""
return _YamlBackedSettings()