"""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()