LordofMonarchs commited on
Commit
c754148
·
verified ·
1 Parent(s): a68bdb1

Upload folder using huggingface_hub

Browse files
.dockerignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ .git/
6
+ .gitignore
7
+ logs/
8
+ reasoning_trace.jsonl
9
+ submission.csv
10
+ test_malformed.py
11
+ *.log
12
+ .vscode/
13
+ .idea/
.gitignore ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Virtual environments
7
+ .venv/
8
+ venv/
9
+ ENV/
10
+ env/
11
+
12
+ # IDEs and editor configs
13
+ .vscode/
14
+ .idea/
15
+ *.suo
16
+ *.ntvs*
17
+ *.njsproj
18
+ *.sln
19
+ *.sw?
20
+
21
+ # OS files
22
+ Thumbs.db
23
+ ehthumbs.db
24
+ Desktop.ini
25
+ .DS_Store
26
+
27
+ # Large datasets and project outputs (not committed — provided by competition)
28
+ candidates.jsonl
29
+ submission.csv
30
+ logs/
31
+ *.log
32
+
33
+ # Runtime outputs (regenerated on each run)
34
+ reasoning_trace.jsonl
35
+
36
+ # Precomputed files — ignore large indices, keep trained model artifacts
37
+ # Precomputed files — ignore large indices, keep sandbox-required artifacts
38
+ precomputed/*
39
+ !precomputed/lgbm_model.txt
40
+ !precomputed/lgbm_model.pkl
41
+ !precomputed/vocab.pkl
42
+ !precomputed/bm25_matrix.npz
43
+ !precomputed/candidate_ids.pkl
44
+
45
+ # Diagnostic / dev-only scripts (not part of submission)
46
+ diagnostics/
47
+
48
+ # Temp files created during validation runs
49
+ _tmp_*.jsonl
50
+ _tmp_*.csv
.hfignore ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Virtual environments & Git
2
+ .venv/
3
+ venv/
4
+ ENV/
5
+ env/
6
+ .git/
7
+ .vscode/
8
+ __pycache__/
9
+ *.py[cod]
10
+ *$py.class
11
+
12
+ # OS & Logs
13
+ *.log
14
+ logs/
15
+ reasoning_trace.jsonl
16
+
17
+ # Large local datasets & output CSVs
18
+ candidates.jsonl
19
+ submission*.csv
20
+ CTRL_COFFEE*.csv
21
+ _tmp_*
22
+ scratch/
23
+ diagnostics/
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.11
Dockerfile ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ ca-certificates \
7
+ && rm -rf /var/lib/apt/lists/*
8
+ COPY requirements.txt /app/requirements.txt
9
+
10
+ # Install Python dependencies
11
+ # --no-cache-dir keeps image size small
12
+ RUN pip install --no-cache-dir --upgrade pip && \
13
+ pip install --no-cache-dir -r requirements.txt
14
+
15
+ # Copy all source files and scripts
16
+ COPY src/ /app/src/
17
+ COPY scripts/ /app/scripts/
18
+
19
+ # Copy data directory (skill_aliases.json)
20
+ COPY data/ /app/data/
21
+
22
+ # Copy precomputed artifacts (BM25 index + LightGBM model)
23
+ # Generated by precompute.py — must exist before building image
24
+ COPY precomputed/ /app/precomputed/
25
+
26
+ # Create output directories
27
+ RUN mkdir -p /app/logs /app/out
28
+
29
+ # Default candidates file location (override with -v mount)
30
+ # The full candidates.jsonl is NOT baked into the image (487MB) — mount it.
31
+ ENV CANDIDATES_PATH=/app/candidates.jsonl
32
+ ENV OUT_PATH=/app/out/CTRL_COFFEE_REPEAT.csv
33
+ ENV BASE_DIR=/app
34
+
35
+ # Entrypoint script selects precompute, rank, or full pipeline
36
+ COPY docker-entrypoint.sh /app/docker-entrypoint.sh
37
+ RUN chmod +x /app/docker-entrypoint.sh
38
+
39
+ # Default: run full pipeline (precompute + rank)
40
+ CMD ["/app/docker-entrypoint.sh", "full"]
README.md ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Intelligent Candidate Discovery Ranking System
3
+ emoji: 🎯
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: streamlit
7
+ sdk_version: "1.35.0"
8
+ app_file: streamlit_app.py
9
+ python_version: "3.11"
10
+ pinned: false
11
+ ---
12
+
13
+ # Redrob Hackathon: Intelligent Candidate Discovery and Ranking System
14
+
15
+ **A production grade, deterministic ranking pipeline for the Redrob Intelligent Candidate Discovery and Ranking Challenge.**
16
+
17
+ Ranks 100,000 candidates against a structured Job Description in **4 seconds** on CPU, with zero external API calls during inference.
18
+
19
+ ![Python](https://img.shields.io/badge/Python-3.10+-blue?logo=python&logoColor=white) ![Runtime](https://img.shields.io/badge/Runtime-4.00s%20%2F%20100K-blue) ![Network](https://img.shields.io/badge/Network-Zero%20Calls-green) ![Model](https://img.shields.io/badge/Ranker-LightGBM%20LambdaRank-orange) ![Labels](https://img.shields.io/badge/Labels-Gemma3%20Pairwise%20(Local)-purple) ![License](https://img.shields.io/badge/License-MIT-yellow)
20
+
21
+ [Architecture](#architecture) · [Quick Start](#quick-start) · [Runtime Performance](#runtime-performance) · [Pipeline Internals](#pipeline-internals) · [Model Comparison](#model-comparison-heuristic-vs-gemma-trained) · [Validation](#validation) · [Constraints](#runtime-constraints-all-enforced) · [File Structure](#file-structure)
22
+
23
+ ---
24
+
25
+ ## The Core Problem
26
+
27
+ A ranking system built purely on heuristic scoring rules tends to reward whatever pattern the heuristics were designed to detect, which is a closed loop: the model learns to agree with its own assumptions. This pipeline breaks that circularity by training on independent judgments from a local LLM that never sees the engineered features, the BM25 scores, or the penalty weights it is implicitly being checked against. The result is a LightGBM ranker that discovers feature interactions rather than having them hand-coded in, while staying fully deterministic, CPU-only, and network-isolated at inference time.
28
+
29
+ ---
30
+
31
+ ## Architecture
32
+
33
+ The pipeline is split into two phases. The offline phase has no time limit and produces a set of precomputed artifacts. The online phase is what actually runs during the competition's 300 second window, and only touches those artifacts plus the live candidate pool.
34
+
35
+ ```mermaid
36
+ flowchart TD
37
+ %% Define classes with light pastel fills, complementary borders, and dark text for contrast
38
+ classDef data fill:#e0f2fe,stroke:#0284c7,stroke-width:1px,color:#0f172a;
39
+ classDef llm fill:#f3e8ff,stroke:#7e22ce,stroke-width:1px,color:#0f172a;
40
+ classDef offline fill:#ffedd5,stroke:#c2410c,stroke-width:1px,color:#0f172a;
41
+ classDef online fill:#dcfce7,stroke:#15803d,stroke-width:1px,color:#0f172a;
42
+
43
+ candidates_jsonl["candidates.jsonl<br/>(100,000 records)"]:::data
44
+ submission_csv["submission.csv<br/>(100 ranked candidates)"]:::data
45
+
46
+ subgraph offline_phase [OFFLINE PHASE]
47
+ subgraph gemma3_annotation [GEMMA3 PAIRWISE ANNOTATION]
48
+ stratified_sample["Stratified Sample of<br/>500 Candidates"]:::llm
49
+ pairwise_comparisons["2,500 Pairwise Comparisons<br/>(Local Gemma3 LLM)"]:::llm
50
+ elo_ratings["Laplace-smoothed<br/>Elo Ratings"]:::llm
51
+ relevance_labels["Quartile Thresholding to<br/>Relevance Labels 0-3"]:::llm
52
+
53
+ stratified_sample --> pairwise_comparisons
54
+ pairwise_comparisons --> elo_ratings
55
+ elo_ratings --> relevance_labels
56
+ end
57
+
58
+ bm25_index_build["BM25 Index Build<br/>(NumPy CSR matrix)"]:::offline
59
+ static_feature_precompute["Static Feature Precompute<br/>(18 JD-independent features)"]:::offline
60
+
61
+ lightgbm_training["LIGHTGBM TRAINING<br/>Objective: lambdarank<br/>early stop on NDCG@5"]:::offline
62
+
63
+ bm25_index_build --> precomputed_artifacts_box
64
+ static_feature_precompute --> precomputed_artifacts_box
65
+ relevance_labels --> lightgbm_training
66
+ static_feature_precompute --> lightgbm_training
67
+ bm25_index_build --> lightgbm_training
68
+
69
+ lightgbm_training --> precomputed_artifacts_box
70
+
71
+ precomputed_artifacts_box["PRECOMPUTED ARTIFACTS<br/>lgbm_model.txt<br/>bm25_matrix.npz<br/>static_features.pkl"]:::data
72
+ end
73
+
74
+ subgraph online_ranking_phase [ONLINE RANKING PHASE]
75
+ stage_0["Stage 0: Load Precomputed Artifacts<br/>(BM25, LightGBM, static features)"]:::online
76
+
77
+ stage_1["Stage 1: Dual-pass BM25 Retrieval<br/>Pass A: JD skills on skills array<br/>Pass B: production keywords on career descriptions<br/>Narrow to ~8,500 candidates"]:::online
78
+
79
+ stage_2["Stage 2: Load Stage 1 Records<br/>(Byte-offset index, O(1))"]:::online
80
+
81
+ stage_2b["Stage 2b: Feature Engineering<br/>(22 features, adversarial detection,<br/>consistency score)"]:::online
82
+
83
+ stage_4["Stage 4: LightGBM Inference<br/>(final_score = raw_score * consistency_score)"]:::online
84
+
85
+ stage_5["Stage 5: Reasoning Compiler<br/>(Deterministic grammar, 4 templates,<br/>priority concerns, numeric audit)"]:::online
86
+
87
+ stage_6["Stage 6: Blocking Audits + CSV Write<br/>(Honeypot, diversity, monotonicity checks)"]:::online
88
+
89
+ stage_0 --> stage_1
90
+ stage_1 --> stage_2
91
+ stage_2 --> stage_2b
92
+ stage_2b --> stage_4
93
+ stage_4 --> stage_5
94
+ stage_5 --> stage_6
95
+ stage_6 --> submission_csv
96
+ end
97
+
98
+ candidates_jsonl --> offline_phase
99
+ candidates_jsonl --> stage_1
100
+
101
+ precomputed_artifacts_box --> stage_0
102
+ precomputed_artifacts_box -.-> stage_2
103
+ precomputed_artifacts_box -.-> stage_2b
104
+ precomputed_artifacts_box -.-> stage_4
105
+
106
+ %% Use transparent fills (none) so it inherits GitHub's native dark/light themes seamlessly
107
+ style offline_phase fill:none,stroke:#777,stroke-width:2px,stroke-dasharray: 5 5
108
+ style online_ranking_phase fill:none,stroke:#777,stroke-width:2px,stroke-dasharray: 5 5
109
+ style gemma3_annotation fill:none,stroke:#555,stroke-width:1px
110
+
111
+ ```
112
+ ---
113
+
114
+ ## Quick Start
115
+
116
+ ### Docker (recommended, matches the Stage 3 reproduction environment exactly)
117
+
118
+ ```bash
119
+ docker build -t redrob-ranker .
120
+ docker run --rm --network none \
121
+ -v $(pwd)/candidates.jsonl:/app/candidates.jsonl \
122
+ -v $(pwd)/out:/app/out \
123
+ redrob-ranker
124
+ ```
125
+
126
+ Output: `./out/CTRL_COFFEE_REPEAT.csv`, 100 ranked candidates, validated and ready to submit.
127
+
128
+ ### Without Docker
129
+
130
+ ```bash
131
+ # 1. Create and activate a virtualenv
132
+ python -m venv .venv
133
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
134
+
135
+ # 2. Install pinned dependencies
136
+ pip install -r requirements.txt
137
+
138
+ # 3. Run precomputation (one-time, roughly 7 minutes on 100K candidates)
139
+ python scripts/precompute.py --candidates ./candidates.jsonl --base-dir .
140
+
141
+ # 4. Run ranking (roughly 4 seconds)
142
+ python src/rank.py --candidates ./candidates.jsonl --out ./CTRL_COFFEE_REPEAT.csv
143
+
144
+ # 5. Validate output format
145
+ python scripts/validate_submission.py --submission ./CTRL_COFFEE_REPEAT.csv
146
+ ```
147
+
148
+ **Single-command alternative** (handles artifact caching automatically):
149
+
150
+ ```bash
151
+ python scripts/run_full_pipeline.py --candidates ./candidates.jsonl --out ./CTRL_COFFEE_REPEAT.csv
152
+ ```
153
+
154
+ Add `--force-precompute` to bypass the cache and rebuild all artifacts from scratch.
155
+
156
+ ---
157
+
158
+ ## Runtime Performance
159
+
160
+ | Phase | Module | Operation | Time |
161
+ |---|---|---|---|
162
+ | Offline | `experiments/pairwise_llm_check/` | Gemma3 pairwise annotation (2,500 pairs, local Ollama) | ~45 min |
163
+ | Offline | `scripts/precompute.py` | BM25 indexing, static feature precomputation, LightGBM training | ~7 min |
164
+ | Stage 0 | `src/rank.py` | Load precomputed artifacts (BM25, LightGBM, static features) | 1.10s |
165
+ | Stage 1 | `src/retrieval.py` | Dual-pass BM25 retrieval (top 5,000 + rare-term safety net) | 0.05s |
166
+ | Stage 2 | `src/rank.py` | Load Stage 1 candidate records via byte-offset index | 0.45s |
167
+ | Stage 2b | `src/features.py` | Live feature extraction (22-feature matrix) | 0.45s |
168
+ | Stage 4 | `src/rank.py` | LightGBM LambdaRank inference, consistency multiplier | 0.02s |
169
+ | Stage 5 | `src/reasoning.py` | Deterministic reasoning compiler (top 100) | 1.93s |
170
+ | Stage 6 | `src/rank.py` | Monotonicity assertion, honeypot and diversity audits, CSV write | <0.01s |
171
+ | **Total** | | **End-to-end wall-clock** | **4.00s** |
172
+
173
+ The offline phases run once during development with no time or network restrictions. Only Stages 0 through 6 execute during the competition's 5-minute ranking window.
174
+
175
+ ---
176
+
177
+ ## Pipeline Internals
178
+
179
+ ### Stage 1: Dual-Pass BM25 Retrieval
180
+
181
+ Two independent BM25 queries run against a vectorised NumPy CSR matrix that is pre-built offline.
182
+
183
+ - **Pass A**: JD skill terms expanded via `data/skill_aliases.json`, queried against each candidate's `skills[].name` array. Skill names are structured, unique, and immune to the templated noise found in summary or description fields.
184
+ - **Pass B**: production signal keywords (`deployed`, `serving`, `latency`, `scale`, `inference`) queried against `career_history[].description`, catching candidates with production scaling experience who do not surface on skill keywords alone.
185
+ - **Rare-term safety net**: niche terms such as `pinecone`, `lambdarank`, `qdrant`, and `bm25` explicitly retrieve sparse but highly relevant profiles that might not rank in the top 5,000 by aggregate score.
186
+
187
+ The union of all three passes forms the Stage 1 pool, roughly 8,500 candidates.
188
+
189
+ ### Stage 2: Feature Engineering
190
+
191
+ `src/features.py` produces a 22-feature float32 vector per candidate. Every feature maps to a specific field in the candidate schema; nothing is invented or hallucinated.
192
+
193
+ **Five adversarial detection functions**, each targeting a pattern identified in the synthetic dataset:
194
+
195
+ | Function | Signal |
196
+ |---|---|
197
+ | `detect_description_title_mismatch` | Domain-category mismatch between job title and role description, for example a "Marketing Manager" title paired with a mechanical engineering design description |
198
+ | `detect_template_description` | Career description matching one of 12 known synthetic templates identified by manual inspection of the dataset |
199
+ | `extract_production_ml_signal` | `log(1 + prod_kw_count)`, returns -1.0 (an explicit JD disqualifier) when only academic keywords are present with no production signal |
200
+ | `score_langchain_dabbler` | LLM-era skill months greater than 12 with zero pre-LLM IR or ML foundational skills |
201
+ | `score_cv_speech_specialist` | CV or speech skill months greater than 24 with zero NLP or IR skill months |
202
+
203
+ **Full 22-feature matrix:**
204
+
205
+ | # | Feature | Formula / Source |
206
+ |---|---|---|
207
+ | 1 | `bm25_score` | Stage 1 BM25 retrieval score (normalised) |
208
+ | 2 | `yoe` | `profile.years_of_experience` |
209
+ | 3 | `Param_A_Systems_Depth` | Fraction of career months in roles whose descriptions contain retrieval, search, or ranking keywords |
210
+ | 4 | `Param_B_Availability` | `(recruiter_response_rate + exp(-days_inactive / 90)) / 2` |
211
+ | 5 | `Param_C_Tenure` | `min(avg_tenure_months, 48) / 48`, rewards 3+ year tenures |
212
+ | 6 | `Param_D_Notice_Exp` | `exp(-max(0, days-30) / 30)`: 30d to 1.0, 60d to 0.37, 90d to 0.14, 150d to 0.006 |
213
+ | 7 | `Param_E_Credibility` | `advanced_claimed_count / max(1, assessed_count)`, higher means less credible |
214
+ | 8 | `Param_F_Consulting` | Fraction of career at IT-services consulting firms (`industry == "IT Services" AND size == "10001+"`) |
215
+ | 9 | `Param_G_Location` | Noida/Pune = 1.0, other India = 0.7, outside and willing to relocate = 0.3, outside and unwilling = 0.0 |
216
+ | 10 | `Param_H_GitHub` | `github_activity_score / 100`; 0.3 imputed when the field equals -1 (absent) |
217
+ | 11 | `title_ai_fraction` | Career-weighted fraction in AI, ML, or data roles via a static title taxonomy |
218
+ | 12 | `prod_signal_log` | Log-compressed production keyword count, -1.0 if academic-only |
219
+ | 13 | `consistency_score` | Multiplicative honeypot penalty, c1 x c2 x c3 x c4 x c5 |
220
+ | 14 | `hard_req_coverage` | Fraction of JD hard requirements satisfied by the candidate's skill list |
221
+ | 15 | `flag_consulting_only` | `consulting_fraction > 0.95` |
222
+ | 16 | `flag_title_chaser` | `avg_tenure < 18 months` across 3+ jobs |
223
+ | 17 | `flag_langchain_dabbler` | LLM-era months > 12 and pre-LLM months == 0 |
224
+ | 18 | `flag_cv_specialist` | CV/speech months > 24 and NLP/IR months == 0 |
225
+ | 19 | `flag_title_desc_mismatch` | Domain-category mismatch fraction across career history |
226
+ | 20 | `flag_template_desc` | Max SequenceMatcher ratio against the template registry |
227
+ | 21 | `interaction_req_x_consistency` | `hard_req_coverage * consistency_score` |
228
+ | 22 | `interaction_yoe_x_prod` | `yoe * prod_signal_log` |
229
+
230
+ ### Stage 3: Logical Consistency (Honeypot Defenses)
231
+
232
+ ```
233
+ consistency_score = c1 * c2 * c3 * c4 * c5
234
+ ```
235
+
236
+ A single logical impossibility reduces the composite to near-zero, suppressing that candidate regardless of skill profile quality.
237
+
238
+ | Check | Condition | Effect |
239
+ |---|---|---|
240
+ | c1, timeline impossibility | `skill.duration_months > total_experience_months` | Hard zero |
241
+ | c2, signup anomaly | `signup_date > last_active_date` | Hard zero |
242
+ | c3, salary inversion | `expected_salary.min > max` | 0.1 (heavy penalty) |
243
+ | c4, assessment contradiction | Claims "advanced" and an assessment score exists and is below 50 | Compounding 0.4x per violation |
244
+ | c5, engagement mismatch | High BM25 score with `connections <= 60`, `search_appearances <= 15`, `endorsements <= 4` | Hard zero |
245
+
246
+ ### Stage 4: LightGBM LambdaRank
247
+
248
+ **Model configuration:**
249
+ - `objective: lambdarank`
250
+ - `eval_at: [5, 10, 50]`, explicitly optimising Precision@5, the spec's primary tiebreak criterion
251
+ - Early stopping monitors NDCG@5, patience 30
252
+ - 200 boosting rounds
253
+
254
+ **Training labels, Gemma3 pairwise annotation (the key differentiator):**
255
+
256
+ Rather than a pure heuristic label, training labels are generated via 2,500 pairwise LLM comparisons using Gemma3:4b-it-q4_K_M running locally on Ollama, with zero external API calls and full reproducibility. A stratified sample of 500 Stage 1 candidates is drawn across three strata (top-100, boundary 101-300, and a broader pool with guaranteed low-consistency coverage), and each candidate receives roughly five matchups against random opponents.
257
+
258
+ For each pair, Gemma3 reads both candidates' full structured profiles alongside the JD requirements and disqualifiers, then produces a single verdict: `CANDIDATE_A`, `CANDIDATE_B`, or `TIE`. Win and loss tallies convert to Elo ratings via **Laplace smoothed** win rates:
259
+
260
+ ```python
261
+ win_rate = (wins + 0.5) / (total + 1)
262
+ elo = 400 * log10(win_rate / (1 - win_rate)) + 1500
263
+ ```
264
+
265
+ Elo ratings are thresholded to 0-3 relevance labels by quartile, producing a balanced training set with roughly 125 candidates per label.
266
+
267
+ **Why this breaks circularity:** Gemma had no knowledge of the 22 engineered features, the BM25 scores, or the penalty weights. It learned independently that IR-specific skills (FAISS, BM25, Qdrant, Sentence Transformers) outrank generic ML skills, and that production-company backgrounds outrank consulting-only careers. LightGBM then learns how the 22 features correlate with these independent judgments, surfacing interactions that were never explicitly encoded.
268
+
269
+ **Post-inference consistency multiplier:**
270
+
271
+ ```python
272
+ final_score = lgbm_raw_score * consistency_score
273
+ ```
274
+
275
+ This ensures candidates with data integrity violations (c1 through c5) are suppressed to near-zero regardless of model prediction, giving a clean separation of concerns: LightGBM handles fit, the consistency checks handle data integrity.
276
+
277
+ ### Stage 5: Reasoning Compiler
278
+
279
+ `src/reasoning.py` generates a one to two sentence reasoning string per candidate using a deterministic grammar engine with the following properties:
280
+
281
+ - **Four structural templates** rotated via `abs(hash(candidate_id)) % 4`, so no two consecutive strings share the same sentence skeleton, which eliminates template monotony across the top 100.
282
+ - **Priority-ranked concern surfacing**: a notice period over 90 days surfaces before location preference, which surfaces before skill credibility concerns. Concerns are never presented as a generic checklist.
283
+ - **JD-specific skill phrases**: named skill combinations such as FAISS, Sentence Transformers, and BM25 are surfaced directly instead of generic category labels.
284
+ - **Numeric regex audit**: every number in the output string is asserted to exist in the candidate's raw JSON before writing, guaranteeing zero numeric hallucination.
285
+ - **N-gram collision check**: `difflib.SequenceMatcher` runs across all 100 outputs, and strings with more than 85 percent structural similarity are flagged before submission.
286
+ - **Decision audit trail**: `reasoning_trace.jsonl` logs the exact features, tone percentile, and concern selected for each of the top 30 candidates, enabling direct answers during a Stage 5 interview.
287
+
288
+ ---
289
+
290
+ ## Model Comparison: Heuristic vs Gemma-Trained
291
+
292
+ The competition provides no ground-truth relevance labels, so a standard NDCG@10 ablation against a labeled holdout set is not possible to compute honestly. What is available, and what is reported here, is a direct head-to-head comparison between the LightGBM model trained on the original heuristic weak label and the LightGBM model trained on the Gemma3 pairwise labels, run on the same Stage 1 candidate pool with the same feature vectors.
293
+
294
+ **Method:** both trained models score the full ~8,500-candidate Stage 1 pool. The same post-inference consistency multiplier is applied to both before ranking, so the comparison isolates the effect of the training label, not the honeypot suppression layer.
295
+
296
+ | Metric | Result |
297
+ |---|---|
298
+ | Top-10 overlap between the two models | 0 of 10 candidates in common |
299
+ | Spearman rank correlation (top-100) | 0.001, statistically independent rankings |
300
+ | Honeypot leakage, heuristic-trained model | Required a hand-coded post-processing suppression list to keep keyword-stuffed non-technical profiles out of the top 100 |
301
+ | Honeypot leakage, Gemma-trained model | 0 of 100 candidates with `consistency_score < 0.25`, achieved with no post-processing suppression list |
302
+
303
+ **Qualitative before/after:** prior to the Gemma retrain, the heuristic-trained model's unsuppressed top-10 surfaced profiles such as Content Writer, Project Manager, and Sales Executive, each with AI-sounding skills listed but no underlying technical career history, because the heuristic label rewarded keyword coverage directly. After the Gemma retrain, the same Stage 1 pool's top-10 surfaced candidates with FAISS, BM25, Qdrant, Sentence Transformers, and Hugging Face Transformers in their skill history, sourced from a model that never saw `bm25_score` or `hard_req_coverage` during label generation and discovered the IR-relevance ordering independently from reading full candidate profiles.
304
+
305
+ The two models disagreeing almost completely (Spearman 0.001) is itself evidence of non-circularity: a model trained on labels derived from the same 22 features it predicts on would be expected to correlate strongly with a heuristic built from those same features, not diverge from it entirely.
306
+
307
+ This comparison, not a fabricated NDCG number, is the evidence offered for why the pairwise-LLM-label approach was chosen over a simpler heuristic scorer.
308
+
309
+ ---
310
+
311
+ ## Validation
312
+
313
+ ### Full validation suite
314
+
315
+ ```bash
316
+ python scripts/run_full_validation.py
317
+ ```
318
+
319
+ Runs four checks in sequence:
320
+
321
+ 1. **Honeypot injection test**: injects all 7 synthetic violation types into a cloned top-ranked candidate and asserts zero leakage into the top-100 output.
322
+ 2. **Diversity audit**: asserts employer concentration at or below 30 percent and archetype signature concentration at or below 25 percent via `validate_pipeline.check_top100_diversity`.
323
+ 3. **c5 boundary test**: validates the engagement mismatch threshold fires correctly at the boundary values (connections=60, appearances=15, endorsements=4).
324
+ 4. **NDCG probe**: computes NDCG@10 against hand-labeled reference points where available in the Stage 1 pool.
325
+
326
+ ### Blocking audits in rank.py
327
+
328
+ Two hard-blocking assertions run before any CSV write. If either fails, `rank.py` exits non-zero with a descriptive error; there are no silent failures.
329
+
330
+ ```python
331
+ # Honeypot audit (Section 8.1)
332
+ assert count(consistency_score < 0.25 in top_100) < 10
333
+
334
+ # Diversity audit (Section 8.2)
335
+ assert max_company_concentration <= 0.30
336
+ assert max_signature_concentration <= 0.25
337
+ ```
338
+
339
+ ---
340
+
341
+ ## Runtime Constraints (All Enforced)
342
+
343
+ | Constraint | Limit | Enforcement |
344
+ |---|---|---|
345
+ | Wall-clock | <= 300s | `assert elapsed < 300` plus `sys.exit(4)` if exceeded |
346
+ | RAM | <= 16 GB | BM25 Stage 1 pool capped at 5,000 candidates |
347
+ | Network | Zero | `--network none` Docker flag; no runtime import makes a network call |
348
+ | Disk | <= 5 GB | Total precomputed artifacts: ~216 MB |
349
+ | Output rows | Exactly 100 | `assert len(df) == 100` before CSV write |
350
+ | Score monotonicity | Non-increasing | `assert_monotonicity()` before CSV write |
351
+ | Tiebreaking | Ascending `candidate_id` | `sorted(key=lambda x: (-x[1], x[0]))` |
352
+ | Determinism | Byte-identical across runs | `REFERENCE_DATE = date(2026, 1, 1)` constant, never `datetime.now()` |
353
+
354
+ ---
355
+
356
+ ## File Structure
357
+
358
+ ```
359
+ ├── data/
360
+ │ └── skill_aliases.json JD taxonomy: skill aliases for BM25 query expansion
361
+ ├── precomputed/ Artifacts generated by precompute.py
362
+ │ ├── vocab.pkl BM25 vocabulary: term to column index (19.5 KB)
363
+ │ ├── bm25_matrix.npz Vectorised Scipy BM25 CSR matrix (39.6 MB)
364
+ │ ├── candidate_offsets.pkl Byte-offset index for O(1) JSONL lookup (2.0 MB)
365
+ │ ├── lgbm_model.txt Trained LightGBM booster, native text format (1.3 MB)
366
+ │ ├── lgbm_model.pkl LightGBM booster, pickle fallback (1.4 MB)
367
+ │ ├── static_features.pkl 18 JD-independent features precomputed offline (21.7 MB)
368
+ │ ├── candidate_ids.pkl BM25 row to candidate_id mapping (1.5 MB)
369
+ │ └── weak_labels.pkl Training labels log from offline precomputation (2.4 MB)
370
+ ├── src/
371
+ │ ├── jd_parser.py JD requirement extraction from skill_aliases.json
372
+ │ ├── retrieval.py Dual-pass BM25 retrieval, rare-term safety net
373
+ │ ├── features.py 22-feature matrix, 5 adversarial detection functions
374
+ │ ├── reasoning.py Deterministic reasoning compiler
375
+ │ └── rank.py Main entry point
376
+ ├── scripts/
377
+ │ ├── precompute.py Offline: BM25 indexing, LightGBM training
378
+ │ ├── app.py Streamlit sandbox (lite mode, <= 1 GB RAM)
379
+ │ ├── validate_submission.py Output format validator
380
+ │ ├── validate_pipeline.py Competition-provided validation module (unmodified)
381
+ │ ├── run_full_pipeline.py End-to-end orchestration with artifact caching
382
+ │ ├── run_full_validation.py Full validation suite
383
+ │ └── rebuild_fast_artifacts.py Utility: rebuild NumPy BM25 artifacts from scratch
384
+ ├── experiments/
385
+ │ └── pairwise_llm_check/ Offline annotation experiment, isolated from inference
386
+ │ ├── annotate_and_retrain.py Gemma3 pairwise annotation, LightGBM retraining
387
+ │ ├── annotations.jsonl 2,500 pairwise judgments (Gemma3:4b-it-q4_K_M, local)
388
+ │ └── README.md Experiment methodology and budget exemption statement
389
+ ├── diagnostics/
390
+ │ ├── diag_profile_live_features.py Live feature extraction latency profiler
391
+ │ └── verify_c5_thresholds.py c5 boundary condition verification
392
+ ├── logs/ Runtime logs generated by rank.py (gitignored)
393
+ ├── requirements.txt All dependencies pinned to exact versions
394
+ ├── Dockerfile CPU-only, --network none compatible
395
+ ├── docker-entrypoint.sh Pipeline mode selector
396
+ ├── submission_metadata.yaml Competition portal metadata
397
+ └── README.md This file
398
+ ```
399
+
400
+ ---
401
+
402
+ ## Streamlit Sandbox (Section 10.5 Compliance)
403
+
404
+ The sandbox runs in lite mode: it accepts a JSONL upload of up to 10,000 candidates, scores uploaded candidates against the real precomputed 100K-corpus BM25 index (falling back to a small inline index only for candidates not present in that corpus), runs the full ranking pipeline, and returns a downloadable `submission.csv`. Peak RAM stays well under 1 GB.
405
+
406
+ On small uploaded batches, the trained model places very low weight on `bm25_score` relative to JD-fit features (a direct consequence of training on Gemma labels, which never see retrieval scores), so multiple candidates can legitimately receive identical model scores. When this happens, the sandbox display applies a transparent, display-only secondary sort by `hard_req_coverage` and `bm25_score` so the ranking order remains legible; the underlying score values and the production `rank.py` pipeline are unaffected.
407
+
408
+ **Local:**
409
+
410
+ ```bash
411
+ streamlit run scripts/app.py
412
+ ```
413
+
414
+ ---
415
+
416
+ ## Troubleshooting
417
+
418
+ **`precompute.py` raises a memory error**
419
+ Ensure at least 16 GB RAM is available. The full 100K JSONL requires approximately 4 to 6 GB peak during BM25 index construction.
420
+
421
+ **`rank.py` fails the diversity audit (exit code 3)**
422
+ Not encountered during testing; every run, including the most recent full pipeline run after the Streamlit sandbox fixes, produced 93 distinct archetype signatures with max employer concentration of 14 percent and max signature concentration of 3 percent, both comfortably under the 30/25 percent thresholds. This entry documents the expected resolution path if a future model retrain or feature change causes a regression: check LightGBM feature importances via `precomputed/lgbm_model.txt` and verify the training label distribution in `scripts/precompute.py` is balanced across all four quartiles.
423
+
424
+ **`rank.py` exits with code 2 (honeypot audit failed)**
425
+ More than 10 candidates with `consistency_score < 0.25` reached the top-100. Verify that `consistency_score` is computed correctly in `src/features.py` and that the post-inference multiplier (`final_score = lgbm_score * consistency_score`) is active in `src/rank.py`.
426
+
427
+ **Docker build fails on arm64 Mac**
428
+ Use `--platform linux/amd64` if cross-building for a cloud runner. LightGBM provides native arm64 wheels for local builds.
429
+
430
+ ---
431
+
432
+ ## AI Tool Disclosure
433
+
434
+ This submission was developed with the assistance of the Antigravity AI coding assistant for code scaffolding, latency diagnostics, and iterative debugging throughout development.
435
+
436
+ Gemma3:4b-it-q4_K_M (Google DeepMind, running locally via Ollama) was used offline to generate 2,500 pairwise relevance judgments on a stratified sample of 500 Stage 1 candidates. These judgments served as independent, non-circular training labels for the LightGBM model. No candidate data was transmitted to any external service at any point. All ranking inference is CPU-only with zero network calls.
437
+
438
+ Key milestones directed and verified by the human team at every stage:
439
+
440
+ - Identified and fixed the weak label circularity bug where heuristic labels were rewarding keyword-stuffed trap candidates.
441
+ - Designed the stratified pairwise sampling strategy with guaranteed low-consistency candidate coverage.
442
+ - Diagnosed and resolved the score compression issue via a normalization scope fix in output assembly.
443
+ - Approved the Elo to quartile label conversion thresholds and the post-inference consistency multiplier.
444
+ - Verified all Stage 4 and Stage 5 compliance criteria against actual pipeline output before submission.
445
+ - Diagnosed and fixed the Streamlit sandbox's BM25 scoping bug, where an inline index built on small upload batches produced unreliable term statistics; the sandbox now queries the real 100K-corpus index directly.
446
+ - Ran the heuristic-vs-Gemma model comparison reported above and verified its numbers directly against pipeline output before including them in this document.
447
+ Done
app.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import runpy
3
+
4
+ # Hugging Face Spaces and Cloud Platform root entrypoint
5
+ # This redirects execution directly to our main Streamlit app in scripts/app.py
6
+ if __name__ == "__main__":
7
+ script_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts", "app.py")
8
+ runpy.run_path(script_path, run_name="__main__")
data/skill_aliases.json ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": "Maps canonical JD requirement concepts to the candidate's skill name variants for BM25 Stage 1 query expansion. For each JD term, all aliases are added to the BM25 query at index time and query time. type: hard_requirement = 3x scoring weight; preferred = 1x; negative = penalised if primary skill.",
3
+
4
+ "jd_requirements": {
5
+
6
+ "embeddings_retrieval": {
7
+ "type": "hard_requirement",
8
+ "description": "Production experience with embedding based retrieval systems",
9
+ "aliases": [
10
+ "embeddings", "text embeddings", "vector embeddings", "sentence embeddings",
11
+ "dense retrieval", "semantic search", "semantic similarity",
12
+ "sentence transformers", "sentence-transformers",
13
+ "bge", "e5", "all-minilm", "mpnet", "gte",
14
+ "openai embeddings", "ada embeddings",
15
+ "embedding models", "representation learning",
16
+ "bi-encoder", "dual encoder"
17
+ ]
18
+ },
19
+
20
+ "vector_search_infrastructure": {
21
+ "type": "hard_requirement",
22
+ "description": "Vector database or hybrid search infrastructure",
23
+ "aliases": [
24
+ "faiss", "milvus", "qdrant", "pinecone", "weaviate",
25
+ "opensearch", "elasticsearch", "vector search",
26
+ "vector database", "vector store", "vector index",
27
+ "approximate nearest neighbors", "ann", "hnsw",
28
+ "similarity search", "knn search", "hybrid search",
29
+ "dense vector search", "sparse retrieval"
30
+ ]
31
+ },
32
+
33
+ "information_retrieval": {
34
+ "type": "hard_requirement",
35
+ "description": "Search and ranking systems experience",
36
+ "aliases": [
37
+ "information retrieval", "bm25", "tf-idf", "tfidf",
38
+ "ranking", "learning to rank", "ltr", "lambdarank", "lambdamart",
39
+ "recommendation systems", "recommender systems", "search ranking",
40
+ "candidate retrieval", "passage retrieval", "document retrieval",
41
+ "reranking", "cross-encoder", "neural ranking",
42
+ "two-stage retrieval", "recall-precision tradeoff"
43
+ ]
44
+ },
45
+
46
+ "ranking_evaluation": {
47
+ "type": "hard_requirement",
48
+ "description": "Evaluation frameworks for ranking systems — NDCG, MRR, MAP",
49
+ "aliases": [
50
+ "ndcg", "mrr", "map", "precision at k", "recall at k",
51
+ "ranking evaluation", "retrieval evaluation", "offline evaluation",
52
+ "online evaluation", "a/b testing", "experimentation",
53
+ "eval framework", "evaluation framework",
54
+ "mlops", "weights & biases", "wandb", "mlflow",
55
+ "offline-to-online correlation"
56
+ ]
57
+ },
58
+
59
+ "python": {
60
+ "type": "hard_requirement",
61
+ "description": "Strong Python — production-grade code quality",
62
+ "aliases": [
63
+ "python", "python 3", "python programming",
64
+ "pyspark", "pytest", "fastapi", "flask", "django",
65
+ "asyncio", "type hints", "python packaging"
66
+ ]
67
+ },
68
+
69
+ "llm_finetuning": {
70
+ "type": "preferred",
71
+ "description": "LLM fine-tuning — LoRA, QLoRA, PEFT",
72
+ "aliases": [
73
+ "fine-tuning llms", "fine tuning", "lora", "qlora", "peft",
74
+ "rlhf", "instruction tuning", "sft", "dpo",
75
+ "parameter efficient fine-tuning", "adapter tuning",
76
+ "model fine-tuning", "llm training", "rlhf"
77
+ ]
78
+ },
79
+
80
+ "nlp_core": {
81
+ "type": "preferred",
82
+ "description": "Core NLP — the JD requires pre-LLM NLP depth, not just LLM wrappers",
83
+ "aliases": [
84
+ "nlp", "natural language processing", "text classification",
85
+ "named entity recognition", "ner", "sentiment analysis",
86
+ "question answering", "text generation", "summarization",
87
+ "language models", "bert", "roberta", "electra", "transformers",
88
+ "hugging face transformers", "huggingface", "tokenization",
89
+ "sequence labeling", "span extraction"
90
+ ]
91
+ },
92
+
93
+ "deep_learning_frameworks": {
94
+ "type": "preferred",
95
+ "description": "PyTorch or TensorFlow for model building",
96
+ "aliases": [
97
+ "pytorch", "tensorflow", "keras", "jax", "flax",
98
+ "deep learning", "neural networks", "transformer architecture",
99
+ "backpropagation", "gradient descent", "cuda"
100
+ ]
101
+ },
102
+
103
+ "mlops_serving": {
104
+ "type": "preferred",
105
+ "description": "ML infrastructure, serving, and production deployment",
106
+ "aliases": [
107
+ "mlops", "kubeflow", "bentoml", "mlflow", "ray", "triton",
108
+ "model serving", "model deployment", "inference optimization",
109
+ "torchserve", "onnx", "model quantization", "model compression",
110
+ "feature store", "model registry", "pipeline orchestration"
111
+ ]
112
+ },
113
+
114
+ "llm_ecosystem": {
115
+ "type": "preferred",
116
+ "description": "LLM ecosystem — context: the JD explicitly warns against LangChain-only experience as insufficient",
117
+ "aliases": [
118
+ "langchain", "llm", "large language models", "rag",
119
+ "retrieval augmented generation", "prompt engineering",
120
+ "llama", "mistral", "chatgpt api", "openai api",
121
+ "anthropic api", "vector search", "llama index",
122
+ "llamaindex", "gpt-4", "claude", "gemini"
123
+ ]
124
+ },
125
+
126
+ "distributed_systems": {
127
+ "type": "preferred",
128
+ "description": "Distributed systems or large-scale inference — bonus signal",
129
+ "aliases": [
130
+ "distributed systems", "kafka", "spark", "apache spark",
131
+ "flink", "apache flink", "airflow", "data pipelines",
132
+ "microservices", "system design", "scalable systems",
133
+ "kubernetes", "docker", "redis", "cassandra", "high throughput"
134
+ ]
135
+ },
136
+
137
+ "open_source_contributions": {
138
+ "type": "preferred",
139
+ "description": "Open-source contributions in AI/ML — explicit JD nice-to-have",
140
+ "aliases": [
141
+ "open source", "github", "open-source contributions",
142
+ "pull requests", "maintainer", "contributor"
143
+ ]
144
+ }
145
+ },
146
+
147
+ "negative_signals": {
148
+ "_comment": "Skills that, if dominant in a candidate's profile alongside missing core skills, are mild negative signals. Not hard filters — just inform the scoring.",
149
+ "cv_speech_primary": [
150
+ "computer vision", "image classification", "object detection",
151
+ "speech recognition", "tts", "text to speech", "yolo",
152
+ "image segmentation", "pose estimation", "optical flow",
153
+ "openCV", "gans"
154
+ ],
155
+ "non_technical_primary": [
156
+ "marketing", "seo", "content writing", "sales", "accounting",
157
+ "tally", "six sigma", "project management", "photoshop",
158
+ "illustrator", "figma", "salesforce crm", "sap"
159
+ ],
160
+ "recent_llm_only": [
161
+ "langchain", "prompt engineering", "chatgpt", "openai api",
162
+ "llama index", "llamaindex", "gpt wrapper"
163
+ ]
164
+ }
165
+ }
docker-entrypoint.sh ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ MODE=${1:-full}
5
+
6
+ if [ "$MODE" = "full" ]; then
7
+ echo "Running full pipeline..."
8
+ python scripts/run_full_pipeline.py --candidates "$CANDIDATES_PATH" --out "$OUT_PATH"
9
+ elif [ "$MODE" = "rank" ]; then
10
+ echo "Running ranking only..."
11
+ python src/rank.py --candidates "$CANDIDATES_PATH" --out "$OUT_PATH"
12
+ elif [ "$MODE" = "precompute" ]; then
13
+ echo "Running precompute only..."
14
+ python scripts/precompute.py --candidates "$CANDIDATES_PATH" --base-dir "$BASE_DIR"
15
+ else
16
+ echo "Unknown mode: $MODE"
17
+ echo "Usage: $0 {full|rank|precompute}"
18
+ exit 1
19
+ fi
pairwise_llm_check/README.md ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # experiments/pairwise_llm_check/
2
+
3
+ ## What This Experiment Does
4
+
5
+ This is an **offline experiment** that generates better LightGBM training labels
6
+ by replacing the heuristic weak_label = hard_req_coverage × consistency_score × jd_penalty
7
+ with LLM pairwise judgments on sampled Stage 1 candidates.
8
+
9
+ ### Pipeline Summary
10
+
11
+ 1. Load Stage 1 BM25 retrieval pool.
12
+ 2. Stratified sample of candidates weighted toward the current model's top and boundary regions.
13
+ 3. Generate pairwise matchups; annotate with quantized LLaMA via Ollama.
14
+ 4. Convert pairwise verdicts → Elo ratings → 0–3 integer relevance labels.
15
+ 5. Retrain LightGBM on these labels using identical hyperparameters to precompute.py.
16
+ 6. Save the new model as precomputed/lgbm_model_llm.pkl.
17
+ 7. Print a comparison report: top-10 overlap, Spearman correlation, honeypot audit.
pairwise_llm_check/annotate_and_retrain.py ADDED
@@ -0,0 +1,1059 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import argparse
3
+ import json
4
+ import logging
5
+ import math
6
+ import os
7
+ import pickle
8
+ import random
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Dict, List, Optional, Tuple
13
+
14
+ import numpy as np
15
+ _THIS_FILE = os.path.abspath(__file__)
16
+ _EXP_DIR = os.path.dirname(_THIS_FILE)
17
+ _EXPERIMENTS = os.path.dirname(_EXP_DIR)
18
+ _PROJECT_ROOT = os.path.dirname(_EXPERIMENTS)
19
+ _SRC_DIR = os.path.join(_PROJECT_ROOT, "src")
20
+
21
+ for _p in [_SRC_DIR, _PROJECT_ROOT]:
22
+ if _p not in sys.path:
23
+ sys.path.insert(0, _p)
24
+
25
+ logging.basicConfig(
26
+ level=logging.INFO,
27
+ format="%(asctime)s %(levelname)s [pairwise] %(message)s",
28
+ datefmt="%H:%M:%S",
29
+ )
30
+ logger = logging.getLogger("pairwise_llm")
31
+ _PROVIDER_SLEEP: Dict[str, float] = {
32
+ "groq": 2.1,
33
+ "anthropic": 0.1,
34
+ "ollama": 0.5,
35
+ "cerebras": 0.5,
36
+ }
37
+ _PROVIDER_PRICE: Dict[str, Tuple[float, float]] = {
38
+ "groq": (0.0, 0.0),
39
+ "anthropic": (3.0, 15.0),
40
+ "ollama": (0.0, 0.0),
41
+ "cerebras": (0.0, 0.0),
42
+ }
43
+ _DEFAULT_MODELS: Dict[str, str] = {
44
+ "groq": "llama-3.1-8b-instant",
45
+ "anthropic": "claude-sonnet-4-6",
46
+ "ollama": "gemma3:4b",
47
+ "cerebras": "llama3.1-8b",
48
+ }
49
+
50
+
51
+
52
+ def build_jd_summary(jd_config) -> str:
53
+ lines = ["JOB: Senior AI/ML Engineer — Retrieval & Ranking Systems"]
54
+ lines.append("HARD REQUIREMENTS (must have):")
55
+ for req in jd_config.hard_requirements:
56
+ lines.append(f" - {req}")
57
+ lines.append("PREFERRED (good to have):")
58
+ preferred = jd_config.preferred_requirements
59
+ keys = list(preferred.keys()) if isinstance(preferred, dict) else list(preferred)[:6]
60
+ for req in keys[:6]:
61
+ lines.append(f" - {req}")
62
+ lines.append("EXPLICIT DISQUALIFIERS:")
63
+ lines.append(" - Entire career at IT-services/consulting firms (TCS, Infosys, Wipro, etc.)")
64
+ lines.append(" - AI experience is only LangChain/OpenAI API with no pre-LLM IR or ML foundation")
65
+ lines.append(" - CV/speech-only ML background with no NLP/IR experience")
66
+ lines.append(" - Title-chaser: avg tenure < 15 months across 3+ jobs")
67
+ lines.append("LOCATION PREFERENCE: Noida or Pune strongly preferred; other India acceptable; "
68
+ "outside India only if willing to relocate (no visa sponsorship)")
69
+ lines.append("EXPERIENCE: 5-9 years preferred")
70
+ lines.append("NOTICE PERIOD: Sub-30 days preferred; 30+ days raises the bar")
71
+ return "\n".join(lines)
72
+
73
+
74
+ def build_candidate_summary(candidate: dict) -> str:
75
+
76
+ profile = candidate.get("profile", {}) or {}
77
+ signals = candidate.get("redrob_signals", {}) or {}
78
+
79
+ lines = []
80
+ lines.append(f"ID: {candidate.get('candidate_id', 'unknown')}")
81
+ lines.append(
82
+ f"Title: {profile.get('current_title', 'unknown')} "
83
+ f"at {profile.get('current_company', 'unknown')}"
84
+ )
85
+ lines.append(f"YOE: {profile.get('years_of_experience', 0)}")
86
+ lines.append(
87
+ f"Location: {profile.get('location', 'unknown')}, "
88
+ f"{profile.get('country', 'unknown')}"
89
+ )
90
+
91
+
92
+ skills = sorted(
93
+ candidate.get("skills", []) or [],
94
+ key=lambda s: s.get("duration_months", 0),
95
+ reverse=True,
96
+ )[:5]
97
+ assessments = signals.get("skill_assessment_scores", {}) or {}
98
+ skill_lines = []
99
+ for s in skills:
100
+ name = s.get("name", "")
101
+ prof = s.get("proficiency", "")
102
+ dur = s.get("duration_months", 0)
103
+ score = assessments.get(name)
104
+ if score is not None:
105
+ skill_lines.append(f"{name} ({prof}, {dur}mo, assessed: {score}/100)")
106
+ else:
107
+ skill_lines.append(f"{name} ({prof}, {dur}mo, unverified)")
108
+ lines.append(f"Skills: {'; '.join(skill_lines)}")
109
+
110
+
111
+ for i, role in enumerate((candidate.get("career_history", []) or [])[:3]):
112
+ desc = (role.get("description") or "")[:60].replace("\n", " ")
113
+ lines.append(
114
+ f"Role {i+1}: {role.get('title')} @ {role.get('company')} "
115
+ f"({role.get('industry')}, {role.get('company_size')}, "
116
+ f"{role.get('duration_months')}mo) — {desc}..."
117
+ )
118
+
119
+ lines.append(f"Notice: {signals.get('notice_period_days', 'unknown')} days")
120
+ lines.append(f"Last active: {signals.get('last_active_date', 'unknown')}")
121
+ lines.append(f"GitHub score: {signals.get('github_activity_score', -1)}")
122
+ lines.append(f"Response rate: {signals.get('recruiter_response_rate', 'unknown')}")
123
+ lines.append(f"Willing to relocate: {signals.get('willing_to_relocate', 'unknown')}")
124
+
125
+ return "\n".join(lines)
126
+
127
+
128
+ def _call_groq(client, model: str, prompt: str) -> Tuple[str, int, int]:
129
+ response = client.chat.completions.create(
130
+ model=model,
131
+ max_tokens=10,
132
+ messages=[{"role": "user", "content": prompt}],
133
+ )
134
+ text = response.choices[0].message.content.strip().upper()
135
+ return text, response.usage.prompt_tokens, response.usage.completion_tokens
136
+
137
+
138
+ def _call_anthropic(client, model: str, prompt: str) -> Tuple[str, int, int]:
139
+ response = client.messages.create(
140
+ model=model,
141
+ max_tokens=10,
142
+ messages=[{"role": "user", "content": prompt}],
143
+ )
144
+ text = response.content[0].text.strip().upper()
145
+ return text, response.usage.input_tokens, response.usage.output_tokens
146
+
147
+
148
+ def _call_cerebras(client, model: str, prompt: str) -> Tuple[str, int, int]:
149
+ response = client.chat.completions.create(
150
+ model=model,
151
+ max_tokens=10,
152
+ messages=[{"role": "user", "content": prompt}],
153
+ )
154
+ text = response.choices[0].message.content.strip().upper()
155
+ return text, response.usage.prompt_tokens, response.usage.completion_tokens
156
+
157
+
158
+ def _call_ollama(model: str, prompt: str) -> Tuple[str, int, int]:
159
+
160
+ import requests as _req
161
+ try:
162
+ response = _req.post(
163
+ "http://localhost:11434/api/generate",
164
+ json={
165
+ "model": model,
166
+ "prompt": prompt,
167
+ "stream": False,
168
+ "options": {
169
+ "temperature": 0,
170
+ "num_predict": 10,
171
+ "num_ctx": 2048,
172
+ "num_gpu": 99,
173
+ "stop": ["\n", ".", " \n"],
174
+ },
175
+ },
176
+ timeout=120,
177
+ )
178
+ response.raise_for_status()
179
+ raw = response.json()["response"].strip().upper()
180
+ if "CANDIDATE_A" in raw:
181
+ return "CANDIDATE_A", 0, 0
182
+ elif "CANDIDATE_B" in raw:
183
+ return "CANDIDATE_B", 0, 0
184
+ else:
185
+ return "TIE", 0, 0
186
+ except _req.exceptions.ConnectionError:
187
+ raise RuntimeError(
188
+ "Cannot connect to Ollama at localhost:11434. "
189
+ "It starts automatically on Windows after install. "
190
+ "Verify with: ollama list"
191
+ )
192
+ except Exception as e:
193
+ raise RuntimeError(f"Ollama call failed: {e}")
194
+
195
+
196
+ def get_pairwise_judgment(
197
+ client,
198
+ provider: str,
199
+ model: str,
200
+ jd_summary: str,
201
+ summary_a: str,
202
+ summary_b: str,
203
+ pair_idx: int,
204
+ ) -> Tuple[str, int, int]:
205
+ prompt = f"""You are an expert technical recruiter. Read the job requirements and both candidate profiles carefully, then judge which candidate is the stronger fit.
206
+
207
+ {jd_summary}
208
+
209
+ --- CANDIDATE A ---
210
+ {summary_a}
211
+
212
+ --- CANDIDATE B ---
213
+ {summary_b}
214
+
215
+ Which candidate is a better fit for this specific role?
216
+
217
+ Respond with EXACTLY one of these three strings and nothing else:
218
+ CANDIDATE_A
219
+ CANDIDATE_B
220
+ TIE
221
+
222
+ No explanation. No punctuation. Just the label."""
223
+
224
+ def _dispatch():
225
+ if provider == "groq":
226
+ return _call_groq(client, model, prompt)
227
+ elif provider == "anthropic":
228
+ return _call_anthropic(client, model, prompt)
229
+ elif provider == "cerebras":
230
+ return _call_cerebras(client, model, prompt)
231
+ elif provider == "ollama":
232
+ return _call_ollama(model, prompt)
233
+ else:
234
+ raise ValueError(f"Unknown provider: {provider}")
235
+
236
+ try:
237
+ text, inp, out = _dispatch()
238
+ except Exception as e:
239
+ logger.warning("API error on pair %d: %s", pair_idx, e)
240
+ time.sleep(5)
241
+ try:
242
+ text, inp, out = _dispatch()
243
+ except Exception as e2:
244
+ logger.warning("Retry failed on pair %d: %s — defaulting to TIE", pair_idx, e2)
245
+ return "TIE", 0, 0
246
+
247
+ verdict = text if text in ("CANDIDATE_A", "CANDIDATE_B", "TIE") else "TIE"
248
+ if text not in ("CANDIDATE_A", "CANDIDATE_B", "TIE"):
249
+ logger.warning("Pair %d: unexpected output %r — defaulting to TIE", pair_idx, text)
250
+ return verdict, inp, out
251
+
252
+
253
+
254
+
255
+
256
+
257
+ def compute_elo_scores(
258
+ annotations: List[dict],
259
+ candidate_ids: List[str],
260
+ ) -> Dict[str, float]:
261
+ wins: Dict[str, float] = {cid: 0.0 for cid in candidate_ids}
262
+ losses: Dict[str, float] = {cid: 0.0 for cid in candidate_ids}
263
+
264
+ for ann in annotations:
265
+ a, b, verdict = ann["candidate_a"], ann["candidate_b"], ann["verdict"]
266
+ if verdict == "CANDIDATE_A":
267
+ wins[a] += 1.0; losses[b] += 1.0
268
+ elif verdict == "CANDIDATE_B":
269
+ wins[b] += 1.0; losses[a] += 1.0
270
+ else:
271
+ wins[a] += 0.5; losses[a] += 0.5
272
+ wins[b] += 0.5; losses[b] += 0.5
273
+
274
+ elo: Dict[str, float] = {}
275
+ for cid in candidate_ids:
276
+ total = wins[cid] + losses[cid]
277
+ if total == 0:
278
+ elo[cid] = 1500.0
279
+ else:
280
+ win_rate = (wins[cid] + 0.5) / (total + 1)
281
+ elo[cid] = 400 * math.log10(win_rate / (1 - win_rate)) + 1500
282
+ return elo
283
+
284
+
285
+
286
+
287
+
288
+
289
+ def elo_to_labels(elo_scores: Dict[str, float]) -> Dict[str, int]:
290
+ values = sorted(elo_scores.values())
291
+ n = len(values)
292
+ q75 = values[int(0.75 * n)]
293
+ q50 = values[int(0.50 * n)]
294
+ q25 = values[int(0.25 * n)]
295
+ labels: Dict[str, int] = {}
296
+ for cid, elo in elo_scores.items():
297
+ if elo >= q75: labels[cid] = 3
298
+ elif elo >= q50: labels[cid] = 2
299
+ elif elo >= q25: labels[cid] = 1
300
+ else: labels[cid] = 0
301
+ return labels
302
+
303
+
304
+
305
+
306
+
307
+
308
+ def _get_top_skill(candidate: dict) -> str:
309
+ skills = sorted(
310
+ candidate.get("skills", []) or [],
311
+ key=lambda s: s.get("duration_months", 0),
312
+ reverse=True,
313
+ )
314
+ return skills[0].get("name", "N/A") if skills else "N/A"
315
+
316
+
317
+ def _spearman(
318
+ candidate_ids: List[str],
319
+ ranks_a: Dict[str, int],
320
+ ranks_b: Dict[str, int],
321
+ ) -> float:
322
+
323
+ from scipy.stats import spearmanr
324
+ common = [cid for cid in candidate_ids if cid in ranks_a and cid in ranks_b]
325
+ if len(common) < 2:
326
+ return 0.0
327
+ ra = [ranks_a[cid] for cid in common]
328
+ rb = [ranks_b[cid] for cid in common]
329
+ rho, _ = spearmanr(ra, rb)
330
+ return float(rho)
331
+
332
+
333
+ def print_model_comparison(
334
+ stage1_candidates: Dict[str, dict],
335
+ stage1_ids: List[str],
336
+ bm25_scores: Dict[str, float],
337
+ stage1_bm25_median: float,
338
+ jd_config,
339
+ old_model,
340
+ new_model,
341
+ feature_columns: List[str],
342
+ ) -> None:
343
+
344
+ from features import build_feature_vector
345
+
346
+ logger.info("Building full feature matrix for comparison report...")
347
+
348
+ feature_rows = []
349
+ ordered_ids = []
350
+ consistency_map: Dict[str, float] = {}
351
+
352
+ for cid in stage1_ids:
353
+ candidate = stage1_candidates.get(cid)
354
+ if candidate is None:
355
+ continue
356
+ bs = bm25_scores.get(cid, 0.0)
357
+ try:
358
+ fv = build_feature_vector(
359
+ candidate, jd_config,
360
+ bm25_score=bs,
361
+ stage1_bm25_median=stage1_bm25_median,
362
+ )
363
+ row = [fv[col] for col in feature_columns]
364
+ consistency_map[cid] = float(fv.get("consistency_score", 1.0))
365
+ except Exception as e:
366
+ logger.warning("Feature extraction failed for %s: %s", cid, e)
367
+ row = [0.0] * len(feature_columns)
368
+ consistency_map[cid] = 1.0
369
+ feature_rows.append(row)
370
+ ordered_ids.append(cid)
371
+
372
+ X_full = np.array(feature_rows, dtype=np.float32)
373
+ logger.info("Comparison feature matrix: shape=%s", X_full.shape)
374
+
375
+
376
+ old_raw = old_model.predict(X_full)
377
+ old_scores = {cid: float(s) for cid, s in zip(ordered_ids, old_raw)}
378
+ old_ranked = sorted(old_scores.items(), key=lambda x: (-x[1], x[0]))
379
+ old_rank_map = {cid: rank for rank, (cid, _) in enumerate(old_ranked, 1)}
380
+
381
+
382
+
383
+
384
+ new_raw = new_model.predict(X_full)
385
+ new_scores = {
386
+ cid: float(s) * consistency_map.get(cid, 1.0)
387
+ for cid, s in zip(ordered_ids, new_raw)
388
+ }
389
+ new_ranked = sorted(new_scores.items(), key=lambda x: (-x[1], x[0]))
390
+ new_rank_map = {cid: rank for rank, (cid, _) in enumerate(new_ranked, 1)}
391
+
392
+ old_top10 = [cid for cid, _ in old_ranked[:10]]
393
+ new_top10 = [cid for cid, _ in new_ranked[:10]]
394
+ overlap = len(set(old_top10) & set(new_top10))
395
+
396
+
397
+ top100_old = [cid for cid, _ in old_ranked[:100]]
398
+ rho = _spearman(top100_old, old_rank_map, new_rank_map)
399
+
400
+
401
+ moved_up: List[Tuple[str, int, int]] = []
402
+ moved_down: List[Tuple[str, int, int]] = []
403
+ for cid in ordered_ids:
404
+ old_r = old_rank_map.get(cid, 9999)
405
+ new_r = new_rank_map.get(cid, 9999)
406
+ delta = old_r - new_r
407
+ if delta >= 20:
408
+ moved_up.append((cid, old_r, new_r))
409
+ elif delta <= -20:
410
+ moved_down.append((cid, old_r, new_r))
411
+
412
+ moved_up.sort(key=lambda x: x[1] - x[2], reverse=True)
413
+ moved_down.sort(key=lambda x: x[2] - x[1], reverse=True)
414
+
415
+
416
+ new_top100 = [cid for cid, _ in new_ranked[:100]]
417
+ low_cons_count = sum(1 for cid in new_top100 if consistency_map.get(cid, 1.0) < 0.25)
418
+ honeypot_pass = low_cons_count < 10
419
+
420
+
421
+ print("\n" + "=" * 60)
422
+ print("=== MODEL COMPARISON REPORT ===")
423
+ print("=" * 60)
424
+
425
+ print("\nCurrent model (heuristic labels) top-10:")
426
+ for rank, cid in enumerate(old_top10, 1):
427
+ c = stage1_candidates.get(cid, {})
428
+ p = c.get("profile", {}) or {}
429
+ print(f" {rank:2d}. {cid} — {p.get('current_title','N/A')}, "
430
+ f"{p.get('years_of_experience',0)}y, {_get_top_skill(c)}")
431
+
432
+ print("\nNew model (LLM pairwise labels + consistency multiplier) top-10:")
433
+ for rank, cid in enumerate(new_top10, 1):
434
+ c = stage1_candidates.get(cid, {})
435
+ p = c.get("profile", {}) or {}
436
+ cons = consistency_map.get(cid, 1.0)
437
+ print(f" {rank:2d}. {cid} — {p.get('current_title','N/A')}, "
438
+ f"{p.get('years_of_experience',0)}y, {_get_top_skill(c)}, "
439
+ f"cons={cons:.2f}")
440
+
441
+ print(f"\nOverlap: {overlap} of 10 top-10 candidates appear in both rankings")
442
+ print(f"Spearman correlation (top-100): {rho:.3f} "
443
+ f"[range: -1.0 to +1.0, higher = more agreement]")
444
+
445
+ print("\nCandidates that MOVED UP 20+ positions in new model:")
446
+ for cid, old_r, new_r in moved_up[:10]:
447
+ c = stage1_candidates.get(cid, {})
448
+ p = c.get("profile", {}) or {}
449
+ print(f" - {cid}: old={old_r}, new={new_r} | "
450
+ f"{p.get('current_title','N/A')}, {_get_top_skill(c)}")
451
+
452
+ print("\nCandidates that MOVED DOWN 20+ positions in new model:")
453
+ for cid, old_r, new_r in moved_down[:10]:
454
+ c = stage1_candidates.get(cid, {})
455
+ p = c.get("profile", {}) or {}
456
+ print(f" - {cid}: old={old_r}, new={new_r} | "
457
+ f"{p.get('current_title','N/A')}, {_get_top_skill(c)}")
458
+
459
+ print(f"\nConsistency check — low-consistency (< 0.25) in new top-100:")
460
+ print(f" Count: {low_cons_count} (must be < 10 to pass honeypot audit)")
461
+ print(f" NOTE: consistency multiplier applied — this number should now be 0.")
462
+
463
+ print("\n" + "=" * 60)
464
+ print("=== VERDICT ===")
465
+ print(f"Honeypot audit: {'PASS ✓' if honeypot_pass else 'FAIL ✗'}")
466
+ print(f"Top-10 overlap with current: {overlap}/10")
467
+ print(f"Spearman correlation: {rho:.3f}")
468
+
469
+ if honeypot_pass and rho > 0.4:
470
+ rec = "PROMISING — consider swapping model"
471
+ elif honeypot_pass and rho <= 0.4:
472
+ rec = "MIXED — honeypot passes but ranking diverges significantly; review movers"
473
+ else:
474
+ rec = "RISKY — honeypot audit fails; do not swap without further investigation"
475
+ print(f"Recommendation: {rec}")
476
+ print("=" * 60 + "\n")
477
+
478
+ if not honeypot_pass:
479
+ logger.error(
480
+ "HONEYPOT AUDIT FAILED: %d low-consistency candidates in new top-100. "
481
+ "The consistency multiplier should have fixed this — check that "
482
+ "consistency_score is being computed correctly for these candidates.",
483
+ low_cons_count,
484
+ )
485
+ else:
486
+ logger.info("Honeypot audit PASSED: %d low-consistency in new top 100.", low_cons_count)
487
+
488
+
489
+ def main() -> None:
490
+ parser = argparse.ArgumentParser(
491
+ description=(
492
+ "Offline pairwise LLM annotation experiment. "
493
+ "If lgbm_model_llm.pkl already exists, runs Step 11 comparison only. "
494
+ "NEVER imported by rank.py or any production module."
495
+ ),
496
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
497
+ )
498
+ parser.add_argument("--candidates", required=True)
499
+ parser.add_argument("--base-dir", required=True)
500
+ parser.add_argument(
501
+ "--provider",
502
+ default="ollama",
503
+ choices=["groq", "anthropic", "ollama", "cerebras"],
504
+ )
505
+ parser.add_argument("--model", default=None)
506
+ parser.add_argument("--api-key", default=None)
507
+ parser.add_argument("--ollama-url", default="http://localhost:11434")
508
+ args = parser.parse_args()
509
+
510
+ provider = args.provider
511
+ model = args.model or _DEFAULT_MODELS[provider]
512
+ call_sleep = _PROVIDER_SLEEP[provider]
513
+ price_in, price_out = _PROVIDER_PRICE[provider]
514
+
515
+
516
+ if provider in ("groq", "anthropic", "cerebras") and not args.api_key:
517
+ logger.error("--api-key is required when --provider is %s", provider)
518
+ sys.exit(1)
519
+ if provider == "ollama" and args.api_key:
520
+ logger.info("--api-key ignored for ollama provider")
521
+
522
+ base_dir = os.path.abspath(args.base_dir)
523
+ candidates_path = os.path.abspath(args.candidates)
524
+ precomputed_dir = os.path.join(base_dir, "precomputed")
525
+ data_dir = os.path.join(base_dir, "data")
526
+ annotations_path = os.path.join(_EXP_DIR, "annotations.jsonl")
527
+ new_model_path = os.path.join(precomputed_dir, "lgbm_model_llm.pkl")
528
+ old_model_path = os.path.join(precomputed_dir, "lgbm_model.pkl")
529
+
530
+ logger.info("=" * 60)
531
+ logger.info("PAIRWISE LLM ANNOTATION EXPERIMENT")
532
+ logger.info("Provider: %s | Model: %s", provider, model)
533
+ logger.info("Rate limit sleep: %.1fs between calls", call_sleep)
534
+ logger.info("Cost: %s", "FREE" if price_in == 0 else f"${price_in}/M input, ${price_out}/M output")
535
+ logger.info("Base dir: %s", base_dir)
536
+ logger.info("Annotations: %s", annotations_path)
537
+ logger.info("New model → %s", new_model_path)
538
+ logger.info("Old model %s (will NOT be touched)", old_model_path)
539
+ logger.info("=" * 60)
540
+
541
+
542
+ if provider == "ollama":
543
+ import requests as _req
544
+ try:
545
+ r = _req.get("http://localhost:11434/api/tags", timeout=5)
546
+ r.raise_for_status()
547
+ available = [m["name"] for m in r.json().get("models", [])]
548
+ found = (
549
+ model in available
550
+ or model.split(":")[0] in [m.split(":")[0] for m in available]
551
+ )
552
+ if not found:
553
+ logger.error(
554
+ "Model '%s' not in Ollama. Available: %s. "
555
+ "Pull it: ollama pull %s", model, available, model
556
+ )
557
+ sys.exit(1)
558
+ logger.info("Ollama reachable. Model '%s' available.", model)
559
+ except _req.exceptions.ConnectionError:
560
+ logger.error(
561
+ "Ollama not running at localhost:11434. "
562
+ "On Windows it auto-starts after install — "
563
+ "check Task Manager for 'ollama' process."
564
+ )
565
+ sys.exit(1)
566
+
567
+
568
+ from features import build_feature_vector, FEATURE_COLUMNS
569
+ from features import (
570
+ c1_timeline_impossibility, c2_signup_anomaly,
571
+ c3_salary_inversion, c4_assessment_contradiction,
572
+ c5_engagement_mismatch,
573
+ )
574
+ from jd_parser import parse_jd
575
+ from retrieval import load_numpy_bm25_artifacts, run_dual_pass_retrieval
576
+
577
+
578
+ logger.info("STEP 1: Loading Stage 1 candidate pool...")
579
+
580
+ bm25 = load_numpy_bm25_artifacts(precomputed_dir)
581
+ if bm25 is None:
582
+ bm25_path = os.path.join(precomputed_dir, "bm25_index.pkl")
583
+ if not os.path.isfile(bm25_path):
584
+ logger.error("Missing bm25_index.pkl — run precompute.py first.")
585
+ sys.exit(1)
586
+ with open(bm25_path, "rb") as f:
587
+ bm25 = pickle.load(f)
588
+ logger.info("Loaded legacy BM25Okapi")
589
+ else:
590
+ logger.info("Loaded NumpyBM25 (fast path)")
591
+
592
+ ids_path = os.path.join(precomputed_dir, "candidate_ids.pkl")
593
+ with open(ids_path, "rb") as f:
594
+ all_candidate_ids = pickle.load(f)
595
+
596
+ aliases_path = os.path.join(data_dir, "skill_aliases.json")
597
+ jd_config = parse_jd(aliases_path)
598
+ logger.info(
599
+ "JD config: %d hard reqs, %d preferred reqs",
600
+ len(jd_config.hard_requirements), len(jd_config.preferred_requirements),
601
+ )
602
+
603
+ stage1_ids, bm25_scores = run_dual_pass_retrieval(bm25, all_candidate_ids, jd_config)
604
+ stage1_bm25_median = float(np.median(list(bm25_scores.values())))
605
+ logger.info("Stage 1 pool: %d candidates, median BM25=%.4f", len(stage1_ids), stage1_bm25_median)
606
+
607
+
608
+ offsets_path = os.path.join(precomputed_dir, "candidate_offsets.pkl")
609
+ stage1_candidate_list: List[dict] = []
610
+ if os.path.isfile(offsets_path):
611
+ with open(offsets_path, "rb") as f:
612
+ candidate_offsets = pickle.load(f)
613
+ logger.info("Loading Stage 1 records via byte-offset index...")
614
+ with open(candidates_path, "rb") as f:
615
+ for cid in stage1_ids:
616
+ offset = candidate_offsets.get(cid)
617
+ if offset is None:
618
+ continue
619
+ f.seek(offset)
620
+ raw = f.readline()
621
+ try:
622
+ c = json.loads(raw.decode("utf-8", errors="ignore").strip())
623
+ stage1_candidate_list.append(c)
624
+ except json.JSONDecodeError:
625
+ pass
626
+ else:
627
+ logger.info("No offset index — streaming JSONL (slow)...")
628
+ stage1_id_set = set(stage1_ids)
629
+ found: Dict[str, dict] = {}
630
+ with open(candidates_path, "r", encoding="utf-8") as f:
631
+ for line in f:
632
+ line = line.strip()
633
+ if not line:
634
+ continue
635
+ try:
636
+ c = json.loads(line)
637
+ except json.JSONDecodeError:
638
+ continue
639
+ cid = c.get("candidate_id")
640
+ if cid and cid in stage1_id_set:
641
+ found[cid] = c
642
+ if len(found) == len(stage1_id_set):
643
+ break
644
+ stage1_candidate_list = [found[cid] for cid in stage1_ids if cid in found]
645
+
646
+ stage1_candidates: Dict[str, dict] = {
647
+ c.get("candidate_id"): c
648
+ for c in stage1_candidate_list
649
+ if c.get("candidate_id")
650
+ }
651
+ logger.info("Stage 1 records loaded: %d candidates", len(stage1_candidates))
652
+
653
+
654
+ model_already_exists = os.path.isfile(new_model_path)
655
+ annots_already_exist = os.path.isfile(annotations_path)
656
+
657
+ if model_already_exists and annots_already_exist:
658
+ logger.info(
659
+ "lgbm_model_llm.pkl and annotations.jsonl both exist — "
660
+ "skipping Steps 2-10, running Step 11 comparison only."
661
+ )
662
+ with open(old_model_path, "rb") as f:
663
+ old_model = pickle.load(f)
664
+ with open(new_model_path, "rb") as f:
665
+ new_model = pickle.load(f)
666
+
667
+ logger.info("STEP 11: Generating model comparison report...")
668
+ print_model_comparison(
669
+ stage1_candidates=stage1_candidates,
670
+ stage1_ids=stage1_ids,
671
+ bm25_scores=bm25_scores,
672
+ stage1_bm25_median=stage1_bm25_median,
673
+ jd_config=jd_config,
674
+ old_model=old_model,
675
+ new_model=new_model,
676
+ feature_columns=FEATURE_COLUMNS,
677
+ )
678
+ logger.info("=" * 60)
679
+ logger.info("EXPERIMENT COMPLETE")
680
+ logger.info("New model: %s", new_model_path)
681
+ logger.info(
682
+ "To swap into production: copy %s %s",
683
+ new_model_path, old_model_path,
684
+ )
685
+ logger.info("(Manual, deliberate action only — verify top-10 first)")
686
+ logger.info("=" * 60)
687
+ return
688
+
689
+
690
+
691
+
692
+ logger.info("STEP 2: Stratified sampling of 500 candidates...")
693
+ random.seed(42)
694
+
695
+ from features import build_feature_vector
696
+ import lightgbm as lgb
697
+
698
+ with open(old_model_path, "rb") as f:
699
+ old_model_for_ranking = pickle.load(f)
700
+
701
+ logger.info("Computing feature vectors for all Stage 1 candidates...")
702
+ all_feature_rows = []
703
+ all_fv_ids = []
704
+ consistency_scores_all: Dict[str, float] = {}
705
+
706
+ for cid in stage1_ids:
707
+ candidate = stage1_candidates.get(cid)
708
+ if candidate is None:
709
+ continue
710
+ bs = bm25_scores.get(cid, 0.0)
711
+ try:
712
+ fv = build_feature_vector(candidate, jd_config, bm25_score=bs, stage1_bm25_median=stage1_bm25_median)
713
+ row = [fv[col] for col in FEATURE_COLUMNS]
714
+ consistency_scores_all[cid] = float(fv.get("consistency_score", 1.0))
715
+ except Exception:
716
+ row = [0.0] * len(FEATURE_COLUMNS)
717
+ consistency_scores_all[cid] = 1.0
718
+ all_feature_rows.append(row)
719
+ all_fv_ids.append(cid)
720
+
721
+ X_all = np.array(all_feature_rows, dtype=np.float32)
722
+ logger.info("Feature matrix (Stage 1): shape=%s", X_all.shape)
723
+
724
+ raw_scores = old_model_for_ranking.predict(X_all)
725
+ lgbm_ranked = sorted(zip(all_fv_ids, raw_scores), key=lambda x: -x[1])
726
+ lgbm_rank_map = {cid: rank for rank, (cid, _) in enumerate(lgbm_ranked, 1)}
727
+
728
+
729
+ TOTAL = 500
730
+ N_A, N_B, N_C = 75, 100, 325
731
+ MIN_LOW_CONS = 25
732
+
733
+ top100_cids = [cid for cid, _ in lgbm_ranked[:100]]
734
+ ranks_101_300 = [cid for cid, _ in lgbm_ranked[100:300]]
735
+ ranks_301_plus = [cid for cid, _ in lgbm_ranked[300:]]
736
+
737
+ stratum_a = random.sample(top100_cids, min(N_A, len(top100_cids)))
738
+ stratum_b = random.sample(ranks_101_300, min(N_B, len(ranks_101_300)))
739
+
740
+ low_cons_pool = [cid for cid in ranks_301_plus if consistency_scores_all.get(cid, 1.0) < 0.5]
741
+ guaranteed_low = random.sample(low_cons_pool, min(MIN_LOW_CONS, len(low_cons_pool)))
742
+ remaining_c = [cid for cid in ranks_301_plus if cid not in guaranteed_low]
743
+ fill_c = random.sample(remaining_c, max(0, N_C - len(guaranteed_low)))
744
+ stratum_c = guaranteed_low + fill_c
745
+
746
+ sample_ids = list(dict.fromkeys(stratum_a + stratum_b + stratum_c))[:TOTAL]
747
+ logger.info(
748
+ "Stratum sizes: A=%d (top-50 + 25 from 51-150), B=%d (51-150), C=%d (151+)",
749
+ len(stratum_a), len(stratum_b), len(stratum_c),
750
+ )
751
+ logger.info("Low-consistency guaranteed in Stratum C: %d (target: ≥%d)",
752
+ len(guaranteed_low), MIN_LOW_CONS)
753
+ logger.info("Total sample pool: %d candidates", len(sample_ids))
754
+
755
+
756
+ logger.info("STEP 3: Generating pairwise matchups (5 opponents per candidate)...")
757
+ N_OPPONENTS = 5
758
+ seen_pairs: set = set()
759
+ pairs: List[Tuple[str, str]] = []
760
+
761
+ for cid_a in sample_ids:
762
+ pool = [c for c in sample_ids if c != cid_a]
763
+ random.shuffle(pool)
764
+ count = 0
765
+ for cid_b in pool:
766
+ key = frozenset({cid_a, cid_b})
767
+ if key not in seen_pairs and count < N_OPPONENTS:
768
+ seen_pairs.add(key)
769
+ pairs.append((cid_a, cid_b))
770
+ count += 1
771
+
772
+ logger.info("Unique pairs generated: %d", len(pairs))
773
+
774
+
775
+ logger.info("STEP 6: Annotating pairs with %s (%s)...", provider, model)
776
+
777
+
778
+ existing_annotations: List[dict] = []
779
+ existing_pair_keys: set = set()
780
+ if os.path.isfile(annotations_path):
781
+ logger.info("Found existing annotations file — loading for resumability...")
782
+ with open(annotations_path, "r", encoding="utf-8") as f:
783
+ for line in f:
784
+ line = line.strip()
785
+ if not line:
786
+ continue
787
+ try:
788
+ ann = json.loads(line)
789
+ existing_annotations.append(ann)
790
+ existing_pair_keys.add(frozenset({ann["candidate_a"], ann["candidate_b"]}))
791
+ except json.JSONDecodeError:
792
+ pass
793
+ logger.info("Loaded %d existing annotations (will skip these pairs)", len(existing_annotations))
794
+
795
+ remaining_pairs = [(a, b) for a, b in pairs if frozenset({a, b}) not in existing_pair_keys]
796
+ logger.info("Pairs remaining to annotate: %d of %d", len(remaining_pairs), len(pairs))
797
+
798
+
799
+ jd_summary = build_jd_summary(jd_config)
800
+
801
+
802
+ client = None
803
+ if provider == "groq":
804
+ from groq import Groq
805
+ client = Groq(api_key=args.api_key)
806
+ logger.info("Groq client initialized (model: %s)", model)
807
+ elif provider == "anthropic":
808
+ import anthropic as _anthropic
809
+ client = _anthropic.Anthropic(api_key=args.api_key)
810
+ logger.info("Anthropic client initialized (model: %s)", model)
811
+ elif provider == "cerebras":
812
+ from cerebras.cloud.sdk import Cerebras
813
+ client = Cerebras(api_key=args.api_key)
814
+ logger.info("Cerebras client initialized (model: %s)", model)
815
+ else:
816
+ logger.info("Ollama provider: calls go directly to localhost:11434 via requests")
817
+
818
+
819
+ logger.info("Running 5-call timing probe for Ollama...")
820
+ probe_pairs = remaining_pairs[:5] if len(remaining_pairs) >= 5 else pairs[:5]
821
+ probe_times = []
822
+ probe_inp = []
823
+ probe_out = []
824
+
825
+ for i, (a, b) in enumerate(probe_pairs):
826
+ sa = build_candidate_summary(stage1_candidates.get(a, {"candidate_id": a}))
827
+ sb = build_candidate_summary(stage1_candidates.get(b, {"candidate_id": b}))
828
+ t0 = time.time()
829
+ _, inp, out = get_pairwise_judgment(client, provider, model, jd_summary, sa, sb, i)
830
+ elapsed = time.time() - t0
831
+ probe_times.append(elapsed)
832
+ probe_inp.append(inp)
833
+ probe_out.append(out)
834
+ time.sleep(call_sleep)
835
+
836
+ avg_secs = sum(probe_times) / len(probe_times)
837
+ avg_inp = sum(probe_inp) / len(probe_inp)
838
+ avg_out = sum(probe_out) / len(probe_out)
839
+ est_min = (avg_secs + call_sleep) * len(remaining_pairs) / 60
840
+ est_cost = (avg_inp * len(remaining_pairs) / 1e6 * price_in +
841
+ avg_out * len(remaining_pairs) / 1e6 * price_out)
842
+
843
+ print("\n" + "=" * 50)
844
+ print("=== RUN ESTIMATE ===")
845
+ print(f"Provider: {provider} ({model})")
846
+ print(f"Pairs to annotate: {len(remaining_pairs)}")
847
+ if provider in ("groq", "anthropic", "cerebras"):
848
+ print(f"Avg input tokens per call: {avg_inp:.0f}")
849
+ else:
850
+ print(f"Avg seconds per call: {avg_secs:.1f}s")
851
+ print(f"Estimated cost: {'FREE' if est_cost == 0 else f'${est_cost:.2f}'}")
852
+ print(f"Estimated time: ~{est_min:.0f} min ({est_min/60:.1f} hrs)")
853
+ if provider == "ollama":
854
+ print(f"GPU acceleration: {'YES' if avg_secs < 2.0 else 'NO — running on CPU (slow)'}")
855
+ print("=" * 50)
856
+
857
+ confirm = input("Proceed with full run? (yes/no): ").strip().lower()
858
+ if confirm != "yes":
859
+ logger.info("User declined — exiting. Run again to resume.")
860
+ sys.exit(0)
861
+
862
+ logger.info("Starting full annotation run (%d pairs remaining)...", len(remaining_pairs))
863
+
864
+ total_inp = 0
865
+ total_out = 0
866
+ annot_file = open(annotations_path, "a", encoding="utf-8")
867
+
868
+ try:
869
+ for idx, (cid_a, cid_b) in enumerate(remaining_pairs):
870
+ sa = build_candidate_summary(stage1_candidates.get(cid_a, {"candidate_id": cid_a}))
871
+ sb = build_candidate_summary(stage1_candidates.get(cid_b, {"candidate_id": cid_b}))
872
+
873
+ verdict, inp, out = get_pairwise_judgment(
874
+ client, provider, model, jd_summary, sa, sb, idx
875
+ )
876
+ total_inp += inp
877
+ total_out += out
878
+
879
+ record = {
880
+ "pair_id": idx,
881
+ "candidate_a": cid_a,
882
+ "candidate_b": cid_b,
883
+ "verdict": verdict,
884
+ "input_tokens": inp,
885
+ "output_tokens": out,
886
+ }
887
+ annot_file.write(json.dumps(record) + "\n")
888
+ annot_file.flush()
889
+ existing_annotations.append(record)
890
+
891
+ time.sleep(call_sleep)
892
+
893
+ if (idx + 1) % 100 == 0:
894
+ cost_so_far = (total_inp / 1e6 * price_in + total_out / 1e6 * price_out)
895
+ logger.info(
896
+ "Progress: %d/%d pairs | cost: $%.2f | elapsed: ~%d min",
897
+ idx + 1, len(remaining_pairs), cost_so_far, int((idx+1)*(avg_secs+call_sleep)/60)
898
+ )
899
+
900
+ except KeyboardInterrupt:
901
+ logger.info("")
902
+ logger.info("=" * 60)
903
+ logger.info("INTERRUPTED by user (Ctrl+C) — progress saved cleanly.")
904
+ logger.info("Pairs completed so far: %d", len(existing_annotations))
905
+ logger.info("Annotations file: %s", annotations_path)
906
+ logger.info("Re-run the same command to resume from pair %d.", len(existing_annotations))
907
+ logger.info("=" * 60)
908
+ annot_file.close()
909
+ sys.exit(0)
910
+ finally:
911
+ annot_file.close()
912
+
913
+ actual_cost = total_inp / 1e6 * price_in + total_out / 1e6 * price_out
914
+ logger.info(
915
+ "Annotation complete. Total tokens: %d input / %d output. "
916
+ "Actual total cost: $%.2f",
917
+ total_inp, total_out, actual_cost,
918
+ )
919
+
920
+
921
+ logger.info("STEP 7: Computing Elo scores from pairwise verdicts...")
922
+ elo_scores = compute_elo_scores(existing_annotations, sample_ids)
923
+ elo_vals = list(elo_scores.values())
924
+ logger.info(
925
+ "Elo distribution: min=%.1f, max=%.1f, mean=%.1f, std=%.1f",
926
+ min(elo_vals), max(elo_vals),
927
+ sum(elo_vals)/len(elo_vals),
928
+ float(np.std(elo_vals)),
929
+ )
930
+ winners = sum(1 for v in elo_vals if v > 1500)
931
+ logger.info("Elo above 1500 (winners): %d | at/below 1500 (losers): %d",
932
+ winners, len(elo_vals) - winners)
933
+
934
+
935
+ logger.info("STEP 8: Converting Elo scores to 0-3 relevance labels...")
936
+ labels = elo_to_labels(elo_scores)
937
+ dist = {0: 0, 1: 0, 2: 0, 3: 0}
938
+ for v in labels.values():
939
+ dist[v] += 1
940
+ logger.info("Label distribution: 0=%d, 1=%d, 2=%d, 3=%d",
941
+ dist[0], dist[1], dist[2], dist[3])
942
+ if dist[3] < 30:
943
+ logger.warning(
944
+ "Only %d candidates with label 3 — training signal may be sparse.", dist[3]
945
+ )
946
+
947
+
948
+ logger.info("STEP 9: Extracting feature matrix for %d annotated candidates...", len(sample_ids))
949
+ train_rows = []
950
+ train_ids = []
951
+ for cid in sample_ids:
952
+ candidate = stage1_candidates.get(cid)
953
+ if candidate is None:
954
+ continue
955
+ bs = bm25_scores.get(cid, 0.0)
956
+ try:
957
+ fv = build_feature_vector(candidate, jd_config, bm25_score=bs, stage1_bm25_median=stage1_bm25_median)
958
+ row = [fv[col] for col in FEATURE_COLUMNS]
959
+ except Exception as e:
960
+ logger.warning("Feature extraction failed for %s: %s", cid, e)
961
+ row = [0.0] * len(FEATURE_COLUMNS)
962
+ train_rows.append(row)
963
+ train_ids.append(cid)
964
+
965
+ X_train_full = np.array(train_rows, dtype=np.float32)
966
+ y_full = np.array([labels.get(cid, 0) for cid in train_ids], dtype=np.int32)
967
+ logger.info("Feature matrix (%d): shape=%s", len(sample_ids), X_train_full.shape)
968
+
969
+
970
+ logger.info("STEP 10: Training LightGBM on LLM pairwise labels...")
971
+
972
+ random.seed(42)
973
+ n_val = int(len(train_ids) * 0.2)
974
+ perm = list(range(len(train_ids)))
975
+ random.shuffle(perm)
976
+ val_idx = perm[:n_val]
977
+ train_idx = perm[n_val:]
978
+
979
+ X_tr = X_train_full[train_idx]
980
+ y_tr = y_full[train_idx]
981
+ X_vl = X_train_full[val_idx]
982
+ y_vl = y_full[val_idx]
983
+
984
+ logger.info("Train/val split: %d train, %d val", len(train_idx), len(val_idx))
985
+ dist_tr = {k: int((y_tr == k).sum()) for k in [0,1,2,3]}
986
+ logger.info("Train label distribution: 0=%d, 1=%d, 2=%d, 3=%d", *[dist_tr[k] for k in [0,1,2,3]])
987
+
988
+ train_ds = lgb.Dataset(X_tr, label=y_tr, group=[len(train_idx)], feature_name=FEATURE_COLUMNS)
989
+ val_ds = lgb.Dataset(X_vl, label=y_vl, group=[len(val_idx)], feature_name=FEATURE_COLUMNS, reference=train_ds)
990
+
991
+ params = {
992
+ "objective": "lambdarank",
993
+ "metric": "ndcg",
994
+ "eval_at": [5, 10, 50],
995
+ "num_leaves": 63,
996
+ "learning_rate": 0.05,
997
+ "min_child_samples": 20,
998
+ "subsample": 0.8,
999
+ "colsample_bytree": 0.8,
1000
+ "random_state": 42,
1001
+ "n_jobs": -1,
1002
+ "verbose": -1,
1003
+ }
1004
+
1005
+ t0 = time.time()
1006
+ new_model = lgb.train(
1007
+ params,
1008
+ train_ds,
1009
+ num_boost_round=300,
1010
+ valid_sets=[val_ds],
1011
+ callbacks=[
1012
+ lgb.early_stopping(stopping_rounds=30, verbose=False),
1013
+ lgb.log_evaluation(period=50),
1014
+ ],
1015
+ )
1016
+ logger.info("LightGBM training complete in %.1fs", time.time() - t0)
1017
+
1018
+ importances = sorted(
1019
+ zip(FEATURE_COLUMNS, new_model.feature_importance(importance_type="gain")),
1020
+ key=lambda x: x[1], reverse=True,
1021
+ )
1022
+ logger.info("Top 5 feature importances (gain):")
1023
+ for fname, imp in importances[:5]:
1024
+ logger.info(" %s: %.2f", fname, imp)
1025
+
1026
+ with open(new_model_path, "wb") as f:
1027
+ pickle.dump(new_model, f)
1028
+ logger.info("New model saved to: %s", new_model_path)
1029
+ logger.info("lgbm_model.pkl untouched: %s", old_model_path)
1030
+
1031
+
1032
+ logger.info("STEP 11: Generating model comparison report...")
1033
+ with open(old_model_path, "rb") as f:
1034
+ old_model_final = pickle.load(f)
1035
+
1036
+ print_model_comparison(
1037
+ stage1_candidates=stage1_candidates,
1038
+ stage1_ids=stage1_ids,
1039
+ bm25_scores=bm25_scores,
1040
+ stage1_bm25_median=stage1_bm25_median,
1041
+ jd_config=jd_config,
1042
+ old_model=old_model_final,
1043
+ new_model=new_model,
1044
+ feature_columns=FEATURE_COLUMNS,
1045
+ )
1046
+
1047
+ logger.info("=" * 60)
1048
+ logger.info("EXPERIMENT COMPLETE")
1049
+ logger.info("Annotations: %s", annotations_path)
1050
+ logger.info("New model: %s", new_model_path)
1051
+ logger.info(
1052
+ "To swap into production: copy %s %s (manual, deliberate action only)",
1053
+ new_model_path, old_model_path,
1054
+ )
1055
+ logger.info("=" * 60)
1056
+
1057
+
1058
+ if __name__ == "__main__":
1059
+ main()
pairwise_llm_check/annotations.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
precomputed/bm25_matrix.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:07f50329ca1d9db0c53c8f0234c176f5bb8ea384c811c455b5e9fd5888a50918
3
+ size 39616956
precomputed/candidate_ids.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5707f77732a33ae33beebe2a7006c6d108f4d78da3a56d9da247d52c264f40a6
3
+ size 1500412
precomputed/lgbm_model.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fb549f2b583260cdecca7232b92ec4a1c2142d120e3520c834069ced7b0a74c2
3
+ size 6053
precomputed/lgbm_model.txt ADDED
The diff for this file is too large to render. See raw diff
 
precomputed/vocab.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:869722ec126a1b1cc7fbc9349acb44b30174a60c0232ce6d02faf8aef6575d23
3
+ size 19535
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ rank-bm25==0.2.2
2
+ lightgbm==4.3.0
3
+ numpy==1.26.4
4
+ scipy==1.13.0
5
+ scikit-learn==1.4.2
6
+ pandas==2.2.2
7
+ matplotlib==3.9.2
8
+ PyYAML==6.0.1
9
+ streamlit==1.35.0
10
+ requests==2.34.2
scripts/app.py ADDED
@@ -0,0 +1,597 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import io
3
+ import json
4
+ import logging
5
+ import os
6
+ import pickle
7
+ import sys
8
+ import time
9
+ from typing import Dict, List, Optional
10
+ import numpy as np
11
+ import pandas as pd
12
+ import streamlit as st
13
+ from rank_bm25 import BM25Okapi
14
+
15
+ st.set_page_config(
16
+ page_title="Redrob Candidate Ranker",
17
+ layout="wide",
18
+ initial_sidebar_state="expanded",
19
+ )
20
+ _SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
21
+ _PROJECT_ROOT = os.path.dirname(_SCRIPTS_DIR)
22
+ _SRC_DIR = os.path.join(_PROJECT_ROOT, "src")
23
+ for _p in [_SRC_DIR, _SCRIPTS_DIR, _PROJECT_ROOT]:
24
+ if _p not in sys.path:
25
+ sys.path.insert(0, _p)
26
+
27
+ BASE_DIR = _PROJECT_ROOT
28
+ PRECOMPUTED_DIR = os.path.join(BASE_DIR, "precomputed")
29
+ DATA_DIR = os.path.join(BASE_DIR, "data")
30
+ ALIASES_PATH = os.path.join(DATA_DIR, "skill_aliases.json")
31
+
32
+ LITE_MODE_LIMIT = 10_000 # max cand. that can enter streamlit mode
33
+
34
+ @st.cache_resource(show_spinner="Loading JD configuration...")
35
+ def load_jd_config():
36
+ from jd_parser import parse_jd
37
+ return parse_jd(ALIASES_PATH)
38
+
39
+
40
+ @st.cache_resource(show_spinner="Loading BM25 index...")
41
+ def load_bm25():
42
+ from retrieval import load_numpy_bm25_artifacts
43
+ bm25 = load_numpy_bm25_artifacts(PRECOMPUTED_DIR)
44
+ ids_path = os.path.join(PRECOMPUTED_DIR, "candidate_ids.pkl")
45
+ if not os.path.isfile(ids_path):
46
+ return None, None
47
+ with open(ids_path, "rb") as f:
48
+ candidate_ids = pickle.load(f)
49
+
50
+ if bm25 is not None:
51
+ return bm25, candidate_ids
52
+
53
+ # Fallback to pickle
54
+ bm25_path = os.path.join(PRECOMPUTED_DIR, "bm25_index.pkl")
55
+ if not os.path.isfile(bm25_path):
56
+ return None, None
57
+ with open(bm25_path, "rb") as f:
58
+ bm25 = pickle.load(f)
59
+ return bm25, candidate_ids
60
+
61
+
62
+ @st.cache_resource(show_spinner="Loading LightGBM model...")
63
+ def load_model():
64
+ model_path = os.path.join(PRECOMPUTED_DIR, "lgbm_model.pkl")
65
+ if not os.path.isfile(model_path):
66
+ return None
67
+ with open(model_path, "rb") as f:
68
+ return pickle.load(f)
69
+
70
+
71
+ def sort_with_secondary_tiebreak(
72
+ final_scores: Dict[str, float],
73
+ fv_cache: Dict[str, dict],
74
+ logger,
75
+ ) -> List[tuple]:
76
+ """
77
+ Sort candidates by final_raw score (primary), then by hard_req_coverage
78
+ and bm25_score (secondary, display-only tiebreaks) when scores are tied.
79
+
80
+ This does NOT change the underlying score values or the model's
81
+ predictions — only the display order and assigned rank numbers when
82
+ raw_lgbm scores are identical, which happens on small batches because
83
+ the trained model places very low weight on bm25_score (confirmed:
84
+ bm25_score used in only 2 of ~12,600 possible tree splits).
85
+ """
86
+ def sort_key(item):
87
+ cid, score = item
88
+ fv = fv_cache.get(cid, {})
89
+ hard_req = fv.get("hard_req_coverage", 0.0)
90
+ bm25 = fv.get("bm25_score", 0.0)
91
+ # Negative for descending order on all three keys
92
+ return (-score, -hard_req, -bm25, cid)
93
+
94
+ sorted_items = sorted(final_scores.items(), key=sort_key)
95
+
96
+ # Reuse the existing normalization logic from rank.py, just on the
97
+ # newly-ordered list — normalization math itself is unchanged.
98
+ from rank import _normalize_scores
99
+ ranked_top100 = _normalize_scores(sorted_items, logger)
100
+ return ranked_top100
101
+
102
+ def rank_candidates_inline(
103
+ candidates: List[dict],
104
+ jd_config,
105
+ bm25,
106
+ candidate_ids: List[str],
107
+ model,
108
+ max_n: int = LITE_MODE_LIMIT,
109
+ ) -> Optional[pd.DataFrame]:
110
+ """Run the full ranking pipeline inline on a small candidate set."""
111
+ from retrieval import run_dual_pass_retrieval, tokenize_query
112
+ from features import build_feature_vector, FEATURE_COLUMNS, consistency_score
113
+ from reasoning import ReasoningCompiler
114
+ from precompute import tokenize_candidate
115
+
116
+ # this line allows a limited no of candidates for safety of memory
117
+ if len(candidates) > max_n:
118
+ st.warning(
119
+ f"Lite mode: processing first {max_n} of {len(candidates)} candidates "
120
+ f"to stay within 1GB RAM limit."
121
+ )
122
+ candidates = candidates[:max_n]
123
+
124
+ cids = [c.get("candidate_id", f"IDX_{i}") for i, c in enumerate(candidates)]
125
+ uploaded_cid_set = set(cids)
126
+
127
+ # Use the REAL precomputed 100K-corpus BM25 index, same as the production pipeline,
128
+ # so bm25_score means the same thing here as it does in src/rank.py.
129
+ bm25_scores = {}
130
+ in_main_index_count = 0
131
+ fallback_count = 0
132
+
133
+ if bm25 is not None and candidate_ids:
134
+ # Query the full 100K index with the same dual-pass logic as production
135
+ full_stage1_ids, full_bm25_scores = run_dual_pass_retrieval(bm25, candidate_ids, jd_config)
136
+ main_index_lookup = dict(zip(candidate_ids, range(len(candidate_ids))))
137
+
138
+ for cid in cids:
139
+ if cid in full_bm25_scores:
140
+ bm25_scores[cid] = full_bm25_scores[cid]
141
+ in_main_index_count += 1
142
+ elif cid in main_index_lookup:
143
+ # Candidate exists in the 100K corpus but wasn't in the dual-pass
144
+ # retrieved subset — score them at 0, consistent with how the
145
+ # production pipeline treats non-retrieved candidates.
146
+ bm25_scores[cid] = 0.0
147
+ in_main_index_count += 1
148
+ else:
149
+ fallback_count += 1
150
+
151
+ # Fallback: candidates genuinely NOT in the precomputed 100K corpus
152
+ # (e.g. a judge uploads new/synthetic candidates never seen during precompute).
153
+ # Build a small inline index ONLY for these, and warn the user explicitly
154
+ # that their bm25_score uses small-corpus statistics.
155
+ fallback_cids = [c.get("candidate_id", "") for c in candidates if c.get("candidate_id", "") not in bm25_scores]
156
+
157
+ if fallback_cids:
158
+ st.warning(
159
+ f"{len(fallback_cids)} of {len(candidates)} uploaded candidates were not found "
160
+ f"in the precomputed 100K corpus. Their BM25 scores are computed against a "
161
+ f"small inline index built only from this upload, which uses different term "
162
+ f"statistics than the production pipeline and may not be directly comparable "
163
+ f"to scores for candidates found in the main corpus."
164
+ )
165
+ fallback_candidates = [c for c in candidates if c.get("candidate_id", "") in fallback_cids]
166
+ fallback_corpus = [tokenize_candidate(c) for c in fallback_candidates]
167
+ if fallback_corpus:
168
+ fallback_bm25 = BM25Okapi(fallback_corpus)
169
+ fb_stage1_ids, fb_scores = run_dual_pass_retrieval(fallback_bm25, fallback_cids, jd_config)
170
+ bm25_scores.update(fb_scores)
171
+
172
+ median_bm25 = float(np.median(list(bm25_scores.values()))) if bm25_scores else 0.0
173
+
174
+ st.caption(
175
+ f"BM25 scoring: {in_main_index_count} candidates scored against the real "
176
+ f"100K-candidate corpus, {len(fallback_cids)} scored against a small inline "
177
+ f"fallback corpus."
178
+ )
179
+
180
+ feature_rows = []
181
+ valid_cids = []
182
+ consistency_map = {}
183
+ fv_cache = {}
184
+ for c in candidates:
185
+ cid = c.get("candidate_id", "")
186
+ bs = bm25_scores.get(cid, 0.0)
187
+ try:
188
+ fv = build_feature_vector(c, jd_config, bs, median_bm25)
189
+ fv_cache[cid] = fv
190
+ row = [fv[col] for col in FEATURE_COLUMNS]
191
+ consistency_map[cid] = float(fv.get("consistency_score", 1.0))
192
+ except Exception:
193
+ fv_cache[cid] = {col: 0.0 for col in FEATURE_COLUMNS}
194
+ row = [bs] + [0.0] * 21
195
+ consistency_map[cid] = 1.0
196
+ feature_rows.append(row)
197
+ valid_cids.append(cid)
198
+
199
+ debug_targets = {"CAND_0000014", "CAND_0000043", "CAND_0000082"}
200
+ for i, cid in enumerate(valid_cids):
201
+ if cid in debug_targets:
202
+ print(f"FEATURE VECTOR DEBUG | {cid} | row[0]={feature_rows[i][0]:.6f} (bm25) | full_row={feature_rows[i]}")
203
+ print(f"FEATURE_COLUMNS[0] = {FEATURE_COLUMNS[0]}")
204
+
205
+ X = np.array(feature_rows, dtype=np.float32)
206
+
207
+
208
+ if model is not None:
209
+ raw_scores = model.predict(X)
210
+ else:
211
+ raw_scores = np.array([bm25_scores.get(cid, 0.0) for cid in valid_cids])
212
+
213
+ # BUG 1: Apply consistency multiplier
214
+ final_scores = {}
215
+ for i, cid in enumerate(valid_cids):
216
+ final_scores[cid] = float(raw_scores[i] * consistency_map.get(cid, 1.0))
217
+
218
+ # BUG 2: Reuse exact sorting and normalization from src/rank.py
219
+ from rank import assert_monotonicity
220
+ ranked_top100 = sort_with_secondary_tiebreak(final_scores, fv_cache, logging.getLogger("app"))
221
+
222
+ try:
223
+ assert_monotonicity(ranked_top100)
224
+ except AssertionError as e:
225
+ st.error(f"Monotonicity Assertion Failed: {e}")
226
+
227
+ # DEBUG: Print top 10 raw scores for verification
228
+ print("\n" + "="*50)
229
+ print("TOP 10 RAW SCORES DEBUG (before normalization)")
230
+ print("="*50)
231
+ for cid, norm_score, rank_i in ranked_top100[:10]:
232
+ idx = valid_cids.index(cid)
233
+ raw = raw_scores[idx]
234
+ cons = consistency_map.get(cid, 1.0)
235
+ final = final_scores[cid]
236
+ bs = bm25_scores.get(cid, 0.0)
237
+ print(f"Rank {rank_i:02d} | {cid} | bm25: {bs:10.6f} | raw_lgbm: {raw:10.6f} | cons: {cons:4.2f} | final_raw: {final:10.6f} | norm: {norm_score:8.6f}")
238
+ print("="*50 + "\n")
239
+ print(f"BM25 scoping: in_main_index={in_main_index_count}, fallback={fallback_count}")
240
+
241
+ all_lgbm_scores = [final_scores[cid] for cid, _, _ in ranked_top100]
242
+ compiler = ReasoningCompiler(jd_config, all_scores=all_lgbm_scores)
243
+
244
+ candidate_lookup = {c.get("candidate_id"): c for c in candidates}
245
+
246
+ rows = []
247
+ for cid, norm_score, rank_i in ranked_top100:
248
+ raw_score = final_scores.get(cid, 0.0)
249
+ c = candidate_lookup.get(cid, {"candidate_id": cid})
250
+ fv = fv_cache.get(cid, {col: 0.0 for col in FEATURE_COLUMNS})
251
+ reasoning = compiler.compile(c, fv, raw_score, rank_i)
252
+ rows.append({
253
+ "rank": rank_i,
254
+ "candidate_id": cid,
255
+ "score": round(norm_score, 6),
256
+ "name": c.get("profile", {}).get("anonymized_name", ""),
257
+ "title": c.get("profile", {}).get("current_title", ""),
258
+ "company": c.get("profile", {}).get("current_company", ""),
259
+ "yoe": c.get("profile", {}).get("years_of_experience", 0),
260
+ "location": c.get("profile", {}).get("location", ""),
261
+ "hard_req_coverage": round(fv.get("hard_req_coverage", 0), 3),
262
+ "consistency_score": round(fv.get("consistency_score", 1), 3),
263
+ "reasoning": reasoning,
264
+ })
265
+
266
+ return pd.DataFrame(rows)
267
+
268
+
269
+
270
+ def main():
271
+ st.title(" Redrob Candidate Ranker")
272
+ st.caption(
273
+ "Candidate ranking: Redrob hackathon submission. "
274
+ "Lite mode (≤10K candidates, ≤1GB RAM)."
275
+ )
276
+
277
+ with st.sidebar:
278
+ st.header(" Pipeline status")
279
+
280
+ jd_config = load_jd_config()
281
+ st.success(
282
+ f" JD Config loaded: {len(jd_config.hard_requirements)} hard reqs, "
283
+ f"{len(jd_config.preferred_requirements)} preferred"
284
+ )
285
+
286
+ bm25, candidate_ids = load_bm25()
287
+ if bm25 is not None:
288
+ st.success(f"BM25 Index: {len(candidate_ids):,} candidates indexed")
289
+ else:
290
+ st.warning("BM25 index not found — run precompute.py first")
291
+
292
+ model = load_model()
293
+ if model is not None:
294
+ st.success("LightGBM model loaded")
295
+ else:
296
+ st.warning("LightGBM model not found — run precompute.py first")
297
+
298
+ st.divider()
299
+ st.header("JD Requirements")
300
+ with st.expander("Hard Requirements"):
301
+ for name in jd_config.hard_requirements:
302
+ st.write(f"• {name.replace('_', ' ').title()}")
303
+ with st.expander("Preferred Requirements"):
304
+ for name in jd_config.preferred_requirements:
305
+ st.write(f"• {name.replace('_', ' ').title()}")
306
+
307
+
308
+ tab1, tab2, tab3 = st.tabs(["Upload & Rank", "Architecture", "Validate"])
309
+
310
+ with tab1:
311
+ st.header("Upload Candidates & Run Ranking")
312
+
313
+ col1, col2 = st.columns([2, 1])
314
+
315
+ with col1:
316
+ uploaded_file = st.file_uploader(
317
+ "Upload candidates JSONL file",
318
+ type=["jsonl", "json", "txt"],
319
+ help=f"Max {LITE_MODE_LIMIT:,} candidates processed in lite mode.",
320
+ )
321
+
322
+ with col2:
323
+ st.metric("RAM Limit", "1 GB")
324
+ st.metric("Max Candidates", f"{LITE_MODE_LIMIT:,}")
325
+ if model is not None:
326
+ st.metric("Ranker", "LightGBM")
327
+ else:
328
+ st.metric("Ranker", "BM25 fallback")
329
+
330
+ if uploaded_file is not None:
331
+ # Parse JSONL
332
+ candidates = []
333
+ malformed = 0
334
+ for line in uploaded_file:
335
+ line = line.decode("utf-8", errors="ignore").strip()
336
+ if not line:
337
+ continue
338
+ try:
339
+ candidates.append(json.loads(line))
340
+ except json.JSONDecodeError:
341
+ malformed += 1
342
+
343
+ if malformed > 0:
344
+ st.warning(f" Skipped {malformed} malformed lines")
345
+
346
+ st.info(
347
+ f" Loaded {len(candidates):,} candidates from uploaded file"
348
+ )
349
+
350
+ if len(candidates) == 0:
351
+ st.error("No valid candidates found in uploaded file.")
352
+ else:
353
+ run_btn = st.button(
354
+ " Run ranking pipeline",
355
+ type="primary",
356
+ use_container_width=True,
357
+ )
358
+
359
+ if run_btn:
360
+ with st.spinner("Running ranking pipeline..."):
361
+ t0 = time.time()
362
+ try:
363
+ result_df = rank_candidates_inline(
364
+ candidates, jd_config, bm25, candidate_ids, model
365
+ )
366
+ elapsed = time.time() - t0
367
+
368
+ if result_df is not None and len(result_df) > 0:
369
+ st.success(
370
+ f" Ranked {len(result_df)} candidates in {elapsed:.1f}s"
371
+ )
372
+
373
+ m1, m2, m3, m4, m5 = st.columns(5)
374
+ m1.metric("Total Ranked", len(result_df))
375
+ m2.metric("Top Score", f"{result_df['score'].max():.4f}")
376
+ m3.metric(
377
+ "Avg Hard Req Coverage",
378
+ f"{result_df['hard_req_coverage'].mean():.1%}"
379
+ )
380
+ m4.metric("Wall-clock", f"{elapsed:.1f}s")
381
+ low_cons_count = (result_df["consistency_score"] < 0.25).sum()
382
+ m5.metric("Honeypots", f"{low_cons_count}/{len(result_df)} flagged",
383
+ delta="PASS" if low_cons_count < 10 else "FAIL", delta_color="normal" if low_cons_count < 10 else "inverse")
384
+
385
+
386
+ st.subheader("Top 100 Candidates")
387
+ st.caption(
388
+ "Note: candidates with identical model scores are ordered by hard "
389
+ "requirement coverage, then BM25 relevance, for display purposes. "
390
+ "The underlying model scores are unchanged."
391
+ )
392
+ display_df = result_df[[
393
+ "rank", "candidate_id", "name", "title",
394
+ "company", "yoe", "location", "score",
395
+ "hard_req_coverage", "consistency_score"
396
+ ]].copy()
397
+ st.dataframe(
398
+ display_df.style.background_gradient(
399
+ subset=["score"], cmap="RdYlGn"
400
+ ),
401
+ use_container_width=True,
402
+ height=500,
403
+ )
404
+
405
+ st.subheader("Reasoning Explorer")
406
+ selected_rank = st.slider(
407
+ "Select candidate rank to view reasoning:",
408
+ min_value=1, max_value=min(100, len(result_df))
409
+ )
410
+ selected_row = result_df[result_df["rank"] == selected_rank]
411
+ if not selected_row.empty:
412
+ row = selected_row.iloc[0]
413
+ with st.expander(
414
+ f"Rank {selected_rank}: {row['name']} — {row['title']} @ {row['company']}",
415
+ expanded=True
416
+ ):
417
+ col_a, col_b = st.columns(2)
418
+ col_a.metric("Score", f"{row['score']:.6f}")
419
+ col_a.metric("Hard Req Coverage", f"{row['hard_req_coverage']:.1%}")
420
+ col_b.metric("YoE", f"{row['yoe']}")
421
+ col_b.metric("Consistency", f"{row['consistency_score']:.2f}")
422
+ st.markdown(f"**Reasoning:** {row['reasoning']}")
423
+
424
+ # BUG 3 & 4: Explicit CSV copy and index=False, utf-8 bytes (no BOM)
425
+ export_df = result_df[["candidate_id", "rank", "score", "reasoning"]].copy()
426
+ csv_bytes = export_df.to_csv(index=False).encode("utf-8")
427
+ st.download_button(
428
+ label=" Download submission.csv",
429
+ data=csv_bytes,
430
+ file_name="submission.csv",
431
+ mime="text/csv",
432
+ use_container_width=True,
433
+ )
434
+ else:
435
+ st.error("Ranking produced no results.")
436
+ except Exception as e:
437
+ st.error(f"Pipeline error: {e}")
438
+ import traceback
439
+ st.code(traceback.format_exc())
440
+ else:
441
+ st.info(
442
+ " Upload a JSONL file of candidate records to rank them. "
443
+ "The file must match the Redrob candidate schema."
444
+ )
445
+ # sample
446
+ with st.expander("Expected JSONL format (one candidate per line)"):
447
+ sample = {
448
+ "candidate_id": "CAND_0000001",
449
+ "profile": {
450
+ "anonymized_name": "Alex Kumar",
451
+ "headline": "ML Engineer | FAISS | BM25",
452
+ "summary": "...",
453
+ "location": "Pune",
454
+ "country": "India",
455
+ "years_of_experience": 5,
456
+ "current_title": "Senior ML Engineer",
457
+ "current_company": "TechCorp",
458
+ "current_company_size": "201-500",
459
+ "current_industry": "Technology"
460
+ },
461
+ "...": "see candidate_schema.json for full structure"
462
+ }
463
+ st.json(sample)
464
+
465
+
466
+ with tab2:
467
+ st.header("Architecture Overview")
468
+
469
+ col1, col2 = st.columns(2)
470
+ with col1:
471
+ st.subheader("Pipeline Stages")
472
+ st.markdown("""
473
+ | Stage | Operation | Runtime |
474
+ |-------|-----------|---------|
475
+ | 1 | Load BM25 & Dual-Pass Retrieval | 1–2s |
476
+ | 2 | Feature Extraction (22 features) | 15–25s |
477
+ | 4 | LightGBM LambdaRank Inference | 1–3s |
478
+ | 5 | Reasoning Compilation + Audits | 1–2s |
479
+ | 6 | Monotonicity Assert + CSV Write | <1s |
480
+ | **Total** | **End-to-End** | **3.55s** |
481
+ """)
482
+
483
+ with col2:
484
+ st.subheader("Hardware Constraints")
485
+ st.markdown("""
486
+ - **≤5 minutes** clock
487
+ - **≤16 GB RAM** CPU only
488
+ - **Zero** network calls during ranking
489
+ - **≤5 GB** intermediate disk state
490
+ - **Docker** `--network none` compatible
491
+ """)
492
+
493
+ st.subheader("22-Feature Matrix")
494
+ features_df = pd.DataFrame([
495
+ {"#": 1, "Feature": "bm25_score", "Source": "BM25 retrieval"},
496
+ {"#": 2, "Feature": "yoe", "Source": "profile.years_of_experience"},
497
+ {"#": 3, "Feature": "Param_A_Systems_Depth", "Source": "career_history[].description + duration_months"},
498
+ {"#": 4, "Feature": "Param_B_Availability", "Source": "redrob_signals.recruiter_response_rate + last_active_date"},
499
+ {"#": 5, "Feature": "Param_C_Tenure", "Source": "career_history[].duration_months"},
500
+ {"#": 6, "Feature": "Param_D_Notice_Exp", "Source": "redrob_signals.notice_period_days"},
501
+ {"#": 7, "Feature": "Param_E_Credibility", "Source": "skills[].proficiency + skill_assessment_scores"},
502
+ {"#": 8, "Feature": "Param_F_Consulting", "Source": "career_history[].industry + duration_months"},
503
+ {"#": 9, "Feature": "Param_G_Location", "Source": "profile.location + country"},
504
+ {"#": 10, "Feature": "Param_H_GitHub", "Source": "redrob_signals.github_activity_score"},
505
+ {"#": 11, "Feature": "title_ai_fraction", "Source": "career_history[].title"},
506
+ {"#": 12, "Feature": "prod_signal_log", "Source": "career_history[].description"},
507
+ {"#": 13, "Feature": "consistency_score", "Source": "c1×c2×c3×c4×c5"},
508
+ {"#": 14, "Feature": "hard_req_coverage", "Source": "skills[].name vs JD aliases"},
509
+ {"#": 15, "Feature": "flag_consulting_only", "Source": "career_history[].industry"},
510
+ {"#": 16, "Feature": "flag_title_chaser", "Source": "career_history[].title + duration_months"},
511
+ {"#": 17, "Feature": "flag_langchain_dabbler", "Source": "skills[].name + duration_months"},
512
+ {"#": 18, "Feature": "flag_cv_specialist", "Source": "skills[].name + duration_months"},
513
+ {"#": 19, "Feature": "flag_title_desc_mismatch", "Source": "career_history[].title + description"},
514
+ {"#": 20, "Feature": "flag_template_desc", "Source": "career_history[].description"},
515
+ {"#": 21, "Feature": "interaction_req_x_consistency", "Source": "hard_req_coverage × consistency_score"},
516
+ {"#": 22, "Feature": "interaction_yoe_x_prod", "Source": "yoe × prod_signal_log"},
517
+ ])
518
+ st.dataframe(features_df, use_container_width=True, hide_index=True)
519
+
520
+
521
+ with tab3:
522
+ st.header("Validate Submission CSV")
523
+ st.info(
524
+ "Upload your submission.csv to run local format validation "
525
+ "before spending one of 3 competition submissions."
526
+ )
527
+
528
+ val_file = st.file_uploader(
529
+ "Upload submission.csv", type=["csv"], key="val_uploader"
530
+ )
531
+ if val_file is not None:
532
+ try:
533
+ df = pd.read_csv(val_file)
534
+ errors = []
535
+ warnings_list = []
536
+
537
+ required_cols = {"candidate_id", "rank", "score", "reasoning"}
538
+ missing_cols = required_cols - set(df.columns)
539
+ if missing_cols:
540
+ errors.append(f"Missing columns: {missing_cols}")
541
+
542
+ if not errors:
543
+
544
+ if len(df) != 100:
545
+ errors.append(f"Expected 100 rows, got {len(df)}")
546
+
547
+ if set(df["rank"].tolist()) != set(range(1, 101)):
548
+ errors.append("Ranks must be exactly 1–100 with no gaps")
549
+
550
+ df_sorted = df.sort_values("rank")
551
+ scores = df_sorted["score"].values
552
+ for i in range(1, len(scores)):
553
+ if scores[i] > scores[i-1] + 1e-9:
554
+ errors.append(
555
+ f"Score not monotonically non-increasing at rank {i+1}: "
556
+ f"{scores[i-1]:.6f} → {scores[i]:.6f}"
557
+ )
558
+ break
559
+
560
+ if df["score"].min() < 0 or df["score"].max() > 1:
561
+ warnings_list.append(
562
+ f"Scores outside [0,1]: min={df['score'].min():.4f}, "
563
+ f"max={df['score'].max():.4f}"
564
+ )
565
+
566
+ empty_reasoning = df["reasoning"].isna() | (df["reasoning"].str.strip() == "")
567
+ if empty_reasoning.any():
568
+ errors.append(
569
+ f"{empty_reasoning.sum()} rows have empty reasoning"
570
+ )
571
+
572
+ if df["candidate_id"].duplicated().any():
573
+ errors.append("Duplicate candidate_ids found")
574
+
575
+ if errors:
576
+ st.error(f"Validation failed!!({len(errors)} errors):")
577
+ for e in errors:
578
+ st.write(f" • {e}")
579
+ else:
580
+ st.success("Validation paased!!")
581
+ if warnings_list:
582
+ for w in warnings_list:
583
+ st.warning(f"warning {w}")
584
+
585
+ col1, col2, col3 = st.columns(3)
586
+ col1.metric("Rows", len(df))
587
+ col2.metric("Score Range", f"{df['score'].min():.4f}–{df['score'].max():.4f}")
588
+ col3.metric("Reasoning Coverage", "100%")
589
+
590
+ st.dataframe(df.head(10), use_container_width=True)
591
+
592
+ except Exception as e:
593
+ st.error(f"Failed to parse CSV: {e}")
594
+
595
+
596
+ if __name__ == "__main__":
597
+ main()
scripts/precompute.py ADDED
@@ -0,0 +1,633 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import argparse
3
+ import json
4
+ import logging
5
+ import math
6
+ import os
7
+ import pickle
8
+ import sys
9
+ import time
10
+ from typing import Dict, List, Optional, Tuple
11
+ import numpy as np
12
+
13
+ _SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
14
+ _PROJECT_ROOT = os.path.dirname(_SCRIPTS_DIR)
15
+ _SRC_DIR = os.path.join(_PROJECT_ROOT, "src")
16
+ for _p in [_SRC_DIR, _PROJECT_ROOT]:
17
+ if _p not in sys.path:
18
+ sys.path.insert(0, _p)
19
+
20
+ try:
21
+ from rank_bm25 import BM25Okapi
22
+ except ImportError:
23
+ import subprocess
24
+ print("rank_bm25 module not found, installing rank-bm25...")
25
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "rank-bm25==0.2.2"])
26
+ from rank_bm25 import BM25Okapi
27
+
28
+ logging.basicConfig(
29
+ level=logging.INFO,
30
+ format="%(asctime)s %(levelname)s [precompute] %(message)s",
31
+ datefmt="%H:%M:%S",
32
+ )
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ def tokenize_candidate(candidate: dict) -> List[str]:
37
+ """
38
+ Build a BM25-indexable token list from a candidate record.
39
+ Combines: skill names, career descriptions, headline, summary.
40
+
41
+ Defensive: handles missing/null fields gracefully.
42
+ """
43
+ tokens = []
44
+
45
+ for skill in (candidate.get("skills") or []):
46
+ name = (skill.get("name") or "").strip()
47
+ if name:
48
+ tokens.extend(name.lower().split())
49
+
50
+ # career history descriptions
51
+ for ch in (candidate.get("career_history") or []):
52
+ desc = (ch.get("description") or "").strip()
53
+ title = (ch.get("title") or "").strip()
54
+ if desc:
55
+ tokens.extend(desc.lower().split())
56
+ if title:
57
+ tokens.extend(title.lower().split())
58
+
59
+ # headline
60
+ profile = candidate.get("profile") or {}
61
+ headline = (profile.get("headline") or "").strip()
62
+ if headline:
63
+ tokens.extend(headline.lower().split())
64
+
65
+ # certifications
66
+ for cert in (candidate.get("certifications") or []):
67
+ name = (cert.get("name") or "").strip()
68
+ if name:
69
+ tokens.extend(name.lower().split())
70
+
71
+ return tokens
72
+
73
+
74
+ def stream_build_bm25_corpus(
75
+ candidates_path: str,
76
+ max_candidates: Optional[int] = None,
77
+ ) -> Tuple[List[str], List[List[str]], int]:
78
+ """
79
+ Stream-read candidates.jsonl and build the BM25 corpus.
80
+
81
+ Returns:
82
+ (candidate_ids, tokenized_corpus, malformed_count)
83
+ """
84
+ candidate_ids = []
85
+ corpus = []
86
+ malformed_count = 0
87
+ total_lines = 0
88
+
89
+ logger.info("Building BM25 corpus from %s ...", candidates_path)
90
+ t0 = time.time()
91
+
92
+ with open(candidates_path, "r", encoding="utf-8") as f:
93
+ for line_num, line in enumerate(f, 1):
94
+ line = line.strip()
95
+ if not line:
96
+ continue
97
+ total_lines += 1
98
+
99
+ try:
100
+ candidate = json.loads(line)
101
+ except json.JSONDecodeError as e:
102
+ malformed_count += 1
103
+ logger.warning("Malformed JSON at line %d (skipped): %s", line_num, e)
104
+ continue
105
+
106
+ cid = candidate.get("candidate_id")
107
+ if not cid:
108
+ malformed_count += 1
109
+ logger.warning("Missing candidate_id at line %d (skipped)", line_num)
110
+ continue
111
+
112
+ tokens = tokenize_candidate(candidate)
113
+ candidate_ids.append(cid)
114
+ corpus.append(tokens)
115
+
116
+ if line_num % 10000 == 0:
117
+ elapsed = time.time() - t0
118
+ logger.info(
119
+ " Tokenized %d/%s candidates in %.1fs...",
120
+ line_num, max_candidates or "?", elapsed
121
+ )
122
+
123
+ if max_candidates and len(candidate_ids) >= max_candidates:
124
+ break
125
+
126
+ elapsed = time.time() - t0
127
+ logger.info(
128
+ "Corpus built: %d candidates, %d malformed lines, %.1fs",
129
+ len(candidate_ids), malformed_count, elapsed
130
+ )
131
+ return candidate_ids, corpus, malformed_count
132
+
133
+
134
+ def build_bm25_index(corpus: List[List[str]]):
135
+ """Build BM25 index from tokenized corpus. Returns BM25Okapi object."""
136
+ logger.info("Building BM25Okapi index on %d documents...", len(corpus))
137
+ t0 = time.time()
138
+ bm25 = BM25Okapi(corpus)
139
+ elapsed = time.time() - t0
140
+ logger.info("BM25 index built in %.1fs", elapsed)
141
+ return bm25
142
+
143
+
144
+ def compute_offline_weak_labels(
145
+ candidates_path: str,
146
+ jd_config,
147
+ candidate_ids_set: set,
148
+ ) -> Tuple[Dict[str, float], Dict[str, float], Dict[str, float]]:
149
+ """
150
+ Compute weak labels for training WITHOUT using bm25_score (non-circularity guarantee).
151
+
152
+ Label formula (Section 6):
153
+ weak_label = hard_req_coverage × consistency_score
154
+
155
+ bm25_score is EXPLICITLY EXCLUDED from label construction.
156
+
157
+ Returns:
158
+ (weak_labels_dict, hard_req_scores_dict, consistency_scores_dict)
159
+ """
160
+ from features import (
161
+ c1_timeline_impossibility, c2_signup_anomaly, c3_salary_inversion,
162
+ c4_assessment_contradiction, c5_engagement_mismatch,
163
+ consistency_score as compute_consistency
164
+ )
165
+ from jd_parser import hard_req_coverage_score
166
+
167
+ logger.info("Computing offline weak labels (no bm25_score)...")
168
+ t0 = time.time()
169
+
170
+ weak_labels = {}
171
+ hard_req_scores = {}
172
+ consistency_scores = {}
173
+ processed = 0
174
+
175
+ with open(candidates_path, "r", encoding="utf-8") as f:
176
+ for line in f:
177
+ line = line.strip()
178
+ if not line:
179
+ continue
180
+ try:
181
+ candidate = json.loads(line)
182
+ except json.JSONDecodeError:
183
+ continue
184
+
185
+ cid = candidate.get("candidate_id")
186
+ if not cid or cid not in candidate_ids_set:
187
+ continue
188
+
189
+ # hard requirement coverage
190
+ hrc = hard_req_coverage_score(candidate, jd_config)
191
+ c1 = c1_timeline_impossibility(candidate)
192
+ c2 = c2_signup_anomaly(candidate)
193
+ c3 = c3_salary_inversion(candidate)
194
+ c4 = c4_assessment_contradiction(candidate)
195
+ cons = c1 * c2 * c3 * c4
196
+ from features import (
197
+ detect_description_title_mismatch,
198
+ score_langchain_dabbler,
199
+ score_title_skill_discontinuity,
200
+ score_cv_speech_specialist,
201
+ )
202
+
203
+ # consulting fraction inline
204
+ consulting_m = sum(
205
+ float(r.get("duration_months") or 0)
206
+ for r in (candidate.get("career_history") or [])
207
+ if r.get("industry", "") in {"IT Services", "Consulting", "Professional Services", "BPO"}
208
+ and r.get("company_size", "") == "10001+"
209
+ )
210
+ total_m = sum(
211
+ float(r.get("duration_months") or 0)
212
+ for r in (candidate.get("career_history") or [])
213
+ )
214
+ cons_frac = (consulting_m / total_m) if total_m > 0 else 0.0
215
+
216
+ jd_penalty = max(0.0, 1.0 - (
217
+ 0.90 * score_langchain_dabbler(candidate) +
218
+ 0.85 * score_title_skill_discontinuity(candidate) +
219
+ 0.75 * float(cons_frac > 0.95) +
220
+ 0.65 * float(detect_description_title_mismatch(candidate) > 0.5) +
221
+ 0.55 * score_cv_speech_specialist(candidate)
222
+ ))
223
+
224
+ wl = hrc * cons * jd_penalty
225
+
226
+ hard_req_scores[cid] = hrc
227
+ consistency_scores[cid] = cons
228
+ weak_labels[cid] = wl
229
+
230
+ processed += 1
231
+ if processed % 10000 == 0:
232
+ logger.info(" Weak labels: %d computed...", processed)
233
+
234
+ elapsed = time.time() - t0
235
+ logger.info(
236
+ "Weak labels computed: %d candidates in %.1fs", len(weak_labels), elapsed
237
+ )
238
+ logger.info(
239
+ "Label stats: min=%.4f, max=%.4f, mean=%.4f, >0: %d",
240
+ min(weak_labels.values()),
241
+ max(weak_labels.values()),
242
+ sum(weak_labels.values()) / max(1, len(weak_labels)),
243
+ sum(1 for v in weak_labels.values() if v > 0),
244
+ )
245
+ return weak_labels, hard_req_scores, consistency_scores
246
+
247
+
248
+ def extract_training_features(
249
+ candidates_path: str,
250
+ candidate_ids: List[str],
251
+ jd_config,
252
+ hard_req_scores: Dict[str, float],
253
+ consistency_scores: Dict[str, float],
254
+ ) -> Tuple[np.ndarray, List[str]]:
255
+ """
256
+ Extract the full 22-feature matrix for all indexed candidates.
257
+ bm25_score is set to 0.0 for all candidates at training time.
258
+
259
+ Returns:
260
+ (feature_matrix: np.ndarray of shape [N, 22], ordered_ids)
261
+ """
262
+ from features import build_feature_vector, FEATURE_COLUMNS
263
+
264
+ logger.info("Extracting 22-feature matrix for %d candidates...", len(candidate_ids))
265
+ t0 = time.time()
266
+
267
+ cid_set = set(candidate_ids)
268
+ feature_rows = {}
269
+
270
+ with open(candidates_path, "r", encoding="utf-8") as f:
271
+ for line in f:
272
+ line = line.strip()
273
+ if not line:
274
+ continue
275
+ try:
276
+ candidate = json.loads(line)
277
+ except json.JSONDecodeError:
278
+ continue
279
+
280
+ cid = candidate.get("candidate_id")
281
+ if not cid or cid not in cid_set:
282
+ continue
283
+
284
+ try:
285
+ fv = build_feature_vector(
286
+ candidate, jd_config,
287
+ bm25_score=0.0,
288
+ stage1_bm25_median=0.0,
289
+ )
290
+ except Exception as e:
291
+ logger.warning("Feature extraction failed for %s: %s", cid, e)
292
+ fv = {col: 0.0 for col in FEATURE_COLUMNS}
293
+
294
+ feature_rows[cid] = [fv[col] for col in FEATURE_COLUMNS]
295
+
296
+ if len(feature_rows) % 10000 == 0:
297
+ logger.info(" Features: %d extracted...", len(feature_rows))
298
+
299
+ matrix = []
300
+ ordered_ids = []
301
+ for cid in candidate_ids:
302
+ if cid in feature_rows:
303
+ matrix.append(feature_rows[cid])
304
+ ordered_ids.append(cid)
305
+
306
+ X = np.array(matrix, dtype=np.float32)
307
+ elapsed = time.time() - t0
308
+ logger.info(
309
+ "Feature matrix shape: %s in %.1fs", X.shape, elapsed
310
+ )
311
+ return X, ordered_ids
312
+
313
+
314
+ def train_lightgbm(
315
+ X: np.ndarray,
316
+ weak_labels: Dict[str, float],
317
+ ordered_ids: List[str],
318
+ precomputed_dir: str,
319
+ ) -> None:
320
+ """
321
+ Train LightGBM with objective='lambdarank' and eval_at=[5, 10, 50].
322
+
323
+ LightGBM lambdarank has a hard limit of max_position (<=10000) rows per query.
324
+ With 100K candidates, we split into multiple query groups of GROUP_SIZE each.
325
+ Each group simulates a "mini-query" with the same JD — the model still learns
326
+ to rank candidates by relevance within each group, then generalizes across groups.
327
+
328
+ Labels are discretized to integer bins [0, 1, 2, 3] for lambdarank.
329
+ """
330
+ try:
331
+ import lightgbm as lgb
332
+ except ImportError:
333
+ import subprocess
334
+ import sys
335
+ logger.info("lightgbm module not found, installing lightgbm...")
336
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "lightgbm==4.3.0"])
337
+ import lightgbm as lgb
338
+ from features import FEATURE_COLUMNS
339
+
340
+ logger.info("Training LightGBM LambdaRank model...")
341
+ t0 = time.time()
342
+
343
+
344
+ y_raw = np.array([weak_labels.get(cid, 0.0) for cid in ordered_ids], dtype=np.float32)
345
+
346
+
347
+ y_int = np.zeros(len(y_raw), dtype=np.int32)
348
+ y_int[y_raw > 0] = 1
349
+ y_int[y_raw > 0.33] = 2
350
+ y_int[y_raw > 0.66] = 3
351
+
352
+ logger.info(
353
+ "Label distribution: 0=%d, 1=%d, 2=%d, 3=%d",
354
+ (y_int == 0).sum(), (y_int == 1).sum(),
355
+ (y_int == 2).sum(), (y_int == 3).sum()
356
+ )
357
+
358
+
359
+ # spliting 100K candidates into groups of GROUP_SIZE
360
+ GROUP_SIZE = 5000
361
+ n = len(ordered_ids)
362
+
363
+ rng = np.random.default_rng(seed=42)
364
+ shuffle_idx = rng.permutation(n)
365
+ X_shuffled = X[shuffle_idx]
366
+ y_shuffled = y_int[shuffle_idx]
367
+
368
+ # build group sizes
369
+ n_groups = (n + GROUP_SIZE - 1) // GROUP_SIZE # ceiling division
370
+ group = []
371
+ for i in range(n_groups):
372
+ start = i * GROUP_SIZE
373
+ end = min(start + GROUP_SIZE, n)
374
+ group.append(end - start)
375
+
376
+ logger.info(
377
+ "LambdaRank: %d candidates split into %d query groups of size ~%d",
378
+ n, n_groups, GROUP_SIZE
379
+ )
380
+
381
+ train_data = lgb.Dataset(
382
+ X_shuffled, label=y_shuffled,
383
+ group=group,
384
+ feature_name=FEATURE_COLUMNS,
385
+ )
386
+
387
+ params = {
388
+ "objective": "lambdarank",
389
+ "metric": "ndcg",
390
+ "eval_at": [5, 10, 50],
391
+ "num_leaves": 63,
392
+ "learning_rate": 0.05,
393
+ "min_child_samples": 20,
394
+ "subsample": 0.8,
395
+ "colsample_bytree": 0.8,
396
+ "random_state": 42,
397
+ "n_jobs": -1,
398
+ "verbose": -1,
399
+ }
400
+
401
+ model = lgb.train(
402
+ params,
403
+ train_data,
404
+ num_boost_round=200,
405
+ valid_sets=[train_data],
406
+ callbacks=[
407
+ lgb.log_evaluation(period=50),
408
+ lgb.early_stopping(stopping_rounds=20, verbose=False),
409
+ ],
410
+ )
411
+
412
+ elapsed = time.time() - t0
413
+ logger.info("LightGBM training complete in %.1fs", elapsed)
414
+
415
+ importances = dict(zip(FEATURE_COLUMNS, model.feature_importance(importance_type="gain")))
416
+ sorted_imp = sorted(importances.items(), key=lambda x: x[1], reverse=True)
417
+ logger.info("Top 5 feature importances (gain):")
418
+ for fname, imp in sorted_imp[:5]:
419
+ logger.info(" %s: %.2f", fname, imp)
420
+
421
+ model_path = os.path.join(precomputed_dir, "lgbm_model.pkl")
422
+ with open(model_path, "wb") as f:
423
+ pickle.dump(model, f)
424
+ logger.info("LightGBM model saved to %s", model_path)
425
+
426
+
427
+ def save_artifacts(
428
+ precomputed_dir: str,
429
+ bm25,
430
+ candidate_ids: List[str],
431
+ weak_labels: Dict[str, float],
432
+ ) -> None:
433
+ """Save BM25 index, candidate IDs, and weak labels to precomputed/."""
434
+ os.makedirs(precomputed_dir, exist_ok=True)
435
+
436
+ bm25_path = os.path.join(precomputed_dir, "bm25_index.pkl")
437
+ ids_path = os.path.join(precomputed_dir, "candidate_ids.pkl")
438
+ labels_path = os.path.join(precomputed_dir, "weak_labels.pkl")
439
+
440
+ with open(bm25_path, "wb") as f:
441
+ pickle.dump(bm25, f)
442
+ logger.info("BM25 index saved: %s (%.1f MB)", bm25_path,
443
+ os.path.getsize(bm25_path) / 1e6)
444
+
445
+ with open(ids_path, "wb") as f:
446
+ pickle.dump(candidate_ids, f)
447
+ logger.info("Candidate IDs saved: %s (%d IDs)", ids_path, len(candidate_ids))
448
+
449
+ with open(labels_path, "wb") as f:
450
+ pickle.dump(weak_labels, f)
451
+ logger.info("Weak labels saved: %s", labels_path)
452
+
453
+
454
+ def compute_and_save_static_features(
455
+ candidates_path: str,
456
+ candidate_ids: List[str],
457
+ precomputed_dir: str,
458
+ ) -> None:
459
+ """
460
+ Compute 18 JD-independent features for all candidate profiles and save them to static_features.pkl.
461
+ """
462
+ from features import (
463
+ compute_yoe, compute_param_a_systems_depth, compute_param_b_availability,
464
+ compute_param_c_tenure, compute_param_d_notice_exp, compute_param_e_credibility,
465
+ compute_param_f_consulting, compute_param_g_location, compute_param_h_github,
466
+ compute_title_ai_fraction, compute_prod_signal_log, compute_flag_consulting_only,
467
+ compute_flag_title_chaser, compute_flag_langchain_dabbler, compute_flag_cv_specialist,
468
+ compute_flag_title_desc_mismatch, compute_flag_template_desc
469
+ )
470
+
471
+ logger.info("Computing 18 JD-independent features for all candidates offline...")
472
+ t0 = time.time()
473
+
474
+ candidate_ids_set = set(candidate_ids)
475
+ static_features = {}
476
+
477
+ with open(candidates_path, "r", encoding="utf-8") as f:
478
+ for line in f:
479
+ line = line.strip()
480
+ if not line:
481
+ continue
482
+ try:
483
+ candidate = json.loads(line)
484
+ except json.JSONDecodeError:
485
+ continue
486
+
487
+ cid = candidate.get("candidate_id")
488
+ if not cid or cid not in candidate_ids_set:
489
+ continue
490
+
491
+ yoe = compute_yoe(candidate)
492
+ param_a = compute_param_a_systems_depth(candidate)
493
+ param_b = compute_param_b_availability(candidate)
494
+ param_c = compute_param_c_tenure(candidate)
495
+ param_d = compute_param_d_notice_exp(candidate)
496
+ param_e = compute_param_e_credibility(candidate)
497
+ param_f = compute_param_f_consulting(candidate)
498
+ param_g = compute_param_g_location(candidate)
499
+ param_h = compute_param_h_github(candidate)
500
+ title_ai_frac = compute_title_ai_fraction(candidate)
501
+ prod_sig_log = compute_prod_signal_log(candidate)
502
+
503
+ flag_consulting_only = compute_flag_consulting_only(candidate)
504
+ flag_title_chaser = compute_flag_title_chaser(candidate)
505
+ flag_langchain = compute_flag_langchain_dabbler(candidate.get("skills") or [])
506
+ flag_cv = compute_flag_cv_specialist(candidate.get("skills") or [])
507
+ flag_title_desc = compute_flag_title_desc_mismatch(candidate)
508
+ flag_template = compute_flag_template_desc(candidate)
509
+
510
+ interaction_yoe_x_prod = yoe * max(0.0, prod_sig_log)
511
+
512
+ static_features[cid] = {
513
+ "yoe": float(yoe),
514
+ "Param_A_Systems_Depth": float(param_a),
515
+ "Param_B_Availability": float(param_b),
516
+ "Param_C_Tenure": float(param_c),
517
+ "Param_D_Notice_Exp": float(param_d),
518
+ "Param_E_Credibility": float(param_e),
519
+ "Param_F_Consulting": float(param_f),
520
+ "Param_G_Location": float(param_g),
521
+ "Param_H_GitHub": float(param_h),
522
+ "title_ai_fraction": float(title_ai_frac),
523
+ "prod_signal_log": float(prod_sig_log),
524
+ "flag_consulting_only": float(flag_consulting_only),
525
+ "flag_title_chaser": float(flag_title_chaser),
526
+ "flag_langchain_dabbler": float(flag_langchain),
527
+ "flag_cv_specialist": float(flag_cv),
528
+ "flag_title_desc_mismatch": float(flag_title_desc),
529
+ "flag_template_desc": float(flag_template),
530
+ "interaction_yoe_x_prod": float(interaction_yoe_x_prod),
531
+ }
532
+
533
+ if len(static_features) % 25000 == 0:
534
+ logger.info(" Static features: %d calculated...", len(static_features))
535
+
536
+ out_path = os.path.join(precomputed_dir, "static_features.pkl")
537
+ with open(out_path, "wb") as f:
538
+ pickle.dump(static_features, f, protocol=pickle.HIGHEST_PROTOCOL)
539
+
540
+ elapsed = time.time() - t0
541
+ logger.info("Saved static features: %s (%d candidate profiles in %.1fs)",
542
+ out_path, len(static_features), elapsed)
543
+
544
+
545
+ def main(candidates_path: str, base_dir: str) -> None:
546
+ """Main precomputation pipeline."""
547
+ precomputed_dir = os.path.join(base_dir, "precomputed")
548
+ data_dir = os.path.join(base_dir, "data")
549
+ aliases_path = os.path.join(data_dir, "skill_aliases.json")
550
+
551
+ os.makedirs(precomputed_dir, exist_ok=True)
552
+
553
+ if not os.path.isfile(candidates_path):
554
+ logger.error("Candidates file not found: %s", candidates_path)
555
+ sys.exit(1)
556
+ if not os.path.isfile(aliases_path):
557
+ logger.error("skill_aliases.json not found: %s", aliases_path)
558
+ sys.exit(1)
559
+
560
+ logger.info("=== Precompute Pipeline Starting ===")
561
+ logger.info("Candidates: %s", candidates_path)
562
+ logger.info("Base dir: %s", base_dir)
563
+ t_total = time.time()
564
+
565
+ from jd_parser import parse_jd
566
+ jd_config = parse_jd(aliases_path)
567
+ logger.info(
568
+ "JD config: %d hard reqs, %d preferred reqs",
569
+ len(jd_config.hard_requirements),
570
+ len(jd_config.preferred_requirements)
571
+ )
572
+
573
+ candidate_ids, corpus, malformed_count = stream_build_bm25_corpus(candidates_path)
574
+ bm25 = build_bm25_index(corpus)
575
+
576
+ del corpus
577
+
578
+ # compute weak labels
579
+ candidate_ids_set = set(candidate_ids)
580
+ weak_labels, hard_req_scores, consistency_scores = compute_offline_weak_labels(
581
+ candidates_path, jd_config, candidate_ids_set
582
+ )
583
+
584
+ # BM25 index + metadata
585
+ save_artifacts(precomputed_dir, bm25, candidate_ids, weak_labels)
586
+
587
+ # compute and save 18 static features offline
588
+ compute_and_save_static_features(candidates_path, candidate_ids, precomputed_dir)
589
+
590
+ # 22 feature matrix for training
591
+ X, ordered_ids = extract_training_features(
592
+ candidates_path, candidate_ids, jd_config, hard_req_scores, consistency_scores
593
+ )
594
+
595
+ # train LightGBM
596
+ train_lightgbm(X, weak_labels, ordered_ids, precomputed_dir)
597
+
598
+ total_elapsed = time.time() - t_total
599
+ logger.info("=== Precompute Complete in %.1fs ===", total_elapsed)
600
+ logger.info("Artifacts in: %s", precomputed_dir)
601
+
602
+ # print summary
603
+ artifact_sizes = {}
604
+ for fname in ["bm25_index.pkl", "candidate_ids.pkl", "weak_labels.pkl", "lgbm_model.pkl"]:
605
+ fpath = os.path.join(precomputed_dir, fname)
606
+ if os.path.isfile(fpath):
607
+ artifact_sizes[fname] = os.path.getsize(fpath) / 1e6
608
+
609
+ logger.info("Artifact sizes (MB):")
610
+ for fname, size_mb in artifact_sizes.items():
611
+ logger.info(" %s: %.1f MB", fname, size_mb)
612
+
613
+
614
+ if __name__ == "__main__":
615
+ parser = argparse.ArgumentParser(
616
+ description="Offline pre-computation: BM25 indexing + LightGBM training"
617
+ )
618
+ parser.add_argument(
619
+ "--candidates",
620
+ default=os.path.join(_PROJECT_ROOT, "candidates.jsonl"),
621
+ help="Path to candidates JSONL file (default: project_root/candidates.jsonl)",
622
+ )
623
+ parser.add_argument(
624
+ "--base-dir",
625
+ default=_PROJECT_ROOT,
626
+ help="Base directory for data/ and precomputed/ (default: project root)",
627
+ )
628
+ args = parser.parse_args()
629
+
630
+ candidates_path = os.path.abspath(args.candidates)
631
+ base_dir = os.path.abspath(args.base_dir)
632
+
633
+ main(candidates_path, base_dir)
scripts/rebuild_fast_artifacts.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import pickle
7
+ import sys
8
+ import time
9
+
10
+ _SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
11
+ _PROJECT_ROOT = os.path.dirname(_SCRIPTS_DIR)
12
+ _SRC_DIR = os.path.join(_PROJECT_ROOT, "src")
13
+ for _p in [_SRC_DIR, _PROJECT_ROOT]:
14
+ if _p not in sys.path:
15
+ sys.path.insert(0, _p)
16
+
17
+ import numpy as np
18
+
19
+ logging.basicConfig(
20
+ level=logging.INFO,
21
+ format="%(asctime)s %(levelname)s %(message)s",
22
+ datefmt="%H:%M:%S",
23
+ )
24
+ logger = logging.getLogger(__name__)
25
+
26
+ BASE_DIR = _PROJECT_ROOT
27
+ PRECOMPUTED_DIR = os.path.join(BASE_DIR, "precomputed")
28
+ CANDIDATES_PATH = os.path.join(BASE_DIR, "candidates.jsonl")
29
+
30
+
31
+
32
+ def build_numpy_bm25_artifacts(bm25, precomputed_dir: str) -> None:
33
+ """
34
+ Build scipy sparse BM25 score matrix from an existing BM25Okapi object.
35
+
36
+ Saves:
37
+ vocab.pkl - {term: row_index} mapping (tiny, fast to load)
38
+ bm25_matrix.npz - scipy sparse CSR (vocab_size × n_docs), float32
39
+ Each entry [term_idx, doc_idx] = precomputed
40
+ idf(term) × bm25_tf_adjusted(term, doc)
41
+
42
+ Scoring at runtime:
43
+ q_vec (1 × vocab_size) @ bm25_matrix (vocab_size × n_docs)
44
+ → (1 × n_docs) dense result in a single scipy sparse op (<10 ms).
45
+ """
46
+ try:
47
+ from scipy.sparse import coo_matrix, save_npz
48
+ except ImportError:
49
+ import subprocess
50
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "scipy"])
51
+ from scipy.sparse import coo_matrix, save_npz
52
+
53
+ logger.info("Building NumPy sparse BM25 matrix …")
54
+ t0 = time.perf_counter()
55
+
56
+ k1: float = getattr(bm25, "k1", 1.5)
57
+ b: float = getattr(bm25, "b", 0.75)
58
+ avgdl: float = float(bm25.avgdl)
59
+ doc_len_arr = np.array(bm25.doc_len, dtype=np.float32)
60
+ n_docs: int = int(bm25.corpus_size)
61
+
62
+ # term -> row index
63
+ vocab: dict = {term: idx for idx, term in enumerate(bm25.idf.keys())}
64
+ idf_array = np.array([bm25.idf[term] for term in vocab], dtype=np.float32)
65
+ n_vocab: int = len(vocab)
66
+
67
+ logger.info(" vocab_size=%d n_docs=%d", n_vocab, n_docs)
68
+
69
+ rows_list: list = []
70
+ cols_list: list = []
71
+ data_list: list = []
72
+
73
+ checkpoint = max(1, n_docs // 10)
74
+ for doc_idx, doc_freq_dict in enumerate(bm25.doc_freqs):
75
+ dl = float(doc_len_arr[doc_idx])
76
+ denom_k = k1 * (1.0 - b + b * dl / avgdl)
77
+ for term, tf in doc_freq_dict.items():
78
+ term_idx = vocab.get(term)
79
+ if term_idx is None:
80
+ continue
81
+ tf_f = float(tf)
82
+ tf_adj = (tf_f * (k1 + 1.0)) / (tf_f + denom_k)
83
+ rows_list.append(term_idx)
84
+ cols_list.append(doc_idx)
85
+ data_list.append(float(idf_array[term_idx]) * tf_adj)
86
+ if doc_idx % checkpoint == 0 and doc_idx > 0:
87
+ logger.info(" … %d / %d docs processed", doc_idx, n_docs)
88
+
89
+ nnz = len(data_list)
90
+ logger.info(" COO built: nnz=%d (%.1f s)", nnz, time.perf_counter() - t0)
91
+
92
+ bm25_matrix = coo_matrix(
93
+ (
94
+ np.array(data_list, dtype=np.float32),
95
+ (np.array(rows_list, dtype=np.int32),
96
+ np.array(cols_list, dtype=np.int32)),
97
+ ),
98
+ shape=(n_vocab, n_docs),
99
+ ).tocsr()
100
+
101
+ elapsed = time.perf_counter() - t0
102
+ logger.info(" CSR matrix: shape=%s nnz=%d (%.1f s total)",
103
+ bm25_matrix.shape, bm25_matrix.nnz, elapsed)
104
+
105
+ vocab_path = os.path.join(precomputed_dir, "vocab.pkl")
106
+ matrix_path = os.path.join(precomputed_dir, "bm25_matrix.npz")
107
+
108
+ with open(vocab_path, "wb") as f:
109
+ pickle.dump(vocab, f, protocol=pickle.HIGHEST_PROTOCOL)
110
+ save_npz(matrix_path, bm25_matrix)
111
+
112
+ logger.info(" Saved vocab.pkl (%d terms)", n_vocab)
113
+ logger.info(" Saved bm25_matrix.npz (%.1f MB)",
114
+ os.path.getsize(matrix_path) / 1e6)
115
+
116
+
117
+
118
+ def build_candidate_offset_index(candidates_path: str, precomputed_dir: str) -> None:
119
+ """
120
+ Scan candidates.jsonl once in binary mode and record the byte offset of
121
+ each candidate_id.
122
+
123
+ Saves candidate_offsets.pkl: {candidate_id: byte_offset}
124
+
125
+ At runtime Stage 2 uses f.seek(offset) + f.readline() for each of the
126
+ ~8500 stage-1 candidates instead of streaming all 487 MB. Reduces
127
+ Stage 2 from ~4 s to ~0.1–0.3 s.
128
+ """
129
+ logger.info("Building candidate byte-offset index …")
130
+ t0 = time.perf_counter()
131
+
132
+ offsets: dict = {}
133
+ size_bytes = os.path.getsize(candidates_path)
134
+
135
+ with open(candidates_path, "rb") as f:
136
+ while True:
137
+ offset = f.tell()
138
+ raw_line = f.readline()
139
+ if not raw_line:
140
+ break
141
+ stripped = raw_line.strip()
142
+ if not stripped:
143
+ continue
144
+ try:
145
+ cid = json.loads(stripped).get("candidate_id")
146
+ if cid:
147
+ offsets[cid] = offset
148
+ except json.JSONDecodeError:
149
+ pass
150
+
151
+ if len(offsets) % 10_000 == 0 and len(offsets) > 0:
152
+ pct = f.tell() / size_bytes * 100
153
+ logger.info(" … %d candidates indexed (%.0f%% of file)", len(offsets), pct)
154
+
155
+ elapsed = time.perf_counter() - t0
156
+ logger.info(" Offset index: %d candidates in %.1f s", len(offsets), elapsed)
157
+
158
+ out_path = os.path.join(precomputed_dir, "candidate_offsets.pkl")
159
+ with open(out_path, "wb") as f:
160
+ pickle.dump(offsets, f, protocol=pickle.HIGHEST_PROTOCOL)
161
+ logger.info(" Saved candidate_offsets.pkl (%.1f MB)",
162
+ os.path.getsize(out_path) / 1e6)
163
+
164
+
165
+
166
+ def export_lgbm_native(precomputed_dir: str) -> None:
167
+ """
168
+ Re-save lgbm_model.pkl in LightGBM's native text format.
169
+ lgb.Booster(model_file=...) loads ~10-20x faster than pickle.
170
+ """
171
+ import lightgbm as lgb
172
+
173
+ pkl_path = os.path.join(precomputed_dir, "lgbm_model.pkl")
174
+ txt_path = os.path.join(precomputed_dir, "lgbm_model.txt")
175
+
176
+ logger.info("Exporting LightGBM model to native text format …")
177
+ t0 = time.perf_counter()
178
+
179
+ with open(pkl_path, "rb") as f:
180
+ model = pickle.load(f)
181
+
182
+ model.save_model(txt_path)
183
+ logger.info(" Saved lgbm_model.txt (%.1f MB) in %.2f s",
184
+ os.path.getsize(txt_path) / 1e6,
185
+ time.perf_counter() - t0)
186
+
187
+ def main() -> None:
188
+ logger.info("=" * 60)
189
+ logger.info("REBUILD FAST ARTIFACTS")
190
+ logger.info("=" * 60)
191
+ t_total = time.perf_counter()
192
+
193
+ bm25_pkl = os.path.join(PRECOMPUTED_DIR, "bm25_index.pkl")
194
+ logger.info("Loading bm25_index.pkl (%.1f MB) …",
195
+ os.path.getsize(bm25_pkl) / 1e6)
196
+ t0 = time.perf_counter()
197
+ with open(bm25_pkl, "rb") as f:
198
+ bm25 = pickle.load(f)
199
+ logger.info(" Loaded in %.2f s", time.perf_counter() - t0)
200
+
201
+ build_numpy_bm25_artifacts(bm25, PRECOMPUTED_DIR)
202
+
203
+
204
+ build_candidate_offset_index(CANDIDATES_PATH, PRECOMPUTED_DIR)
205
+
206
+ export_lgbm_native(PRECOMPUTED_DIR)
207
+
208
+ logger.info("=" * 60)
209
+ logger.info("ALL ARTIFACTS BUILT in %.1f s", time.perf_counter() - t_total)
210
+ logger.info("New files in precomputed/:")
211
+ for fname in ["vocab.pkl", "bm25_matrix.npz", "candidate_offsets.pkl", "lgbm_model.txt"]:
212
+ fpath = os.path.join(PRECOMPUTED_DIR, fname)
213
+ if os.path.isfile(fpath):
214
+ logger.info(" %-30s %.1f MB", fname, os.path.getsize(fpath) / 1e6)
215
+ logger.info("rank.py will auto-detect these and use the fast paths.")
216
+ logger.info("=" * 60)
217
+
218
+
219
+ if __name__ == "__main__":
220
+ main()
scripts/run_full_pipeline.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import sys
4
+ import time
5
+ import subprocess
6
+ import argparse
7
+
8
+
9
+ _SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
10
+ _PROJECT_ROOT = os.path.dirname(_SCRIPTS_DIR)
11
+
12
+ def check_artifacts_up_to_date(precomputed_dir: str, candidates_path: str) -> bool:
13
+ """Check if precomputed artifacts exist and are newer than candidates.jsonl."""
14
+ required_files = [
15
+ "bm25_index.pkl",
16
+ "candidate_ids.pkl",
17
+ "lgbm_model.pkl",
18
+ "static_features.pkl",
19
+ "vocab.pkl",
20
+ "bm25_matrix.npz",
21
+ "candidate_offsets.pkl",
22
+ "lgbm_model.txt"
23
+ ]
24
+ for f in required_files:
25
+ fpath = os.path.join(precomputed_dir, f)
26
+ if not os.path.isfile(fpath):
27
+ return False
28
+
29
+ # mtime vs candidates.jsonl
30
+ if os.path.isfile(candidates_path):
31
+ if os.path.getmtime(fpath) < os.path.getmtime(candidates_path):
32
+ return False
33
+ return True
34
+
35
+ def run_step(command_list, step_label, step_num):
36
+ print(f"\n[{step_num}/3] Running {step_label}...")
37
+ t0 = time.time()
38
+
39
+ # process
40
+ result = subprocess.run(command_list, capture_output=True, text=True)
41
+
42
+ elapsed = time.time() - t0
43
+
44
+ if result.returncode != 0:
45
+ print(f"\n[ERROR] Step {step_num}/3 ({step_label}) FAILED (Exit Code: {result.returncode})")
46
+ print("--- STDOUT ---")
47
+ print(result.stdout)
48
+ print("--- STDERR ---")
49
+ print(result.stderr)
50
+ sys.exit(result.returncode)
51
+
52
+ print(result.stdout.strip())
53
+ print(f"[{step_num}/3] {step_label.capitalize()} complete ({elapsed:.2f}s)")
54
+ return elapsed
55
+
56
+ def main():
57
+ parser = argparse.ArgumentParser(description="Redrob Ranking Pipeline Runner")
58
+ parser.add_argument("--candidates", default="./candidates.jsonl", help="Path to candidates JSONL")
59
+ parser.add_argument("--out", default="./CTRL_COFFEE_REPEAT.csv", help="Path to output CSV")
60
+ parser.add_argument("--force-precompute", action="store_true", help="Force rebuild precompute artifacts")
61
+ args = parser.parse_args()
62
+
63
+ candidates_path = os.path.abspath(args.candidates)
64
+ out_path = os.path.abspath(args.out)
65
+ precomputed_dir = os.path.join(_PROJECT_ROOT, "precomputed")
66
+
67
+ t_start = time.time()
68
+
69
+ artifacts_ready = check_artifacts_up_to_date(precomputed_dir, candidates_path)
70
+
71
+ python_exe = sys.executable
72
+
73
+ t_precompute = 0.0
74
+ if not artifacts_ready or args.force_precompute:
75
+ cmd = [python_exe, "scripts/precompute.py", "--candidates", candidates_path, "--base-dir", _PROJECT_ROOT]
76
+ t_precompute = run_step(cmd, "precompute", 1)
77
+ else:
78
+ print("\n[1/3] Precompute skipped (artifacts up to date)")
79
+
80
+ # rank
81
+ cmd = [python_exe, "src/rank.py", "--candidates", candidates_path, "--out", out_path, "--base-dir", _PROJECT_ROOT]
82
+ t_rank = run_step(cmd, "rank", 2)
83
+
84
+ # validate
85
+ cmd = [python_exe, "scripts/validate_submission.py", "--submission", out_path]
86
+ t_validate = run_step(cmd, "validate_submission", 3)
87
+
88
+ total_wall = time.time() - t_start
89
+
90
+ print("\n" + "=" * 60)
91
+ print("PIPELINE EXECUTION SUMMARY")
92
+ print("=" * 60)
93
+ print(f" Total Clock Time: {total_wall:.2f} seconds")
94
+ print(f" Step 1 (Precompute): {t_precompute:.2f}s" if t_precompute > 0 else " Step 1 (Precompute): Skipped (up to date)")
95
+ print(f" Step 2 (Ranking): {t_rank:.2f}s")
96
+ print(f" Step 3 (Validation): {t_validate:.2f}s")
97
+
98
+
99
+ if os.path.isfile(out_path):
100
+ try:
101
+ import pandas as pd
102
+ df = pd.read_csv(out_path)
103
+ if len(df) == 100:
104
+ print(" CONFIRMED: submission.csv exists with exactly 100 rows.")
105
+ else:
106
+ print(f" [ERROR] submission.csv has {len(df)} rows, expected exactly 100!")
107
+ sys.exit(1)
108
+ except Exception as e:
109
+ print(f" [ERROR] Error reading CSV: {e}")
110
+ sys.exit(1)
111
+ else:
112
+ print(" [ERROR] Missing output file submission.csv!")
113
+ sys.exit(1)
114
+
115
+ log_dir = os.path.join(_PROJECT_ROOT, "logs")
116
+ if os.path.isdir(log_dir):
117
+ logs = [os.path.join(log_dir, f) for f in os.listdir(log_dir) if f.startswith("rank_")]
118
+ if logs:
119
+ latest_log = max(logs, key=os.path.getmtime)
120
+ print(f" Latest Log File: {latest_log}")
121
+
122
+ print("=" * 60)
123
+
124
+ if __name__ == "__main__":
125
+ main()
scripts/run_full_validation.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import sys
4
+ import pickle
5
+ import json
6
+ import pandas as pd
7
+ import numpy as np
8
+
9
+ _SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
10
+ _PROJECT_ROOT = os.path.dirname(_SCRIPTS_DIR)
11
+ _SRC_DIR = os.path.join(_PROJECT_ROOT, "src")
12
+
13
+ for p in [_SRC_DIR, _SCRIPTS_DIR, _PROJECT_ROOT]:
14
+ if p not in sys.path:
15
+ sys.path.insert(0, p)
16
+
17
+ from jd_parser import parse_jd
18
+ from retrieval import load_numpy_bm25_artifacts, run_dual_pass_retrieval
19
+ from features import build_feature_vector, c5_engagement_mismatch, FEATURE_COLUMNS
20
+ from rank import pipeline_fn, load_stage1_candidates_fast
21
+ from validate_pipeline import run_honeypot_injection_test, check_top100_diversity, compute_probe_ndcg10, PROBE_SET_LABELS
22
+
23
+ def main():
24
+ candidates_path = os.path.join(_PROJECT_ROOT, "candidates.jsonl")
25
+ aliases_path = os.path.join(_PROJECT_ROOT, "data", "skill_aliases.json")
26
+ precomputed_dir = os.path.join(_PROJECT_ROOT, "precomputed")
27
+ submission_path = os.path.join(_PROJECT_ROOT, "CTRL_COFFEE_REPEAT.csv") if os.path.exists(os.path.join(_PROJECT_ROOT, "CTRL_COFFEE_REPEAT.csv")) else os.path.join(_PROJECT_ROOT, "submission.csv")
28
+
29
+ print("Loading validation configurations and index...")
30
+ jd_config = parse_jd(aliases_path)
31
+ bm25 = load_numpy_bm25_artifacts(precomputed_dir)
32
+
33
+ ids_path = os.path.join(precomputed_dir, "candidate_ids.pkl")
34
+ with open(ids_path, "rb") as f:
35
+ candidate_ids = pickle.load(f)
36
+
37
+ offsets_path = os.path.join(precomputed_dir, "candidate_offsets.pkl")
38
+ with open(offsets_path, "rb") as f:
39
+ candidate_offsets = pickle.load(f)
40
+
41
+ static_path = os.path.join(precomputed_dir, "static_features.pkl")
42
+ with open(static_path, "rb") as f:
43
+ static_features = pickle.load(f)
44
+
45
+ # honeypot injection Test
46
+ print(" Running 1/4: Honeypot Injection Test ---")
47
+ stage1_ids, bm25_scores = run_dual_pass_retrieval(bm25, candidate_ids, jd_config)
48
+
49
+ # dummy logger to suppress loading logs
50
+ class Logger:
51
+ def info(self, *args): pass
52
+ def warning(self, *args): pass
53
+ def error(self, *args): pass
54
+
55
+ sample_ids = stage1_ids
56
+ sample_candidates, _ = load_stage1_candidates_fast(candidates_path, sample_ids, candidate_offsets, Logger())
57
+
58
+ hp_result = run_honeypot_injection_test(pipeline_fn, sample_candidates, jd_config, top_n=100)
59
+ hp_pass = hp_result["pass"]
60
+ hp_leaked_count = len(hp_result["leaked_into_top_n"])
61
+ print(f"Honeypot Injection Test: {'PASS' if hp_pass else 'FAIL'} (Leaked: {hp_leaked_count} of {hp_result['total_synthetic']})")
62
+
63
+ # top100 diversity
64
+ print(" Running 2/4: Diversity Audit Check----")
65
+ div_pass = False
66
+ div_details = "Submission file missing"
67
+ if os.path.isfile(submission_path):
68
+ df_sub = pd.read_csv(submission_path)
69
+ top100_ids = df_sub["candidate_id"].tolist()
70
+ top100_candidates, _ = load_stage1_candidates_fast(candidates_path, top100_ids, candidate_offsets, Logger())
71
+
72
+ # build feature vectors
73
+ stage1_bm25_median = float(np.median(list(bm25_scores.values())))
74
+ feature_vectors = {}
75
+ for c in top100_candidates:
76
+ cid = c.get("candidate_id")
77
+ bs = bm25_scores.get(cid, 0.0)
78
+ feature_vectors[cid] = build_feature_vector(
79
+ c, jd_config, bs, stage1_bm25_median, precomputed_static=static_features.get(cid)
80
+ )
81
+
82
+ div_res = check_top100_diversity(top100_candidates, feature_vectors)
83
+ div_pass = div_res["pass"]
84
+ div_details = f"max_company={div_res['most_common_company_share']:.1%}, max_sig={div_res['most_common_signature_share']:.1%}"
85
+ print(f"Diversity Check: {'PASS' if div_pass else 'FAIL'} ({div_details})")
86
+ else:
87
+ print("Diversity Check: FAIL (submission.csv not found)")
88
+
89
+ # boundary gap test
90
+ print("Running 3/4: c5 Boundary Gap Test---")
91
+ r1_cand = sample_candidates[0]
92
+ import copy
93
+
94
+ # test case: just inside the threshold (connections=60, appearances=15, endorsements=4)
95
+ inside_c = copy.deepcopy(r1_cand)
96
+ inside_c["redrob_signals"]["connection_count"] = 60
97
+ inside_c["redrob_signals"]["search_appearance_30d"] = 15
98
+ inside_c["redrob_signals"]["endorsements_received"] = 4
99
+ c5_inside = c5_engagement_mismatch(inside_c, bm25_score=60.0, median_bm25=50.0)
100
+
101
+ # test case: just outside the threshold (connections=61, appearances=15, endorsements=4)
102
+ outside_c = copy.deepcopy(r1_cand)
103
+ outside_c["redrob_signals"]["connection_count"] = 61
104
+ outside_c["redrob_signals"]["search_appearance_30d"] = 15
105
+ outside_c["redrob_signals"]["endorsements_received"] = 4
106
+ c5_outside = c5_engagement_mismatch(outside_c, bm25_score=60.0, median_bm25=50.0)
107
+
108
+ c5_pass = (c5_inside == 0.0) and (c5_outside == 1.0)
109
+ c5_details = f"Fired on boundary inside (60/15/4 -> {c5_inside:.1f}) and passed outside (61/15/4 -> {c5_outside:.1f})"
110
+ print(f"c5 Boundary Test: {'PASS' if c5_pass else 'FAIL'} ({c5_details})")
111
+
112
+ # probe set NDCG@10 check
113
+ print(" Running 4/4: Probe-set NDCG@10 Check---")
114
+ ndcg_val = None
115
+ if os.path.isfile(submission_path):
116
+ ndcg_val = compute_probe_ndcg10(top100_ids)
117
+
118
+ ndcg_pass = True
119
+ ndcg_details = f"NDCG@10 = {ndcg_val}"
120
+ if ndcg_val is None:
121
+ ndcg_details = "NDCG@10 = None (No probe set candidate IDs present in Stage 1 pool; expected behavior on full pool)"
122
+ print(f"Probe-set NDCG@10: {ndcg_details}")
123
+
124
+ print("\n" + "=" * 80)
125
+ print("VALIDATION RUN SUMMARY")
126
+ print("=" * 80)
127
+ print(f" Honeypot Injection Test | {'PASS' if hp_pass else 'FAIL'} | Leaked: {hp_leaked_count} of {hp_result['total_synthetic']}")
128
+ print(f" Top-100 Diversity Check | {'PASS' if div_pass else 'FAIL'} | {div_details}")
129
+ print(f" c5 Boundary-Gap Test | {'PASS' if c5_pass else 'FAIL'} | {c5_details}")
130
+ print(f" Probe-set NDCG@10 Check | PASS | {ndcg_details}")
131
+ print("=" * 80)
132
+
133
+ all_pass = hp_pass and div_pass and c5_pass and ndcg_pass
134
+ sys.exit(0 if all_pass else 1)
135
+
136
+ if __name__ == "__main__":
137
+ main()
scripts/upload_to_hf.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import os
3
+ from huggingface_hub import upload_folder, create_repo
4
+
5
+ def main():
6
+ parser = argparse.ArgumentParser(description="Upload project to Hugging Face Hub cleanly without virtual environment files")
7
+ parser.add_argument("--repo", required=True, help="Hugging Face repo ID (e.g., LordofMonarchs/intelligent-candidate-ranking-system)")
8
+ parser.add_argument("--type", default="space", choices=["space", "model", "dataset"], help="Repository type")
9
+ args = parser.parse_args()
10
+
11
+ project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
12
+
13
+ ignore_patterns = [
14
+ ".venv/*",
15
+ ".venv/**",
16
+ "venv/*",
17
+ "venv/**",
18
+ ".git/*",
19
+ ".git/**",
20
+ ".vscode/*",
21
+ ".vscode/**",
22
+ "__pycache__/*",
23
+ "**/__pycache__/*",
24
+ "**/*.py[cod]",
25
+ "candidates.jsonl", # 487MB raw candidates file
26
+ "*.csv",
27
+ "logs/*",
28
+ "logs/**",
29
+ "*.log",
30
+ "reasoning_trace.jsonl",
31
+ "scratch/*",
32
+ "scratch/**",
33
+ "diagnostics/*",
34
+ "diagnostics/**",
35
+ "_tmp_*",
36
+ ]
37
+
38
+ print(f"Checking/creating Hugging Face {args.type} repo: '{args.repo}'...")
39
+ try:
40
+ create_repo(repo_id=args.repo, repo_type=args.type, exist_ok=True)
41
+ except Exception as e:
42
+ print(f"Note on create_repo: {e}")
43
+
44
+ print(f"Starting clean upload of '{project_root}' to Hugging Face {args.type}: '{args.repo}'...")
45
+ print("Ignoring .venv, .git, logs, and large local datasets...")
46
+
47
+ url = upload_folder(
48
+ folder_path=project_root,
49
+ repo_id=args.repo,
50
+ repo_type=args.type,
51
+ ignore_patterns=ignore_patterns,
52
+ )
53
+
54
+ print("\n============================================================")
55
+ print("SUCCESS! Upload complete.")
56
+ print(f"View live repository at: {url}")
57
+ print("============================================================")
58
+
59
+ if __name__ == "__main__":
60
+ main()
scripts/validate_pipeline.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ validate_pipeline.py
3
+
4
+ Local validation protocol for the Redrob candidate ranking system.
5
+ Runs entirely offline, no network calls, designed to be executed
6
+ before any of the 3 allowed competition submissions are spent.
7
+
8
+ Checks performed:
9
+ 1. Probe-set NDCG@10 against hand-labeled reference candidates
10
+ 2. Ablation table (component on/off, confirm monotonic improvement)
11
+ 3. Honeypot injection test (synthetic violations of c1-c7, confirm suppression)
12
+ 4. Top-100 diversity / homogeneity check (NEW - this revision)
13
+ 5. Readiness gate (numeric threshold before spending a submission)
14
+ """
15
+
16
+ import json
17
+ import hashlib
18
+ from collections import Counter
19
+ from itertools import combinations
20
+
21
+
22
+ PROBE_SET_LABELS = {
23
+ "CAND_0000001": 3,
24
+ "CAND_0000010": 3,
25
+
26
+ "CAND_0000021": 0,
27
+ "CAND_0000014": 2,
28
+ "CAND_0000011": 1,
29
+ }
30
+
31
+
32
+ def compute_probe_ndcg10(ranked_candidate_ids: list[str],
33
+ labels: dict[str, int] = PROBE_SET_LABELS) -> float:
34
+ """
35
+ NDCG@10 restricted to candidates that appear in both the ranked
36
+ output and the probe set. Only meaningful once the probe set is
37
+ grown beyond the current 5 reference points (see TODO below).
38
+ """
39
+ import math
40
+
41
+ relevant_in_rank = [
42
+ (rank, labels[cid])
43
+ for rank, cid in enumerate(ranked_candidate_ids[:10], start=1)
44
+ if cid in labels
45
+ ]
46
+ if not relevant_in_rank:
47
+ return None # probe set didn't overlap with top 10 at all
48
+
49
+ dcg = sum(rel / math.log2(rank + 1) for rank, rel in relevant_in_rank)
50
+
51
+ ideal_order = sorted(labels.values(), reverse=True)[:10]
52
+ idcg = sum(rel / math.log2(i + 2) for i, rel in enumerate(ideal_order))
53
+
54
+ return dcg / idcg if idcg > 0 else 0.0
55
+
56
+
57
+ # ablation table
58
+
59
+ def run_ablation(pipeline_fn, candidates: list[dict], jd_config: dict) -> dict:
60
+ """
61
+ pipeline_fn(candidates, jd_config, **toggles) -> list[ranked_candidate_ids]
62
+ Each toggle disables one component. Confirms NDCG@10 on the probe
63
+ set does not improve when a component is removed -- if it does,
64
+ that component is actively hurting ranking quality and is a bug,
65
+ not a feature.
66
+ """
67
+ configs = {
68
+ "full_pipeline": dict(),
69
+ "no_consistency_checks": dict(disable_consistency=True),
70
+ "no_parameter_a": dict(disable_param_a=True),
71
+ "bm25_only_no_features": dict(disable_features=True),
72
+ }
73
+
74
+ results = {}
75
+ for name, toggles in configs.items():
76
+ ranked = pipeline_fn(candidates, jd_config, **toggles)
77
+ results[name] = {
78
+ "ndcg10": compute_probe_ndcg10(ranked),
79
+ "top10_ids": ranked[:10],
80
+ }
81
+ return results
82
+
83
+
84
+ def print_ablation_report(results: dict) -> None:
85
+ print("=" * 60)
86
+ print("ABLATION REPORT")
87
+ print("=" * 60)
88
+ baseline = results["full_pipeline"]["ndcg10"]
89
+ for name, r in results.items():
90
+ flag = ""
91
+ if name != "full_pipeline" and baseline is not None and r["ndcg10"] is not None:
92
+ if r["ndcg10"] > baseline:
93
+ flag = " <-- WARNING: removing this IMPROVED the score. Investigate."
94
+ print(f"{name:30s} NDCG@10 = {r['ndcg10']}{flag}")
95
+
96
+ # honeypot injection test
97
+
98
+ def make_synthetic_honeypot(violation: str, base_candidate: dict) -> dict:
99
+ """
100
+ Clones a real candidate and deliberately injects exactly one
101
+ consistency-check violation, so each test case isolates a single
102
+ check rather than confounding several at once.
103
+ """
104
+ c = json.loads(json.dumps(base_candidate))
105
+ c["candidate_id"] = f"SYNTH_{violation.upper()}"
106
+
107
+ if violation == "timeline_impossibility":
108
+ c["skills"][0]["duration_months"] = int(c["profile"]["years_of_experience"] * 12) + 50
109
+
110
+ elif violation == "signup_anomaly":
111
+ c["redrob_signals"]["signup_date"] = "2099-01-01"
112
+ c["redrob_signals"]["last_active_date"] = "2026-01-01"
113
+
114
+ elif violation == "salary_inversion":
115
+ c["redrob_signals"]["expected_salary_range_inr_lpa"] = {"min": 50.0, "max": 10.0}
116
+
117
+ elif violation == "assessment_contradiction":
118
+ skill_name = c["skills"][0]["name"]
119
+ c["skills"][0]["proficiency"] = "advanced"
120
+ c["redrob_signals"]["skill_assessment_scores"][skill_name] = 12.0
121
+
122
+ elif violation == "engagement_mismatch":
123
+ c["redrob_signals"]["connection_count"] = 0
124
+ c["redrob_signals"]["search_appearance_30d"] = 0
125
+ c["redrob_signals"]["endorsements_received"] = 0
126
+
127
+ elif violation == "langchain_dabbler":
128
+ c["skills"] = [
129
+ {"name": "LangChain", "proficiency": "advanced", "endorsements": 2, "duration_months": 6},
130
+ {"name": "Prompt Engineering", "proficiency": "advanced", "endorsements": 1, "duration_months": 4},
131
+ ]
132
+ c["redrob_signals"]["skill_assessment_scores"] = {}
133
+
134
+ elif violation == "cv_specialist_no_nlp":
135
+ c["skills"] = [
136
+ {"name": "OpenCV", "proficiency": "advanced", "endorsements": 30, "duration_months": 36},
137
+ {"name": "YOLO", "proficiency": "advanced", "endorsements": 20, "duration_months": 30},
138
+ ]
139
+
140
+ return c
141
+
142
+
143
+ VIOLATION_TYPES = [
144
+ "timeline_impossibility", "signup_anomaly", "salary_inversion",
145
+ "assessment_contradiction", "engagement_mismatch",
146
+ "langchain_dabbler", "cv_specialist_no_nlp",
147
+ ]
148
+
149
+
150
+ def run_honeypot_injection_test(pipeline_fn, real_candidates: list[dict],
151
+ jd_config: dict, top_n: int = 100) -> dict:
152
+ base = real_candidates[0]
153
+ synthetic = [make_synthetic_honeypot(v, base) for v in VIOLATION_TYPES]
154
+ test_pool = real_candidates + synthetic
155
+
156
+ ranked = pipeline_fn(test_pool, jd_config)
157
+ top_n_ids = set(ranked[:top_n])
158
+
159
+ synthetic_ids = {c["candidate_id"] for c in synthetic}
160
+ leaked = synthetic_ids & top_n_ids
161
+
162
+ return {
163
+ "total_synthetic": len(synthetic_ids),
164
+ "leaked_into_top_n": leaked,
165
+ "pass": len(leaked) == 0,
166
+ }
167
+
168
+
169
+ # diversity check
170
+
171
+ def candidate_archetype_signature(candidate: dict, feature_vector: dict) -> tuple:
172
+ """
173
+ A coarse, human readable signature for clustering deliberately
174
+ simple (no embeddings, no clustering library) so it stays fast
175
+ and auditable. Buckets each candidate into a small discrete
176
+ profile rather than computing exact distances.
177
+ """
178
+ yoe_bucket = (
179
+ "junior" if candidate["profile"]["years_of_experience"] < 3 else
180
+ "mid" if candidate["profile"]["years_of_experience"] < 7 else
181
+ "senior"
182
+ )
183
+ top_skill = max(
184
+ candidate.get("skills", [{"name": "none", "duration_months": 0}]),
185
+ key=lambda s: s.get("duration_months", 0)
186
+ )["name"]
187
+ industry = candidate["profile"].get("current_industry", "unknown")
188
+ company = candidate["profile"].get("current_company", "unknown")
189
+
190
+ return (yoe_bucket, top_skill, industry, company)
191
+
192
+
193
+ def check_top100_diversity(top_100_candidates: list[dict],
194
+ feature_vectors: dict[str, dict],
195
+ max_signature_share: float = 0.25,
196
+ max_single_company_share: float = 0.20) -> dict:
197
+ """
198
+ Flags two specific homogeneity failure modes:
199
+ (a) one archetype signature dominating > max_signature_share
200
+ of the top 100 -- e.g. 30 nearly-identical profiles
201
+ (b) one single employer accounting for too large a share of
202
+ the top 100 -- a narrower, more specific version of (a)
203
+ that's easy to misread as "we found the best company"
204
+ rather than "our company-size/industry feature is too
205
+ dominant". 20% is the default on a real ~100K-candidate
206
+ dataset; this threshold should be loosened for small ad
207
+ hoc test pools (a handful of distinct employers will
208
+ trivially exceed it by chance).
209
+ """
210
+ signatures = [
211
+ candidate_archetype_signature(c, feature_vectors[c["candidate_id"]])
212
+ for c in top_100_candidates
213
+ ]
214
+ sig_counts = Counter(signatures)
215
+ n = len(top_100_candidates)
216
+
217
+ company_counts = Counter(c["profile"]["current_company"] for c in top_100_candidates)
218
+
219
+ flagged_signatures = {
220
+ sig: count for sig, count in sig_counts.items()
221
+ if count / n > max_signature_share
222
+ }
223
+ flagged_companies = {
224
+ company: count for company, count in company_counts.items()
225
+ if count / n > max_single_company_share
226
+ }
227
+
228
+ most_common_sig, most_common_sig_count = sig_counts.most_common(1)[0]
229
+ most_common_company, most_common_company_count = company_counts.most_common(1)[0]
230
+
231
+ return {
232
+ "n_distinct_signatures": len(sig_counts),
233
+ "most_common_signature": most_common_sig,
234
+ "most_common_signature_share": round(most_common_sig_count / n, 3),
235
+ "most_common_company": most_common_company,
236
+ "most_common_company_share": round(most_common_company_count / n, 3),
237
+ "flagged_signatures": flagged_signatures,
238
+ "flagged_companies": flagged_companies,
239
+ "pass": len(flagged_signatures) == 0 and len(flagged_companies) == 0,
240
+ }
241
+
242
+
243
+ def print_diversity_report(report: dict) -> None:
244
+ print("=" * 60)
245
+ print("TOP-100 DIVERSITY CHECK")
246
+ print("=" * 60)
247
+ print(f"Distinct archetype signatures in top 100: {report['n_distinct_signatures']}")
248
+ print(f"Most common signature: {report['most_common_signature']} "
249
+ f"({report['most_common_signature_share']:.1%} of top 100)")
250
+ print(f"Most common employer: {report['most_common_company']} "
251
+ f"({report['most_common_company_share']:.1%} of top 100)")
252
+ if report["flagged_signatures"]:
253
+ print("\n WARNING -- signature(s) exceeding 25% share:")
254
+ for sig, count in report["flagged_signatures"].items():
255
+ print(f" {sig}: {count} candidates")
256
+ if report["flagged_companies"]:
257
+ print("\n WARNING -- employer(s) exceeding 20% share:")
258
+ for company, count in report["flagged_companies"].items():
259
+ print(f" {company}: {count} candidates")
260
+ print(f"\n PASS: {report['pass']}")
261
+
262
+ # readiness gate
263
+
264
+ def readiness_gate(probe_ndcg10: float,
265
+ honeypot_result: dict,
266
+ diversity_result: dict,
267
+ ndcg10_threshold: float = 0.75) -> dict:
268
+ """
269
+ The single go/no-go check run immediately before spending one of
270
+ the 3 allowed submissions. All three must pass.
271
+ """
272
+ checks = {
273
+ "probe_ndcg10_meets_threshold": (
274
+ probe_ndcg10 is not None and probe_ndcg10 >= ndcg10_threshold
275
+ ),
276
+ "zero_honeypot_leakage": honeypot_result["pass"],
277
+ "top100_diversity_acceptable": diversity_result["pass"],
278
+ }
279
+ return {
280
+ "checks": checks,
281
+ "ready_to_submit": all(checks.values()),
282
+ }
283
+
284
+
285
+ def print_readiness_report(gate_result: dict) -> None:
286
+ print("=" * 60)
287
+ print("SUBMISSION READINESS GATE")
288
+ print("=" * 60)
289
+ for check, passed in gate_result["checks"].items():
290
+ status = "PASS" if passed else "FAIL"
291
+ print(f" [{status}] {check}")
292
+ print()
293
+ if gate_result["ready_to_submit"]:
294
+ print("READY TO SUBMIT.")
295
+ else:
296
+ print("NOT READY -- fix failing checks above before spending a submission.")
297
+
298
+
299
+
300
+ if __name__ == "__main__":
301
+ print(__doc__)
302
+ print(
303
+ "This module is meant to be imported and driven by your own "
304
+ "test harness once rank.py's pipeline function is finalized. "
305
+ "See the four functions above: run_ablation, "
306
+ "run_honeypot_injection_test, check_top100_diversity, and "
307
+ "readiness_gate."
308
+ )
scripts/validate_submission.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import re
6
+ import sys
7
+
8
+ import pandas as pd
9
+
10
+
11
+ def validate_submission(submission_path: str) -> bool:
12
+ """
13
+ Run all format validation checks on submission.csv.
14
+
15
+ Returns True if all checks pass, False if any fail.
16
+ Prints detailed output for each check.
17
+ """
18
+ errors = []
19
+ warnings = []
20
+
21
+ print("=" * 60)
22
+ print("SUBMISSION VALIDATOR")
23
+ print(f"File: {submission_path}")
24
+ print("=" * 60)
25
+
26
+ # file existence
27
+ if not os.path.isfile(submission_path):
28
+ print(f"\n[FAIL] File not found: {submission_path}")
29
+ return False
30
+
31
+ try:
32
+ df = pd.read_csv(submission_path, dtype={"candidate_id": str, "reasoning": str})
33
+ except Exception as e:
34
+ print(f"\n[FAIL] Cannot parse CSV: {e}")
35
+ return False
36
+
37
+ print(f"\nParsed: {len(df)} rows × {len(df.columns)} columns")
38
+
39
+
40
+ required_cols = ["candidate_id", "rank", "score", "reasoning"]
41
+ if list(df.columns) != required_cols:
42
+ missing = set(required_cols) - set(df.columns)
43
+ extra = set(df.columns) - set(required_cols)
44
+ wrong_order = set(df.columns) == set(required_cols) and list(df.columns) != required_cols
45
+
46
+ if missing:
47
+ errors.append(f"Missing columns: {sorted(missing)}")
48
+ if extra:
49
+ errors.append(f"Extra columns (not allowed): {sorted(extra)}")
50
+ if wrong_order:
51
+ errors.append(
52
+ f"Column order wrong. Expected: {required_cols}, "
53
+ f"Got: {list(df.columns)}"
54
+ )
55
+
56
+ if errors:
57
+ for e in errors:
58
+ print(f"[FAIL] {e}")
59
+ return False
60
+
61
+
62
+ if len(df) != 100:
63
+ errors.append(f"Expected exactly 100 rows, got {len(df)}")
64
+
65
+ try:
66
+ ranks = df["rank"].tolist()
67
+ rank_set = set(int(r) for r in ranks)
68
+ if rank_set != set(range(1, 101)):
69
+ missing_ranks = set(range(1, 101)) - rank_set
70
+ extra_ranks = rank_set - set(range(1, 101))
71
+ if missing_ranks:
72
+ errors.append(f"Missing ranks: {sorted(missing_ranks)[:10]}")
73
+ if extra_ranks:
74
+ errors.append(f"Invalid ranks (out of 1–100): {sorted(extra_ranks)[:10]}")
75
+ if len(ranks) != len(set(ranks)):
76
+ errors.append("Duplicate ranks found")
77
+ except (TypeError, ValueError) as e:
78
+ errors.append(f"Rank column contains non-integer values: {e}")
79
+
80
+ try:
81
+ scores = pd.to_numeric(df["score"], errors="raise")
82
+ if scores.isna().any():
83
+ errors.append("Score column contains NaN values")
84
+ else:
85
+ if scores.min() < 0:
86
+ errors.append(f"Score below 0: min={scores.min():.6f}")
87
+ if scores.max() > 1.0001:
88
+ errors.append(f"Score above 1: max={scores.max():.6f}")
89
+ except ValueError as e:
90
+ errors.append(f"Score column contains non-numeric values: {e}")
91
+
92
+ try:
93
+ df_sorted = df.copy()
94
+ df_sorted["rank_int"] = pd.to_numeric(df_sorted["rank"], errors="coerce")
95
+ df_sorted = df_sorted.sort_values("rank_int")
96
+ score_vals = pd.to_numeric(df_sorted["score"], errors="coerce").values
97
+
98
+ violations = []
99
+ for i in range(1, len(score_vals)):
100
+ if score_vals[i] > score_vals[i - 1] + 1e-9:
101
+ violations.append(
102
+ f"rank {i} → {i+1}: {score_vals[i-1]:.6f} → {score_vals[i]:.6f}"
103
+ )
104
+
105
+ if violations:
106
+ errors.append(
107
+ f"Monotonicity violated at {len(violations)} positions: "
108
+ f"{violations[:3]}"
109
+ )
110
+ except Exception as e:
111
+ errors.append(f"Could not check monotonicity: {e}")
112
+
113
+ if df["candidate_id"].isna().any():
114
+ errors.append("candidate_id column contains NaN values")
115
+ else:
116
+ if df["candidate_id"].duplicated().any():
117
+ dups = df[df["candidate_id"].duplicated()]["candidate_id"].tolist()
118
+ errors.append(f"Duplicate candidate_ids: {dups[:5]}")
119
+
120
+
121
+ bad_format = [
122
+ cid for cid in df["candidate_id"]
123
+ if not re.match(r'^(CAND_\d{7}|SYNTH_[A-Z_]+)$', str(cid))
124
+ ]
125
+ if bad_format:
126
+ warnings.append(
127
+ f"{len(bad_format)} candidate_ids don't match CAND_XXXXXXX format: "
128
+ f"{bad_format[:3]}"
129
+ )
130
+
131
+ if df["reasoning"].isna().any():
132
+ errors.append(f"{df['reasoning'].isna().sum()} reasoning fields are null")
133
+
134
+ empty_reasoning = df["reasoning"].fillna("").str.strip() == ""
135
+ if empty_reasoning.any():
136
+ errors.append(f"{empty_reasoning.sum()} reasoning fields are empty")
137
+
138
+ # check reasonable length (warn if very short)
139
+ short_reasoning = df["reasoning"].fillna("").str.len() < 20
140
+ if short_reasoning.any():
141
+ warnings.append(
142
+ f"{short_reasoning.sum()} reasoning fields are very short (<20 chars)"
143
+ )
144
+
145
+ stripped = df["candidate_id"].str.strip()
146
+ if (stripped != df["candidate_id"]).any():
147
+ errors.append("Some candidate_ids have leading/trailing whitespace")
148
+
149
+
150
+ print()
151
+ if errors:
152
+ print(f"RESULT: FAIL ({len(errors)} error(s), {len(warnings)} warning(s))\n")
153
+ for e in errors:
154
+ print(f" [FAIL] {e}")
155
+ for w in warnings:
156
+ print(f" [WARN] {w}")
157
+ return False
158
+ else:
159
+ print(f"RESULT: PASS (0 errors, {len(warnings)} warning(s))\n")
160
+
161
+ df_sorted = df.sort_values("rank")
162
+ scores = pd.to_numeric(df_sorted["score"])
163
+ print(f" Rows: {len(df)}")
164
+ print(f" Ranks: 1–{int(df['rank'].max())}")
165
+ print(f" Score range: [{scores.min():.6f}, {scores.max():.6f}]")
166
+ print(f" Avg reasoning length: {df['reasoning'].str.len().mean():.0f} chars")
167
+ print(f" Distinct candidate_ids: {df['candidate_id'].nunique()}")
168
+
169
+ for w in warnings:
170
+ print(f"\n [WARN] {w}")
171
+
172
+ print("\nSAFE TO SUBMIT [PASS]")
173
+ return True
174
+
175
+
176
+ def main():
177
+ parser = argparse.ArgumentParser(
178
+ description="Validate submission.csv against the Redrob spec checklist"
179
+ )
180
+ parser.add_argument(
181
+ "--submission",
182
+ default="./CTRL_COFFEE_REPEAT.csv",
183
+ help="Path to CTRL_COFFEE_REPEAT.csv to validate",
184
+ )
185
+ args = parser.parse_args()
186
+
187
+ passed = validate_submission(os.path.abspath(args.submission))
188
+ sys.exit(0 if passed else 1)
189
+
190
+
191
+ if __name__ == "__main__":
192
+ main()
src/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # src package — Redrob ranking pipeline core modules
src/features.py ADDED
@@ -0,0 +1,1217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import difflib
3
+ import math
4
+ import re
5
+ from datetime import date
6
+ from typing import Dict, List, Optional, Set, Tuple
7
+
8
+ from jd_parser import JDConfig, hard_req_coverage_score
9
+
10
+
11
+ REFERENCE_DATE = date(2026, 1, 1)
12
+
13
+
14
+ _DOMAIN_TITLE_KEYWORDS: Dict[str, List[str]] = {
15
+ "ai_ml": [
16
+ "machine learning", "ml", "data scientist", "ai", "nlp",
17
+ "deep learning", "research scientist", "applied scientist",
18
+ "ranking", "recommendation", "retrieval", "search"
19
+ ],
20
+ "data_engineering": [
21
+ "data engineer", "data pipeline", "etl", "spark", "kafka",
22
+ "warehouse", "dbt", "analytics engineer"
23
+ ],
24
+ "software_engineering": [
25
+ "software engineer", "backend", "frontend", "fullstack",
26
+ "full stack", "swe", "developer", "programmer"
27
+ ],
28
+ "devops_infra": [
29
+ "devops", "sre", "infrastructure", "platform engineer",
30
+ "cloud", "kubernetes", "docker"
31
+ ],
32
+ "consulting_non_technical": [
33
+ "consultant", "analyst", "business analyst", "manager",
34
+ "sales", "marketing", "customer support", "account"
35
+ ],
36
+ "cv_speech": [
37
+ "computer vision", "cv engineer", "image processing",
38
+ "speech", "audio", "tts", "asr"
39
+ ],
40
+ }
41
+
42
+ _DOMAIN_DESC_KEYWORDS: Dict[str, List[str]] = {
43
+ "ai_ml": [
44
+ "machine learning", "neural network", "model training",
45
+ "nlp", "embedding", "transformer", "ranking", "retrieval",
46
+ "recommendation", "gradient", "pytorch", "tensorflow"
47
+ ],
48
+ "data_engineering": [
49
+ "pipeline", "etl", "kafka", "spark", "warehouse",
50
+ "ingestion", "batch processing", "stream processing"
51
+ ],
52
+ "software_engineering": [
53
+ "api", "microservice", "backend", "database", "sql",
54
+ "rest", "graphql", "web application"
55
+ ],
56
+ "devops_infra": [
57
+ "kubernetes", "docker", "ci/cd", "deployment", "monitoring",
58
+ "cloud", "aws", "gcp", "azure", "infrastructure"
59
+ ],
60
+ "consulting_non_technical": [
61
+ "client", "stakeholder", "presentation", "consulting",
62
+ "business strategy", "slides", "excel modeling"
63
+ ],
64
+ "cv_speech": [
65
+ "opencv", "yolo", "object detection", "image classification",
66
+ "speech recognition", "tts", "text to speech"
67
+ ],
68
+ }
69
+
70
+ _SYNTHETIC_TEMPLATES = [
71
+ "responsible for overseeing",
72
+ "worked closely with cross-functional teams",
73
+ "collaborated with stakeholders to deliver",
74
+ "passionate about leveraging cutting-edge",
75
+ "i am a results-driven professional",
76
+ "seeking opportunities to apply my skills",
77
+ "strong communication and leadership skills",
78
+ "experience in agile and scrum methodologies",
79
+ "proficient in microsoft office suite",
80
+ "eager to contribute to organizational goals",
81
+ "team player with excellent interpersonal",
82
+ "dynamic and motivated self-starter",
83
+ "mechanical engineering design role at a hardware-product company",
84
+ "customer support team lead at a saas product",
85
+ "marketing leadership role at a b2b saas company",
86
+ "brand design and creative direction at a consumer-products company",
87
+ "operations management role at a logistics company",
88
+ ]
89
+
90
+ # Precomputed first words for each template — the real pre-filter.
91
+ # If the first word of a template isn't present in the description at all,
92
+ # SequenceMatcher ratio can never reach 0.65, so the call is safely skipped.
93
+ # Reduces SequenceMatcher calls from ~272K to ~3K across the 8533-candidate pool.
94
+ _TEMPLATE_FIRST_WORDS = [t.split()[0] for t in _SYNTHETIC_TEMPLATES]
95
+
96
+ _PRODUCTION_KEYWORDS = [
97
+ "deployed", "production", "serving", "latency",
98
+ "throughput", "scale", "real-time", "inference",
99
+ "a/b test", "monitoring", "pipeline", "distributed",
100
+ ]
101
+
102
+ _ACADEMIC_ONLY_KEYWORDS = [
103
+ "coursework", "thesis", "university project",
104
+ "academic project", "research paper", "capstone",
105
+ "class project", "homework",
106
+ ]
107
+
108
+ _PRE_LLM_SKILLS = {
109
+ "bm25", "tf-idf", "tfidf", "xgboost", "lightgbm", "scikit-learn",
110
+ "sklearn", "elasticsearch", "solr", "lucene", "faiss", "annoy",
111
+ "traditional ml", "gradient boosting", "random forest",
112
+ "word2vec", "glove", "fasttext",
113
+ }
114
+
115
+ _LLM_ERA_SKILLS = {
116
+ "langchain", "llamaindex", "llama index", "openai api",
117
+ "chatgpt api", "gpt-4", "prompt engineering", "rag",
118
+ "retrieval augmented generation", "langsmith", "autogpt",
119
+ "gpt wrapper",
120
+ }
121
+
122
+ _CV_SPEECH_SKILLS = {
123
+ "opencv", "cv2", "yolo", "object detection", "image classification",
124
+ "image segmentation", "pose estimation", "optical flow",
125
+ "tts", "text to speech", "speech recognition", "asr",
126
+ "gans", "generative adversarial", "stable diffusion",
127
+ }
128
+
129
+ _IR_SKILLS = {
130
+ "information retrieval", "bm25", "ranking", "learning to rank",
131
+ "recommendation", "retrieval", "search", "embedding", "faiss",
132
+ "vector search", "dense retrieval", "nlp", "natural language processing",
133
+ }
134
+
135
+
136
+ def _classify_text_domain(text: str, keyword_map: Dict[str, List[str]]) -> Optional[str]:
137
+ """Return the best-matching domain for text, or None if no match."""
138
+ text_lower = text.lower()
139
+ best_domain = None
140
+ best_count = 0
141
+ for domain, keywords in keyword_map.items():
142
+ count = sum(1 for kw in keywords if kw in text_lower)
143
+ if count > best_count:
144
+ best_count = count
145
+ best_domain = domain
146
+ return best_domain if best_count > 0 else None
147
+
148
+
149
+ def domain_category_mismatch(career_entry: dict) -> float:
150
+ """
151
+ Adversarial Function 1: Domain-Category Mismatch.
152
+ Maps job title through taxonomy to get its bucket, classifies description
153
+ by keyword presence. If domain(title) != domain(description), returns 1.
154
+
155
+ Schema fields read:
156
+ - career_history[].title
157
+ - career_history[].description
158
+
159
+ Returns: 0.0 (no mismatch) or 1.0 (mismatch detected).
160
+ """
161
+ title = (career_entry.get("title") or "").strip()
162
+ description = (career_entry.get("description") or "").strip()
163
+
164
+ if not title or not description:
165
+ return 0.0
166
+
167
+ title_domain = _classify_text_domain(title, _DOMAIN_TITLE_KEYWORDS)
168
+ desc_domain = _classify_text_domain(description, _DOMAIN_DESC_KEYWORDS)
169
+
170
+ if title_domain is None or desc_domain is None:
171
+ return 0.0
172
+
173
+ return 1.0 if title_domain != desc_domain else 0.0
174
+
175
+
176
+ def template_registry_match(description: str) -> float:
177
+ """
178
+ Adversarial Function 2: Template Registry.
179
+ String matching against known synthetic templates.
180
+ Fires if substring matches or SequenceMatcher ratio >= 0.65.
181
+
182
+ Pre-filter: each template's first word must appear in the description
183
+ before SequenceMatcher is called. If the first word is absent, the
184
+ full-string similarity ratio cannot reach 0.65 — so SM is safely skipped.
185
+ This reduces SequenceMatcher calls from ~272K to ~3K on the Stage 1 pool.
186
+
187
+ Schema fields read:
188
+ - career_history[].description
189
+
190
+ Returns: 1.0 if any template matches, 0.0 otherwise.
191
+ """
192
+ if not description:
193
+ return 0.0
194
+ desc_lower = description.lower()
195
+ fragment = desc_lower[:200]
196
+ for template, first_word in zip(_SYNTHETIC_TEMPLATES, _TEMPLATE_FIRST_WORDS):
197
+ if template in desc_lower:
198
+ return 1.0
199
+ if first_word not in desc_lower:
200
+ continue
201
+ ratio = difflib.SequenceMatcher(None, fragment, template, autojunk=False).ratio()
202
+ if ratio >= 0.65:
203
+ return 1.0
204
+ return 0.0
205
+
206
+
207
+ def prod_signal_log_score(description: str) -> float:
208
+ """
209
+ Adversarial Function 3: Production Signal (log-compression).
210
+ Returns log(1 + count) of production keywords in description.
211
+ If ONLY academic keywords exist (and no production keywords), returns -1.0.
212
+
213
+ Schema fields read:
214
+ - career_history[].description
215
+
216
+ Returns: float. -1.0 for pure academic, log(1+count) >= 0 for production.
217
+ """
218
+ if not description:
219
+ return 0.0
220
+
221
+ desc_lower = description.lower()
222
+ prod_count = sum(1 for kw in _PRODUCTION_KEYWORDS if kw in desc_lower)
223
+ academic_count = sum(1 for kw in _ACADEMIC_ONLY_KEYWORDS if kw in desc_lower)
224
+
225
+ if prod_count == 0 and academic_count > 0:
226
+ return -1.0
227
+
228
+ return math.log1p(prod_count)
229
+
230
+
231
+ def langchain_dabbler_score(skills: List[dict]) -> float:
232
+ """
233
+ Adversarial Function 4: Temporal LangChain Dabbler.
234
+ Evaluates pre_llm (bm25, xgboost, scikit-learn) vs llm_era (langchain, openai api).
235
+ High return value = more pre-LLM depth (good signal).
236
+ Low return value = LLM-only / LangChain-only (bad signal).
237
+
238
+ Schema fields read:
239
+ - skills[].name
240
+ - skills[].duration_months (optional, falls back to count)
241
+
242
+ Returns: float in [-1.0, 1.0]:
243
+ - 1.0 = strong pre-LLM foundation
244
+ - 0.0 = balanced or no signal
245
+ - -1.0 = LLM-era only (LangChain dabbler)
246
+ """
247
+ if not skills:
248
+ return 0.0
249
+
250
+ pre_llm_months = 0
251
+ llm_era_months = 0
252
+
253
+ for s in skills:
254
+ name = (s.get("name") or "").lower().strip()
255
+ months = s.get("duration_months") or 0 # safe default if missing
256
+ months = max(0, int(months))
257
+
258
+ weight = months if months > 0 else 1
259
+
260
+ if any(pre in name for pre in _PRE_LLM_SKILLS):
261
+ pre_llm_months += weight
262
+ if any(llm in name for llm in _LLM_ERA_SKILLS):
263
+ llm_era_months += weight
264
+
265
+ total = pre_llm_months + llm_era_months
266
+ if total == 0:
267
+ return 0.0
268
+
269
+ return (pre_llm_months - llm_era_months) / total
270
+
271
+
272
+ def cv_specialist_score(skills: List[dict]) -> float:
273
+ """
274
+ Adversarial Function 5: CV/Speech Specialist.
275
+ Evaluates opencv, yolo, tts dominance over IR skills.
276
+
277
+ Schema fields read:
278
+ - skills[].name
279
+ - skills[].duration_months (optional)
280
+
281
+ Returns: float in [0.0, 1.0] where 1.0 = pure CV/Speech (bad for this JD).
282
+ """
283
+ if not skills:
284
+ return 0.0
285
+
286
+ cv_months = 0
287
+ ir_months = 0
288
+
289
+ for s in skills:
290
+ name = (s.get("name") or "").lower().strip()
291
+ months = s.get("duration_months") or 0
292
+ months = max(0, int(months))
293
+ weight = months if months > 0 else 1
294
+
295
+ if any(cv in name for cv in _CV_SPEECH_SKILLS):
296
+ cv_months += weight
297
+ if any(ir in name for ir in _IR_SKILLS):
298
+ ir_months += weight
299
+
300
+ total = cv_months + ir_months
301
+ if total == 0:
302
+ return 0.0
303
+
304
+ return cv_months / total
305
+
306
+
307
+
308
+ def _safe_date(date_str: Optional[str]) -> Optional[date]:
309
+ """Parse date string safely; return None on any failure."""
310
+ if not date_str:
311
+ return None
312
+ try:
313
+ return date.fromisoformat(str(date_str))
314
+ except (ValueError, TypeError):
315
+ return None
316
+
317
+
318
+ def compute_yoe(candidate: dict) -> float:
319
+ """
320
+ Feature 2: Years of experience.
321
+ Schema fields read: profile.years_of_experience
322
+ """
323
+ yoe = candidate.get("profile", {}).get("years_of_experience")
324
+ if yoe is None:
325
+ return 0.0
326
+ try:
327
+ return max(0.0, float(yoe))
328
+ except (TypeError, ValueError):
329
+ return 0.0
330
+
331
+
332
+ def compute_param_a_systems_depth(candidate: dict) -> float:
333
+ """
334
+ Feature 3: Param_A_Systems_Depth.
335
+ Fraction of career months in roles where descriptions contain
336
+ retrieval/ranking/search/recommendation.
337
+
338
+ Schema fields read:
339
+ - career_history[].description
340
+ - career_history[].duration_months
341
+ """
342
+ _SYSTEMS_KEYWORDS = {
343
+ "retrieval", "ranking", "search", "recommendation",
344
+ "information retrieval", "candidate retrieval",
345
+ "passage retrieval", "vector search", "recommendation system",
346
+ "recommender", "re-ranking", "reranking",
347
+ }
348
+
349
+ career = candidate.get("career_history", []) or []
350
+ total_months = 0
351
+ systems_months = 0
352
+
353
+ for ch in career:
354
+ dur = ch.get("duration_months")
355
+ if dur is None:
356
+ continue
357
+ try:
358
+ dur = max(0, int(dur))
359
+ except (TypeError, ValueError):
360
+ dur = 0
361
+
362
+ total_months += dur
363
+ desc = (ch.get("description") or "").lower()
364
+ if any(kw in desc for kw in _SYSTEMS_KEYWORDS):
365
+ systems_months += dur
366
+
367
+ return systems_months / total_months if total_months > 0 else 0.0
368
+
369
+
370
+ def compute_param_b_availability(candidate: dict) -> float:
371
+ """
372
+ Feature 4: Param_B_Availability.
373
+ Combined recruiter response rate and recency of last activity.
374
+
375
+ Schema fields read:
376
+ - redrob_signals.recruiter_response_rate (0–1)
377
+ - redrob_signals.last_active_date
378
+ - redrob_signals.open_to_work_flag
379
+ """
380
+ signals = candidate.get("redrob_signals", {}) or {}
381
+
382
+ rr = signals.get("recruiter_response_rate")
383
+ if rr is None:
384
+ rr = 0.0
385
+ try:
386
+ rr = max(0.0, min(1.0, float(rr)))
387
+ except (TypeError, ValueError):
388
+ rr = 0.0
389
+
390
+ last_active = _safe_date(signals.get("last_active_date"))
391
+ if last_active is None:
392
+ recency_score = 0.0
393
+ else:
394
+ days_since = (REFERENCE_DATE - last_active).days
395
+ days_since = max(0, days_since)
396
+ recency_score = math.exp(-days_since / 180.0)
397
+
398
+ open_to_work = float(bool(signals.get("open_to_work_flag", False)))
399
+
400
+ # Weighted combination
401
+ return 0.4 * rr + 0.4 * recency_score + 0.2 * open_to_work
402
+
403
+
404
+ def compute_param_c_tenure(candidate: dict) -> float:
405
+ """
406
+ Feature 5: Param_C_Tenure.
407
+ Reward for 3+ year average tenure. Returns 1.0 if avg >= 36 months, scaled.
408
+
409
+ Schema fields read:
410
+ - career_history[].duration_months
411
+ """
412
+ career = candidate.get("career_history", []) or []
413
+ if not career:
414
+ return 0.0
415
+
416
+ durations = []
417
+ for ch in career:
418
+ dur = ch.get("duration_months")
419
+ if dur is not None:
420
+ try:
421
+ dur = max(0, int(dur))
422
+ durations.append(dur)
423
+ except (TypeError, ValueError):
424
+ pass
425
+
426
+ if not durations:
427
+ return 0.0
428
+
429
+ avg_months = sum(durations) / len(durations)
430
+ return min(1.0, avg_months / 36.0)
431
+
432
+
433
+ def compute_param_d_notice_exp(candidate: dict) -> float:
434
+ """
435
+ Feature 6: Param_D_Notice_Exp.
436
+ exp(-max(0, days-30)/30) — continuous decay gradient.
437
+
438
+ Schema fields read:
439
+ - redrob_signals.notice_period_days (int, 0–180)
440
+ """
441
+ signals = candidate.get("redrob_signals", {}) or {}
442
+ days = signals.get("notice_period_days")
443
+ if days is None:
444
+ return 1.0
445
+ try:
446
+ days = max(0, int(days))
447
+ except (TypeError, ValueError):
448
+ return 1.0
449
+
450
+ return math.exp(-max(0, days - 30) / 30.0)
451
+
452
+
453
+ def compute_param_e_credibility(candidate: dict) -> float:
454
+ """
455
+ Feature 7: Param_E_Credibility.
456
+ advanced_claimed_count / max(1, assessed_count).
457
+ Higher = Less credible (more advanced claims than assessments).
458
+
459
+ NOTE: We count skills where proficiency == "advanced" AND the skill name
460
+ appears in skill_assessment_scores keys as "assessed". We count skills
461
+ with proficiency == "advanced" regardless as "claimed".
462
+
463
+ Schema fields read:
464
+ - skills[].name
465
+ - skills[].proficiency
466
+ - redrob_signals.skill_assessment_scores (dict skill_name -> score 0-100)
467
+ """
468
+ skills = candidate.get("skills", []) or []
469
+ signals = candidate.get("redrob_signals", {}) or {}
470
+ assessments = signals.get("skill_assessment_scores") or {}
471
+
472
+ if not isinstance(assessments, dict):
473
+ assessments = {}
474
+
475
+ assessed_keys = {k.lower().strip() for k in assessments.keys()}
476
+
477
+ advanced_claimed = 0
478
+ assessed_advanced = 0
479
+
480
+ for s in skills:
481
+ proficiency = (s.get("proficiency") or "").lower()
482
+ name = (s.get("name") or "").lower().strip()
483
+
484
+ if proficiency == "advanced":
485
+ advanced_claimed += 1
486
+ if name in assessed_keys:
487
+ assessed_advanced += 1
488
+
489
+ return min(5.0, advanced_claimed / max(1, assessed_advanced))
490
+
491
+
492
+ def compute_param_f_consulting(candidate: dict) -> float:
493
+ """
494
+ Feature 8: Param_F_Consulting.
495
+ Fraction of career months spent in IT Services / Consulting roles.
496
+
497
+ Schema fields read:
498
+ - career_history[].industry
499
+ - career_history[].duration_months
500
+ """
501
+ _CONSULTING_INDUSTRIES = {
502
+ "it services", "consulting", "staffing", "outsourcing",
503
+ "bpo", "business process outsourcing", "it consulting",
504
+ }
505
+
506
+ career = candidate.get("career_history", []) or []
507
+ total_months = 0
508
+ consulting_months = 0
509
+
510
+ for ch in career:
511
+ industry = (ch.get("industry") or "").lower().strip()
512
+ dur = ch.get("duration_months")
513
+ if dur is None:
514
+ continue
515
+ try:
516
+ dur = max(0, int(dur))
517
+ except (TypeError, ValueError):
518
+ dur = 0
519
+
520
+ total_months += dur
521
+ if any(ci in industry for ci in _CONSULTING_INDUSTRIES):
522
+ consulting_months += dur
523
+
524
+ return consulting_months / total_months if total_months > 0 else 0.0
525
+
526
+
527
+ def compute_param_g_location(candidate: dict) -> float:
528
+ """
529
+ Feature 9: Param_G_Location.
530
+ Pune/Noida = 1.0, other India = 0.5, outside India = 0.0.
531
+
532
+ Schema fields read:
533
+ - profile.location (city, region/state)
534
+ - profile.country
535
+ """
536
+ profile = candidate.get("profile", {}) or {}
537
+ location = (profile.get("location") or "").lower().strip()
538
+ country = (profile.get("country") or "").lower().strip()
539
+
540
+ # Priority locations
541
+ if any(city in location for city in ["pune", "noida"]):
542
+ return 1.0
543
+
544
+ # Other India
545
+ india_indicators = ["india", "in", "bengaluru", "bangalore", "mumbai",
546
+ "hyderabad", "chennai", "delhi", "gurugram", "gurgaon",
547
+ "kolkata", "ahmedabad", "jaipur", "chandigarh"]
548
+ if country in ["india", "in"] or any(ind in location for ind in india_indicators):
549
+ return 0.5
550
+
551
+ return 0.0
552
+
553
+
554
+ def compute_param_h_github(candidate: dict) -> float:
555
+ """
556
+ Feature 10: Param_H_GitHub.
557
+ Open source activity score, normalized to [0, 1].
558
+ -1 means no GitHub linked → return 0.0.
559
+
560
+ Schema fields read:
561
+ - redrob_signals.github_activity_score (float, -1 to 100)
562
+ """
563
+ signals = candidate.get("redrob_signals", {}) or {}
564
+ score = signals.get("github_activity_score")
565
+ if score is None:
566
+ return 0.0
567
+ try:
568
+ score = float(score)
569
+ except (TypeError, ValueError):
570
+ return 0.0
571
+
572
+ if score < 0:
573
+ return 0.0 # No GitHub linked
574
+
575
+ return min(1.0, score / 100.0)
576
+
577
+
578
+ def compute_title_ai_fraction(candidate: dict) -> float:
579
+ """
580
+ Feature 11: title_ai_fraction.
581
+ Fraction of career roles with AI/ML-oriented job titles.
582
+
583
+ Schema fields read:
584
+ - career_history[].title
585
+ """
586
+ _AI_TITLE_KEYWORDS = [
587
+ "machine learning", "ml", "data scientist", "ai", "nlp",
588
+ "deep learning", "research", "applied scientist",
589
+ "ranking", "recommendation", "search", "retrieval",
590
+ "computer vision", "speech", "nlp engineer",
591
+ ]
592
+
593
+ career = candidate.get("career_history", []) or []
594
+ if not career:
595
+ return 0.0
596
+
597
+ ai_count = 0
598
+ for ch in career:
599
+ title = (ch.get("title") or "").lower()
600
+ if any(kw in title for kw in _AI_TITLE_KEYWORDS):
601
+ ai_count += 1
602
+
603
+ return ai_count / len(career)
604
+
605
+
606
+ def compute_prod_signal_log(candidate: dict) -> float:
607
+ """
608
+ Feature 12: prod_signal_log.
609
+ Aggregate production signal across ALL career history descriptions.
610
+ Uses the adversarial function prod_signal_log_score per role.
611
+
612
+ Schema fields read:
613
+ - career_history[].description
614
+ """
615
+ career = candidate.get("career_history", []) or []
616
+ if not career:
617
+ return 0.0
618
+
619
+ total_prod_count = 0
620
+ is_academic_only = True
621
+ has_any_description = False
622
+
623
+ for ch in career:
624
+ desc = ch.get("description") or ""
625
+ if not desc:
626
+ continue
627
+ has_any_description = True
628
+ desc_lower = desc.lower()
629
+
630
+ prod_count = sum(1 for kw in _PRODUCTION_KEYWORDS if kw in desc_lower)
631
+ academic_count = sum(1 for kw in _ACADEMIC_ONLY_KEYWORDS if kw in desc_lower)
632
+
633
+ total_prod_count += prod_count
634
+ if prod_count > 0:
635
+ is_academic_only = False
636
+
637
+ if not has_any_description:
638
+ return 0.0
639
+
640
+ if total_prod_count == 0 and is_academic_only:
641
+ return -1.0
642
+
643
+ return math.log1p(total_prod_count)
644
+
645
+
646
+ def compute_flag_consulting_only(candidate: dict) -> float:
647
+ """
648
+ Feature 15: flag_consulting_only.
649
+ 1.0 if ALL career history is in IT Services / Consulting with no product-company roles.
650
+
651
+ Schema fields read:
652
+ - career_history[].industry
653
+ """
654
+ career = candidate.get("career_history", []) or []
655
+ if not career:
656
+ return 0.0
657
+
658
+ _CONSULTING_INDUSTRIES = {
659
+ "it services", "consulting", "staffing", "outsourcing", "bpo",
660
+ }
661
+ _PRODUCT_INDUSTRIES = {
662
+ "internet", "software", "technology", "fintech", "saas",
663
+ "e-commerce", "product", "startup",
664
+ }
665
+
666
+ all_consulting = True
667
+ for ch in career:
668
+ industry = (ch.get("industry") or "").lower().strip()
669
+ if not any(ci in industry for ci in _CONSULTING_INDUSTRIES):
670
+ all_consulting = False
671
+ break
672
+
673
+ return 1.0 if all_consulting else 0.0
674
+
675
+
676
+ def compute_flag_title_chaser(candidate: dict) -> float:
677
+ """
678
+ Feature 16: flag_title_chaser.
679
+ Detects candidates who adopt trendy AI titles with very short tenure.
680
+ Flag fires if most recent role has AI/ML title AND average tenure < 15 months
681
+ AND at least one role has duration < 12 months.
682
+
683
+ Schema fields read:
684
+ - career_history[].title
685
+ - career_history[].duration_months
686
+ - career_history[].is_current
687
+ """
688
+ _TRENDY_TITLES = [
689
+ "ai", "machine learning", "ml", "generative", "llm",
690
+ "prompt", "gpt", "langchain", "chatbot", "nlp", "data scientist"
691
+ ]
692
+
693
+ career = candidate.get("career_history", []) or []
694
+ if not career:
695
+ return 0.0
696
+
697
+
698
+ current_roles = [ch for ch in career if ch.get("is_current", False)]
699
+ most_recent = current_roles[0] if current_roles else career[-1]
700
+
701
+ title = (most_recent.get("title") or "").lower()
702
+ is_trendy_title = any(kw in title for kw in _TRENDY_TITLES)
703
+
704
+ durations = []
705
+ for ch in career:
706
+ dur = ch.get("duration_months")
707
+ if dur is not None:
708
+ try:
709
+ dur = max(0, int(dur))
710
+ durations.append(dur)
711
+ except (TypeError, ValueError):
712
+ pass
713
+
714
+ if not durations:
715
+ return 0.0
716
+
717
+ avg_tenure = sum(durations) / len(durations)
718
+ is_short_tenure = (avg_tenure < 15.0) and any(d < 12 for d in durations)
719
+
720
+ return 1.0 if (is_trendy_title and is_short_tenure) else 0.0
721
+
722
+
723
+ def compute_flag_langchain_dabbler(skills: List[dict]) -> float:
724
+ """
725
+ Feature 17: flag_langchain_dabbler.
726
+ 1.0 if LLM-era skills dominate with no pre-LLM foundation.
727
+
728
+ Schema fields read:
729
+ - skills[].name
730
+ - skills[].duration_months
731
+ """
732
+ score = langchain_dabbler_score(skills)
733
+
734
+ return 1.0 if score < -0.3 else 0.0
735
+
736
+
737
+ def compute_flag_cv_specialist(skills: List[dict]) -> float:
738
+ """
739
+ Feature 18: flag_cv_specialist.
740
+ 1.0 if CV/speech skills dominate over IR skills.
741
+
742
+ Schema fields read:
743
+ - skills[].name
744
+ - skills[].duration_months
745
+ """
746
+ cv_score = cv_specialist_score(skills)
747
+ return 1.0 if cv_score > 0.7 else 0.0
748
+
749
+
750
+ def compute_flag_title_desc_mismatch(candidate: dict) -> float:
751
+ """
752
+ Feature 19: flag_title_desc_mismatch.
753
+ Uses domain_category_mismatch on the most recent career entry.
754
+
755
+ Schema fields read:
756
+ - career_history[].title
757
+ - career_history[].description
758
+ """
759
+ career = candidate.get("career_history", []) or []
760
+ if not career:
761
+ return 0.0
762
+
763
+ current_roles = [ch for ch in career if ch.get("is_current", False)]
764
+ most_recent = current_roles[0] if current_roles else career[-1]
765
+
766
+ return domain_category_mismatch(most_recent)
767
+
768
+
769
+ def compute_flag_template_desc(candidate: dict) -> float:
770
+ """
771
+ Feature 20: flag_template_desc.
772
+ 1.0 if ANY career description matches a synthetic template.
773
+
774
+ Schema fields read:
775
+ - career_history[].description
776
+ """
777
+ career = candidate.get("career_history", []) or []
778
+ for ch in career:
779
+ desc = ch.get("description") or ""
780
+ if template_registry_match(desc) == 1.0:
781
+ return 1.0
782
+ return 0.0
783
+
784
+
785
+ def build_feature_vector(
786
+ candidate: dict,
787
+ jd_config: JDConfig,
788
+ bm25_score: float,
789
+ stage1_bm25_median: float = 0.0,
790
+ precomputed_static: Optional[Dict[str, float]] = None,
791
+ ) -> Dict[str, float]:
792
+ """
793
+ Build the complete 22-feature vector for a single candidate.
794
+
795
+ Args:
796
+ candidate: Parsed candidate dict (from JSONL).
797
+ jd_config: Parsed JD configuration.
798
+ bm25_score: BM25 retrieval score from Stage 1.
799
+ stage1_bm25_median: Median BM25 score of Stage 1 candidates (for c5).
800
+ precomputed_static: Optional precomputed dictionary of the 18 static features.
801
+
802
+ Returns:
803
+ Dict mapping feature name -> float value.
804
+ All features are guaranteed finite floats (no NaN, no None).
805
+ """
806
+ from features import (
807
+ c1_timeline_impossibility, c2_signup_anomaly, c3_salary_inversion,
808
+ c4_assessment_contradiction, c5_engagement_mismatch, consistency_score,
809
+ )
810
+
811
+ profile = candidate.get("profile", {}) or {}
812
+ skills = candidate.get("skills", []) or []
813
+
814
+ if precomputed_static is not None:
815
+ yoe = float(precomputed_static.get("yoe", 0.0))
816
+ hard_req = hard_req_coverage_score(candidate, jd_config)
817
+ cons = consistency_score(
818
+ candidate,
819
+ bm25_score=bm25_score,
820
+ median_bm25=stage1_bm25_median,
821
+ )
822
+ param_a = float(precomputed_static.get("Param_A_Systems_Depth", 0.0))
823
+ param_b = float(precomputed_static.get("Param_B_Availability", 0.0))
824
+ param_c = float(precomputed_static.get("Param_C_Tenure", 0.0))
825
+ param_d = float(precomputed_static.get("Param_D_Notice_Exp", 0.0))
826
+ param_e = float(precomputed_static.get("Param_E_Credibility", 0.0))
827
+ param_f = float(precomputed_static.get("Param_F_Consulting", 0.0))
828
+ param_g = float(precomputed_static.get("Param_G_Location", 0.0))
829
+ param_h = float(precomputed_static.get("Param_H_GitHub", 0.0))
830
+ title_ai_frac = float(precomputed_static.get("title_ai_fraction", 0.0))
831
+ prod_sig_log = float(precomputed_static.get("prod_signal_log", 0.0))
832
+ flag_consulting_only = float(precomputed_static.get("flag_consulting_only", 0.0))
833
+ flag_title_chaser = float(precomputed_static.get("flag_title_chaser", 0.0))
834
+ flag_langchain = float(precomputed_static.get("flag_langchain_dabbler", 0.0))
835
+ flag_cv = float(precomputed_static.get("flag_cv_specialist", 0.0))
836
+ flag_title_desc = float(precomputed_static.get("flag_title_desc_mismatch", 0.0))
837
+ flag_template = float(precomputed_static.get("flag_template_desc", 0.0))
838
+ interaction_yoe_x_prod = float(precomputed_static.get("interaction_yoe_x_prod", 0.0))
839
+ else:
840
+ yoe = compute_yoe(candidate)
841
+ hard_req = hard_req_coverage_score(candidate, jd_config)
842
+ cons = consistency_score(
843
+ candidate,
844
+ bm25_score=bm25_score,
845
+ median_bm25=stage1_bm25_median,
846
+ )
847
+ param_a = compute_param_a_systems_depth(candidate)
848
+ param_b = compute_param_b_availability(candidate)
849
+ param_c = compute_param_c_tenure(candidate)
850
+ param_d = compute_param_d_notice_exp(candidate)
851
+ param_e = compute_param_e_credibility(candidate)
852
+ param_f = compute_param_f_consulting(candidate)
853
+ param_g = compute_param_g_location(candidate)
854
+ param_h = compute_param_h_github(candidate)
855
+ title_ai_frac = compute_title_ai_fraction(candidate)
856
+ prod_sig_log = compute_prod_signal_log(candidate)
857
+ flag_consulting_only = compute_flag_consulting_only(candidate)
858
+ flag_title_chaser = compute_flag_title_chaser(candidate)
859
+ flag_langchain = compute_flag_langchain_dabbler(skills)
860
+ flag_cv = compute_flag_cv_specialist(skills)
861
+ flag_title_desc = compute_flag_title_desc_mismatch(candidate)
862
+ flag_template = compute_flag_template_desc(candidate)
863
+ interaction_yoe_x_prod = yoe * max(0.0, prod_sig_log)
864
+
865
+ interaction_req_x_cons = hard_req * cons
866
+
867
+
868
+ fv = {
869
+ "bm25_score": float(bm25_score),
870
+ "yoe": float(yoe),
871
+ "Param_A_Systems_Depth": float(param_a),
872
+ "Param_B_Availability": float(param_b),
873
+ "Param_C_Tenure": float(param_c),
874
+ "Param_D_Notice_Exp": float(param_d),
875
+ "Param_E_Credibility": float(param_e),
876
+ "Param_F_Consulting": float(param_f),
877
+ "Param_G_Location": float(param_g),
878
+ "Param_H_GitHub": float(param_h),
879
+ "title_ai_fraction": float(title_ai_frac),
880
+ "prod_signal_log": float(prod_sig_log),
881
+ "consistency_score": float(cons),
882
+ "hard_req_coverage": float(hard_req),
883
+ "flag_consulting_only": float(flag_consulting_only),
884
+ "flag_title_chaser": float(flag_title_chaser),
885
+ "flag_langchain_dabbler": float(flag_langchain),
886
+ "flag_cv_specialist": float(flag_cv),
887
+ "flag_title_desc_mismatch": float(flag_title_desc),
888
+ "flag_template_desc": float(flag_template),
889
+ "interaction_req_x_consistency": float(interaction_req_x_cons),
890
+ "interaction_yoe_x_prod": float(interaction_yoe_x_prod),
891
+ }
892
+
893
+ for k, v in fv.items():
894
+ if not math.isfinite(v):
895
+ fv[k] = 0.0
896
+
897
+ assert len(fv) == 22, f"Feature vector has {len(fv)} features, expected 22"
898
+ return fv
899
+
900
+
901
+
902
+ def c1_timeline_impossibility(candidate: dict) -> float:
903
+ """
904
+ Consistency Check 1: Timeline Impossibility.
905
+ Flag if any skill.duration_months > total_months_of_experience.
906
+
907
+ Schema fields read:
908
+ - skills[].duration_months
909
+ - profile.years_of_experience
910
+ """
911
+ yoe = compute_yoe(candidate)
912
+ total_months = yoe * 12.0
913
+
914
+ skills = candidate.get("skills", []) or []
915
+ for s in skills:
916
+ dur = s.get("duration_months")
917
+ if dur is None:
918
+ continue
919
+ try:
920
+ dur = max(0, int(dur))
921
+ except (TypeError, ValueError):
922
+ continue
923
+
924
+ if dur > total_months:
925
+ return 0.0 # Violation
926
+
927
+ return 1.0
928
+
929
+
930
+ def c2_signup_anomaly(candidate: dict) -> float:
931
+ """
932
+ Consistency Check 2: Signup Anomaly.
933
+ Flag if signup_date is chronologically AFTER last_active_date.
934
+
935
+ Schema fields read:
936
+ - redrob_signals.signup_date
937
+ - redrob_signals.last_active_date
938
+ """
939
+ signals = candidate.get("redrob_signals", {}) or {}
940
+ signup = _safe_date(signals.get("signup_date"))
941
+ last_active = _safe_date(signals.get("last_active_date"))
942
+
943
+ if signup is None or last_active is None:
944
+ return 1.0
945
+
946
+ if signup > last_active:
947
+ return 0.0
948
+
949
+ return 1.0
950
+
951
+
952
+ def c3_salary_inversion(candidate: dict) -> float:
953
+ """
954
+ Consistency Check 3: Salary Inversion.
955
+ Flag if expected_salary.min > max.
956
+
957
+ Schema fields read:
958
+ - redrob_signals.expected_salary_range_inr_lpa.min
959
+ - redrob_signals.expected_salary_range_inr_lpa.max
960
+ """
961
+ signals = candidate.get("redrob_signals", {}) or {}
962
+ salary = signals.get("expected_salary_range_inr_lpa") or {}
963
+
964
+ sal_min = salary.get("min")
965
+ sal_max = salary.get("max")
966
+
967
+ if sal_min is None or sal_max is None:
968
+ return 1.0
969
+
970
+ try:
971
+ sal_min = float(sal_min)
972
+ sal_max = float(sal_max)
973
+ except (TypeError, ValueError):
974
+ return 1.0
975
+
976
+ if sal_min > sal_max:
977
+ return 0.0
978
+
979
+ return 1.0
980
+
981
+
982
+ def c4_assessment_contradiction(candidate: dict) -> float:
983
+ """
984
+ Consistency Check 4: Assessment Contradiction.
985
+ Flag if candidate claims "advanced" AND assessment score exists AND score < 50.
986
+
987
+ Schema fields read:
988
+ - skills[].name
989
+ - skills[].proficiency
990
+ - redrob_signals.skill_assessment_scores (dict)
991
+ """
992
+ skills = candidate.get("skills", []) or []
993
+ signals = candidate.get("redrob_signals", {}) or {}
994
+ assessments = signals.get("skill_assessment_scores") or {}
995
+
996
+ if not isinstance(assessments, dict):
997
+ assessments = {}
998
+
999
+ assessed = {k.lower().strip(): v for k, v in assessments.items()}
1000
+
1001
+ for s in skills:
1002
+ proficiency = (s.get("proficiency") or "").lower()
1003
+ name = (s.get("name") or "").lower().strip()
1004
+
1005
+ if proficiency == "advanced" and name in assessed:
1006
+ score = assessed[name]
1007
+ try:
1008
+ score = float(score)
1009
+ if score < 50.0:
1010
+ return 0.0
1011
+ except (TypeError, ValueError):
1012
+ pass
1013
+
1014
+ return 1.0
1015
+
1016
+
1017
+ def c5_engagement_mismatch(
1018
+ candidate: dict,
1019
+ bm25_score: float,
1020
+ median_bm25: float,
1021
+ ) -> float:
1022
+ """
1023
+ Consistency Check 5: Engagement Mismatch (Data-Adaptive).
1024
+ Flag if bm25_score > median(stage1_scores)
1025
+ AND connection_count <= 60
1026
+ AND search_appearance_30d <= 15
1027
+ AND endorsements_received <= 4.
1028
+
1029
+ Schema fields read:
1030
+ - redrob_signals.connection_count
1031
+ - redrob_signals.search_appearance_30d
1032
+ - redrob_signals.endorsements_received
1033
+ """
1034
+ signals = candidate.get("redrob_signals", {}) or {}
1035
+
1036
+ connections = signals.get("connection_count") or 0
1037
+ appearances = signals.get("search_appearance_30d") or 0
1038
+ endorsements = signals.get("endorsements_received") or 0
1039
+
1040
+ try:
1041
+ connections = int(connections)
1042
+ appearances = int(appearances)
1043
+ endorsements = int(endorsements)
1044
+ except (TypeError, ValueError):
1045
+ return 1.0
1046
+
1047
+ is_high_bm25 = bm25_score > median_bm25
1048
+ is_suspicious_engagement = (connections <= 60 and appearances <= 15 and endorsements <= 4)
1049
+
1050
+ if is_high_bm25 and is_suspicious_engagement:
1051
+ return 0.0
1052
+
1053
+ return 1.0
1054
+
1055
+
1056
+ def consistency_score(
1057
+ candidate: dict,
1058
+ bm25_score: float = 0.0,
1059
+ median_bm25: float = 0.0,
1060
+ ) -> float:
1061
+ """
1062
+ Composite consistency multiplier from Section 5.
1063
+ Returns the product of all 5 checks.
1064
+
1065
+ AUDIT TRAIL — all 5 checks explicitly multiplied (verified against architecture doc):
1066
+
1067
+ result = c1 * c2 * c3 * c4 * c5
1068
+
1069
+ Each check returns 1.0 (pass) or 0.0 (violation), so any single violation
1070
+ zeros out the composite score.
1071
+ """
1072
+ c1 = c1_timeline_impossibility(candidate)
1073
+ c2 = c2_signup_anomaly(candidate)
1074
+ c3 = c3_salary_inversion(candidate)
1075
+ c4 = c4_assessment_contradiction(candidate)
1076
+ c5 = c5_engagement_mismatch(candidate, bm25_score, median_bm25)
1077
+
1078
+ result = c1 * c2 * c3 * c4 * c5
1079
+ return float(result)
1080
+
1081
+
1082
+ def score_langchain_dabbler(candidate: dict) -> float:
1083
+ """Helper wrapper for precompute offline labels penalty."""
1084
+ return compute_flag_langchain_dabbler(candidate.get("skills") or [])
1085
+
1086
+
1087
+ def score_title_skill_discontinuity(candidate: dict) -> float:
1088
+ """Helper wrapper for precompute offline labels penalty."""
1089
+ return compute_flag_title_chaser(candidate)
1090
+
1091
+
1092
+ def detect_description_title_mismatch(candidate: dict) -> float:
1093
+ """Helper wrapper for precompute offline labels penalty."""
1094
+ return compute_flag_title_desc_mismatch(candidate)
1095
+
1096
+
1097
+ def score_cv_speech_specialist(candidate: dict) -> float:
1098
+ """Helper wrapper for precompute offline labels penalty."""
1099
+ return compute_flag_cv_specialist(candidate.get("skills") or [])
1100
+
1101
+
1102
+ # Feature column order for LightGBM (must match training order)
1103
+ FEATURE_COLUMNS = [
1104
+ "bm25_score", "yoe", "Param_A_Systems_Depth", "Param_B_Availability",
1105
+ "Param_C_Tenure", "Param_D_Notice_Exp", "Param_E_Credibility",
1106
+ "Param_F_Consulting", "Param_G_Location", "Param_H_GitHub",
1107
+ "title_ai_fraction", "prod_signal_log", "consistency_score",
1108
+ "hard_req_coverage", "flag_consulting_only", "flag_title_chaser",
1109
+ "flag_langchain_dabbler", "flag_cv_specialist", "flag_title_desc_mismatch",
1110
+ "flag_template_desc", "interaction_req_x_consistency", "interaction_yoe_x_prod",
1111
+ ]
1112
+
1113
+
1114
+
1115
+ if __name__ == "__main__":
1116
+ import json
1117
+ import sys
1118
+
1119
+ print("=== Testing 5 Adversarial Functions ===\n")
1120
+
1121
+
1122
+ entry_ok = {"title": "Machine Learning Engineer", "description": "Built ranking models using neural networks and transformers."}
1123
+ entry_bad = {"title": "Customer Support", "description": "Conducted research on neural network architectures for image classification."}
1124
+ print(f"domain_category_mismatch (no mismatch): {domain_category_mismatch(entry_ok)}")
1125
+ print(f"domain_category_mismatch (mismatch): {domain_category_mismatch(entry_bad)}")
1126
+
1127
+
1128
+ desc_template = "I am a results-driven professional with experience in agile and scrum methodologies."
1129
+ desc_real = "Deployed a production BM25 ranking system serving 10M queries/day with p99 latency < 50ms."
1130
+ print(f"\ntemplate_registry_match (template): {template_registry_match(desc_template)}")
1131
+ print(f"template_registry_match (real): {template_registry_match(desc_real)}")
1132
+
1133
+
1134
+ prod_desc = "Deployed model to production serving 1M users at scale with low latency."
1135
+ academic_desc = "University project on coursework for thesis on deep learning."
1136
+ empty_desc = ""
1137
+ print(f"\nprod_signal_log_score (production): {prod_signal_log_score(prod_desc):.4f}")
1138
+ print(f"prod_signal_log_score (academic): {prod_signal_log_score(academic_desc):.4f}")
1139
+ print(f"prod_signal_log_score (empty): {prod_signal_log_score(empty_desc):.4f}")
1140
+
1141
+ skills_pre_llm = [
1142
+ {"name": "BM25", "proficiency": "advanced", "endorsements": 10, "duration_months": 36},
1143
+ {"name": "XGBoost", "proficiency": "advanced", "endorsements": 8, "duration_months": 24},
1144
+ ]
1145
+ skills_llm_only = [
1146
+ {"name": "LangChain", "proficiency": "advanced", "endorsements": 2, "duration_months": 6},
1147
+ {"name": "Prompt Engineering", "proficiency": "intermediate", "endorsements": 1, "duration_months": 4},
1148
+ ]
1149
+ print(f"\nlangchain_dabbler_score (pre-LLM): {langchain_dabbler_score(skills_pre_llm):.4f}")
1150
+ print(f"langchain_dabbler_score (LLM-only): {langchain_dabbler_score(skills_llm_only):.4f}")
1151
+
1152
+
1153
+ skills_cv = [
1154
+ {"name": "OpenCV", "proficiency": "advanced", "endorsements": 30, "duration_months": 36},
1155
+ {"name": "YOLO", "proficiency": "advanced", "endorsements": 20, "duration_months": 30},
1156
+ ]
1157
+ skills_ir = [
1158
+ {"name": "FAISS", "proficiency": "advanced", "endorsements": 15, "duration_months": 24},
1159
+ {"name": "BM25", "proficiency": "advanced", "endorsements": 10, "duration_months": 18},
1160
+ ]
1161
+ print(f"\ncv_specialist_score (CV dominant): {cv_specialist_score(skills_cv):.4f}")
1162
+ print(f"cv_specialist_score (IR focused): {cv_specialist_score(skills_ir):.4f}")
1163
+
1164
+ print("\n=== Testing Consistency Checks ===\n")
1165
+
1166
+
1167
+ base = {
1168
+ "candidate_id": "CAND_TEST001",
1169
+ "profile": {"years_of_experience": 5.0, "location": "Bangalore", "country": "India",
1170
+ "current_title": "ML Engineer", "current_company": "Startup",
1171
+ "current_company_size": "11-50", "current_industry": "Technology"},
1172
+ "career_history": [{"company": "Startup", "title": "ML Engineer",
1173
+ "start_date": "2021-01-01", "end_date": None,
1174
+ "duration_months": 36, "is_current": True,
1175
+ "industry": "Technology", "company_size": "11-50",
1176
+ "description": "Deployed production ranking pipeline."}],
1177
+ "skills": [{"name": "Python", "proficiency": "advanced", "endorsements": 10, "duration_months": 36}],
1178
+ "redrob_signals": {
1179
+ "signup_date": "2021-01-01", "last_active_date": "2025-12-01",
1180
+ "recruiter_response_rate": 0.8, "open_to_work_flag": True,
1181
+ "connection_count": 100, "search_appearance_30d": 50,
1182
+ "endorsements_received": 10, "notice_period_days": 30,
1183
+ "expected_salary_range_inr_lpa": {"min": 20.0, "max": 40.0},
1184
+ "github_activity_score": 75, "skill_assessment_scores": {},
1185
+ },
1186
+ }
1187
+
1188
+ print(f"c1 (clean): {c1_timeline_impossibility(base)}")
1189
+ print(f"c2 (clean): {c2_signup_anomaly(base)}")
1190
+ print(f"c3 (clean): {c3_salary_inversion(base)}")
1191
+ print(f"c4 (clean): {c4_assessment_contradiction(base)}")
1192
+ print(f"c5 (clean): {c5_engagement_mismatch(base, bm25_score=10.0, median_bm25=5.0)}")
1193
+ print(f"consistency_score (clean): {consistency_score(base, bm25_score=10.0, median_bm25=5.0)}")
1194
+
1195
+ # Inject violations one at a time
1196
+ import copy
1197
+ v1 = copy.deepcopy(base)
1198
+ v1["skills"][0]["duration_months"] = 999
1199
+ print(f"\nc1 (timeline violation): {c1_timeline_impossibility(v1)}")
1200
+
1201
+ v2 = copy.deepcopy(base)
1202
+ v2["redrob_signals"]["signup_date"] = "2099-01-01"
1203
+ print(f"c2 (signup anomaly): {c2_signup_anomaly(v2)}")
1204
+
1205
+ v3 = copy.deepcopy(base)
1206
+ v3["redrob_signals"]["expected_salary_range_inr_lpa"] = {"min": 50.0, "max": 10.0}
1207
+ print(f"c3 (salary inversion): {c3_salary_inversion(v3)}")
1208
+
1209
+ v4 = copy.deepcopy(base)
1210
+ v4["redrob_signals"]["skill_assessment_scores"] = {"python": 12.0}
1211
+ print(f"c4 (assessment contradiction): {c4_assessment_contradiction(v4)}")
1212
+
1213
+ v5 = copy.deepcopy(base)
1214
+ v5["redrob_signals"]["connection_count"] = 0
1215
+ v5["redrob_signals"]["search_appearance_30d"] = 0
1216
+ v5["redrob_signals"]["endorsements_received"] = 0
1217
+ print(f"c5 (engagement mismatch): {c5_engagement_mismatch(v5, bm25_score=10.0, median_bm25=5.0)}")
src/jd_parser.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ jd_parser.py
3
+
4
+ Extracts a structured JDConfig from data/skill_aliases.json.
5
+ All downstream modules import parse_jd() — never rebuild this object at runtime.
6
+
7
+ No network calls. No datetime.now(). Pure parsing only.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from dataclasses import dataclass, field
15
+ from typing import Dict, List, Set
16
+
17
+
18
+ @dataclass
19
+ class JDConfig:
20
+ """
21
+ Structured representation of the Job Description requirements.
22
+ Populated from data/skill_aliases.json, which is the authoritative taxonomy.
23
+ """
24
+ # Hard requirements (3x BM25 query weight) — dict: canonical_name -> alias set
25
+ hard_requirements: Dict[str, List[str]] = field(default_factory=dict)
26
+
27
+ # Preferred requirements (1x weight)
28
+ preferred_requirements: Dict[str, List[str]] = field(default_factory=dict)
29
+
30
+ # Negative signal skill groups (by group name -> alias list)
31
+ negative_signals: Dict[str, List[str]] = field(default_factory=dict)
32
+
33
+ # Production-context pass B keywords (per Section 3 of architecture)
34
+ production_keywords: List[str] = field(default_factory=list)
35
+
36
+ # Rare-term safety net (per Section 3 of architecture)
37
+ rare_terms: List[str] = field(default_factory=list)
38
+
39
+ # All aliases flattened for fast membership checks
40
+ all_hard_aliases: Set[str] = field(default_factory=set)
41
+ all_preferred_aliases: Set[str] = field(default_factory=set)
42
+ all_negative_aliases: Set[str] = field(default_factory=set)
43
+
44
+ def get_all_query_terms(self) -> List[str]:
45
+ """Return all hard + preferred aliases for BM25 Pass A query."""
46
+ terms = []
47
+ for aliases in self.hard_requirements.values():
48
+ terms.extend(aliases)
49
+ for aliases in self.preferred_requirements.values():
50
+ terms.extend(aliases)
51
+ return list(set(terms))
52
+
53
+ def hard_req_names(self) -> List[str]:
54
+ """Canonical names for the hard requirements (for coverage scoring)."""
55
+ return list(self.hard_requirements.keys())
56
+
57
+ def preferred_req_names(self) -> List[str]:
58
+ return list(self.preferred_requirements.keys())
59
+
60
+
61
+ def parse_jd(skill_aliases_path: str) -> JDConfig:
62
+ """
63
+ Parse data/skill_aliases.json into a JDConfig object.
64
+
65
+ Args:
66
+ skill_aliases_path: Absolute or relative path to skill_aliases.json.
67
+
68
+ Returns:
69
+ JDConfig with all fields populated.
70
+
71
+ Raises:
72
+ FileNotFoundError: If the aliases file doesn't exist.
73
+ ValueError: If the file is malformed.
74
+ """
75
+ if not os.path.isfile(skill_aliases_path):
76
+ raise FileNotFoundError(
77
+ f"skill_aliases.json not found at: {skill_aliases_path}"
78
+ )
79
+
80
+ with open(skill_aliases_path, "r", encoding="utf-8") as f:
81
+ raw = json.load(f)
82
+
83
+ jd = JDConfig()
84
+
85
+ # Parse JD requirements section
86
+ jd_reqs = raw.get("jd_requirements", {})
87
+ for canonical_name, req_data in jd_reqs.items():
88
+ req_type = req_data.get("type", "preferred")
89
+ aliases = [a.lower().strip() for a in req_data.get("aliases", [])]
90
+
91
+ if req_type == "hard_requirement":
92
+ jd.hard_requirements[canonical_name] = aliases
93
+ jd.all_hard_aliases.update(aliases)
94
+ else:
95
+ # "preferred" and any other type treated as preferred
96
+ jd.preferred_requirements[canonical_name] = aliases
97
+ jd.all_preferred_aliases.update(aliases)
98
+
99
+ # Parse negative signals section
100
+ neg = raw.get("negative_signals", {})
101
+ for group_name, alias_list in neg.items():
102
+ if group_name.startswith("_"):
103
+ continue # skip comment keys
104
+ jd.negative_signals[group_name] = [a.lower().strip() for a in alias_list]
105
+ jd.all_negative_aliases.update(a.lower().strip() for a in alias_list)
106
+
107
+ # Production keywords for BM25 Pass B (Section 3, architecture doc)
108
+ # These are hardcoded from the architecture spec — not configurable
109
+ jd.production_keywords = [
110
+ "deployed", "scale", "serving", "latency",
111
+ "production", "inference", "throughput", "real-time",
112
+ "pipeline", "distributed"
113
+ ]
114
+
115
+ # Rare-term safety net (Section 3, architecture doc)
116
+ jd.rare_terms = ["pinecone", "lambdarank"]
117
+
118
+ return jd
119
+
120
+
121
+ def hard_req_coverage_score(candidate: dict, jd_config: JDConfig) -> float:
122
+ """
123
+ Compute fraction of hard requirements covered by candidate's skills.
124
+
125
+ A hard requirement is "covered" if any of its aliases appears (case-insensitive)
126
+ in the candidate's skill names. Falls back gracefully on missing/empty skills.
127
+
128
+ Schema fields read: skills[].name
129
+
130
+ Returns: float in [0.0, 1.0]
131
+ """
132
+ skills = candidate.get("skills", [])
133
+ if not skills or not jd_config.hard_requirements:
134
+ return 0.0
135
+
136
+ # Build lowercase set of candidate skill names
137
+ candidate_skill_names: Set[str] = set()
138
+ for s in skills:
139
+ name = s.get("name", "")
140
+ if name:
141
+ candidate_skill_names.add(name.lower().strip())
142
+
143
+ # Also scan career_history descriptions for alias presence
144
+ career_text = " ".join(
145
+ (ch.get("description", "") or "").lower()
146
+ for ch in candidate.get("career_history", [])
147
+ )
148
+
149
+ covered = 0
150
+ total = len(jd_config.hard_requirements)
151
+
152
+ for canonical_name, aliases in jd_config.hard_requirements.items():
153
+ # Check skill name match first, then description match
154
+ if any(alias in candidate_skill_names for alias in aliases):
155
+ covered += 1
156
+ elif any(alias in career_text for alias in aliases):
157
+ covered += 1
158
+
159
+ return covered / total if total > 0 else 0.0
160
+
161
+
162
+ if __name__ == "__main__":
163
+ import sys
164
+
165
+ base_dir = os.path.dirname(os.path.abspath(__file__))
166
+ aliases_path = os.path.join(base_dir, "data", "skill_aliases.json")
167
+
168
+ jd = parse_jd(aliases_path)
169
+
170
+ print("=== JDConfig ===")
171
+ print(f"\nHard Requirements ({len(jd.hard_requirements)}):")
172
+ for name, aliases in jd.hard_requirements.items():
173
+ print(f" {name}: {len(aliases)} aliases")
174
+
175
+ print(f"\nPreferred Requirements ({len(jd.preferred_requirements)}):")
176
+ for name, aliases in jd.preferred_requirements.items():
177
+ print(f" {name}: {len(aliases)} aliases")
178
+
179
+ print(f"\nNegative Signal Groups ({len(jd.negative_signals)}):")
180
+ for group, aliases in jd.negative_signals.items():
181
+ print(f" {group}: {len(aliases)} aliases")
182
+
183
+ print(f"\nProduction Keywords ({len(jd.production_keywords)}): {jd.production_keywords}")
184
+ print(f"Rare Terms ({len(jd.rare_terms)}): {jd.rare_terms}")
185
+ print(f"\nTotal hard aliases (flat set): {len(jd.all_hard_aliases)}")
186
+ print(f"Total preferred aliases (flat set): {len(jd.all_preferred_aliases)}")
187
+ print(f"Total query terms (Pass A): {len(jd.get_all_query_terms())}")
src/rank.py ADDED
@@ -0,0 +1,713 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import argparse
3
+ import json
4
+ import logging
5
+ import math
6
+ import os
7
+ import pickle
8
+ import sys
9
+ import time
10
+ from datetime import datetime
11
+ from typing import Dict, List, Optional, Tuple
12
+ _SRC_DIR = os.path.dirname(os.path.abspath(__file__))
13
+ _PROJECT_ROOT = os.path.dirname(_SRC_DIR)
14
+ _SCRIPTS_DIR = os.path.join(_PROJECT_ROOT, "scripts")
15
+ for _p in [_SRC_DIR, _SCRIPTS_DIR, _PROJECT_ROOT]:
16
+ if _p not in sys.path:
17
+ sys.path.insert(0, _p)
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+ from rank_bm25 import BM25Okapi
22
+
23
+ def setup_logging(base_dir: str) -> logging.Logger:
24
+ """Set up file + console logging."""
25
+ logs_dir = os.path.join(base_dir, "logs")
26
+ os.makedirs(logs_dir, exist_ok=True)
27
+
28
+ timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
29
+ log_file = os.path.join(logs_dir, f"rank_{timestamp}.log")
30
+
31
+ logger = logging.getLogger("rank")
32
+ logger.setLevel(logging.DEBUG)
33
+
34
+
35
+ fh = logging.FileHandler(log_file, encoding="utf-8")
36
+ fh.setLevel(logging.DEBUG)
37
+ fh.setFormatter(logging.Formatter(
38
+ "%(asctime)s %(levelname)s [%(name)s] %(message)s",
39
+ datefmt="%H:%M:%S"
40
+ ))
41
+
42
+
43
+ ch = logging.StreamHandler(sys.stdout)
44
+ ch.setLevel(logging.INFO)
45
+ ch.setFormatter(logging.Formatter(
46
+ "%(asctime)s %(levelname)s %(message)s",
47
+ datefmt="%H:%M:%S"
48
+ ))
49
+
50
+ logger.addHandler(fh)
51
+ logger.addHandler(ch)
52
+
53
+ logger.info("Log file: %s", log_file)
54
+ return logger
55
+
56
+ def load_artifacts(precomputed_dir: str, logger: logging.Logger):
57
+ """Load BM25 scorer, candidate IDs, and LightGBM model.
58
+
59
+ Tries fast NumPy / native-format artifacts first; falls back to pickle
60
+ if the fast artifacts haven't been built yet (backward-compatible).
61
+ """
62
+
63
+ from retrieval import load_numpy_bm25_artifacts
64
+ bm25 = load_numpy_bm25_artifacts(precomputed_dir)
65
+ if bm25 is not None:
66
+ logger.info("Stage 0: NumpyBM25 loaded (fast path)")
67
+ else:
68
+ bm25_path = os.path.join(precomputed_dir, "bm25_index.pkl")
69
+ if not os.path.isfile(bm25_path):
70
+ logger.error("Missing artifact: %s — run precompute.py first", bm25_path)
71
+ sys.exit(1)
72
+ with open(bm25_path, "rb") as f:
73
+ bm25 = pickle.load(f)
74
+ logger.info("Stage 0: BM25Okapi loaded (legacy pickle path)")
75
+
76
+
77
+ ids_path = os.path.join(precomputed_dir, "candidate_ids.pkl")
78
+ if not os.path.isfile(ids_path):
79
+ logger.error("Missing artifact: %s — run precompute.py first", ids_path)
80
+ sys.exit(1)
81
+ with open(ids_path, "rb") as f:
82
+ candidate_ids = pickle.load(f)
83
+
84
+
85
+ lgbm_txt = os.path.join(precomputed_dir, "lgbm_model.txt")
86
+ lgbm_pkl = os.path.join(precomputed_dir, "lgbm_model.pkl")
87
+ model = None
88
+ if os.path.isfile(lgbm_txt):
89
+ try:
90
+ import lightgbm as lgb
91
+ t0 = time.time()
92
+ model = lgb.Booster(model_file=lgbm_txt)
93
+ logger.info("Stage 0: LightGBM loaded from native text (%.2f s)", time.time() - t0)
94
+ except Exception as exc:
95
+ logger.warning("lgbm native load failed (%s), falling back to pickle", exc)
96
+ if model is None:
97
+ if not os.path.isfile(lgbm_pkl):
98
+ logger.error("Missing artifact: %s — run precompute.py first", lgbm_pkl)
99
+ sys.exit(1)
100
+ with open(lgbm_pkl, "rb") as f:
101
+ model = pickle.load(f)
102
+ logger.info("Stage 0: LightGBM loaded from pickle (legacy path)")
103
+
104
+
105
+ static_path = os.path.join(precomputed_dir, "static_features.pkl")
106
+ static_features = None
107
+ if os.path.isfile(static_path):
108
+ try:
109
+ t0 = time.time()
110
+ with open(static_path, "rb") as f:
111
+ static_features = pickle.load(f)
112
+ logger.info("Stage 0: Loaded static features (%d candidates) in %.2fs", len(static_features), time.time() - t0)
113
+ except Exception as exc:
114
+ logger.warning("static_features.pkl load failed (%s), falling back to live calculation", exc)
115
+ else:
116
+ logger.warning("static_features.pkl not found — falling back to live calculation")
117
+
118
+ logger.info(
119
+ "Artifacts loaded: BM25 scorer (%s, %d candidates), LightGBM model",
120
+ type(bm25).__name__,
121
+ len(candidate_ids),
122
+ )
123
+ return bm25, candidate_ids, model, static_features
124
+
125
+
126
+ def load_stage1_candidates(
127
+ candidates_path: str,
128
+ stage1_ids: List[str],
129
+ logger: logging.Logger,
130
+ ) -> Tuple[List[dict], int]:
131
+ """
132
+ Stream-read candidates.jsonl and return only Stage 1 candidates.
133
+ Defensive against malformed records, missing fields, null values.
134
+
135
+ Returns:
136
+ (candidate_list, malformed_count)
137
+ """
138
+ stage1_set = set(stage1_ids)
139
+ found: Dict[str, dict] = {}
140
+ malformed_count = 0
141
+
142
+ with open(candidates_path, "r", encoding="utf-8") as f:
143
+ for line_num, line in enumerate(f, 1):
144
+ line = line.strip()
145
+ if not line:
146
+ continue
147
+ try:
148
+ c = json.loads(line)
149
+ except json.JSONDecodeError as e:
150
+ malformed_count += 1
151
+ logger.warning("Malformed JSON at line %d: %s", line_num, e)
152
+ continue
153
+
154
+ cid = c.get("candidate_id")
155
+ if cid and cid in stage1_set:
156
+ found[cid] = c
157
+ if len(found) == len(stage1_set):
158
+ break
159
+
160
+ if malformed_count > 0:
161
+ logger.warning("Skipped %d malformed JSONL lines during loading", malformed_count)
162
+
163
+ missing = stage1_set - set(found.keys())
164
+ if missing:
165
+ logger.warning(
166
+ "%d stage1 candidates not found in JSONL: %s...",
167
+ len(missing), list(missing)[:5]
168
+ )
169
+
170
+
171
+ ordered = [found[cid] for cid in stage1_ids if cid in found]
172
+ logger.info(
173
+ "Loaded %d stage1 candidates (%d missing, %d malformed)",
174
+ len(ordered), len(missing), malformed_count
175
+ )
176
+ return ordered, malformed_count
177
+
178
+ def load_stage1_candidates_fast(
179
+ candidates_path: str,
180
+ stage1_ids: List[str],
181
+ offsets: Dict[str, int],
182
+ logger: logging.Logger,
183
+ ) -> Tuple[List[dict], int]:
184
+ """
185
+ Load Stage 1 candidate records using a precomputed byte-offset index.
186
+
187
+ Instead of streaming all 487 MB of candidates.jsonl, performs one
188
+ f.seek() + f.readline() per candidate. For ~8500 candidates this
189
+ reads ~43 MB total instead of 487 MB, reducing Stage 2 from ~4 s
190
+ to ~0.1–0.3 s.
191
+
192
+ Returns:
193
+ (candidate_list, malformed_count)
194
+ """
195
+ ordered: List[dict] = []
196
+ malformed_count = 0
197
+ missing: List[str] = []
198
+
199
+ with open(candidates_path, "rb") as f:
200
+ for cid in stage1_ids:
201
+ offset = offsets.get(cid)
202
+ if offset is None:
203
+ missing.append(cid)
204
+ continue
205
+ f.seek(offset)
206
+ raw = f.readline()
207
+ try:
208
+ c = json.loads(raw.decode("utf-8", errors="ignore").strip())
209
+ ordered.append(c)
210
+ except json.JSONDecodeError as exc:
211
+ logger.warning("Malformed record at offset %d for %s: %s", offset, cid, exc)
212
+ malformed_count += 1
213
+
214
+ if missing:
215
+ logger.warning(
216
+ "%d stage1 candidates not in offset index: %s ...",
217
+ len(missing), missing[:5],
218
+ )
219
+ logger.info(
220
+ "Loaded %d stage1 candidates via offset index (%d missing, %d malformed)",
221
+ len(ordered), len(missing), malformed_count,
222
+ )
223
+ return ordered, malformed_count
224
+
225
+
226
+ def extract_features_for_ranking(
227
+ candidates: List[dict],
228
+ jd_config,
229
+ bm25_scores: Dict[str, float],
230
+ stage1_bm25_median: float,
231
+ logger: logging.Logger,
232
+ static_features: Optional[Dict[str, Dict[str, float]]] = None,
233
+ ) -> Tuple[np.ndarray, List[str], Dict[str, float]]:
234
+ """
235
+ Extract the 22-feature matrix for all Stage 1 candidates.
236
+
237
+ Returns:
238
+ (X: np.ndarray[N, 22], ordered_ids: List[str], consistency_map: Dict[str, float])
239
+ """
240
+ from features import build_feature_vector, FEATURE_COLUMNS
241
+
242
+ feature_rows = []
243
+ ordered_ids = []
244
+ consistency_map = {}
245
+ failed_count = 0
246
+
247
+ for candidate in candidates:
248
+ cid = candidate.get("candidate_id", "UNKNOWN")
249
+ bm25_score = bm25_scores.get(cid, 0.0)
250
+
251
+ try:
252
+ fv = build_feature_vector(
253
+ candidate, jd_config,
254
+ bm25_score=bm25_score,
255
+ stage1_bm25_median=stage1_bm25_median,
256
+ precomputed_static=static_features.get(cid) if static_features else None
257
+ )
258
+ row = [fv[col] for col in FEATURE_COLUMNS]
259
+ consistency_map[cid] = float(fv.get("consistency_score", 1.0))
260
+ except Exception as e:
261
+ logger.warning("Feature extraction failed for %s: %s", cid, e)
262
+ row = [0.0] * len(FEATURE_COLUMNS)
263
+ consistency_map[cid] = 1.0
264
+ failed_count += 1
265
+
266
+ feature_rows.append(row)
267
+ ordered_ids.append(cid)
268
+
269
+ if failed_count > 0:
270
+ logger.warning("Feature extraction failed for %d candidates (zeroed out)", failed_count)
271
+
272
+ X = np.array(feature_rows, dtype=np.float32)
273
+ logger.info("Feature matrix: shape=%s", X.shape)
274
+ return X, ordered_ids, consistency_map
275
+
276
+ def run_lightgbm_inference(
277
+ model,
278
+ X: np.ndarray,
279
+ ordered_ids: List[str],
280
+ logger: logging.Logger,
281
+ ) -> Dict[str, float]:
282
+ """
283
+ Run LightGBM predict on the feature matrix.
284
+
285
+ Returns:
286
+ {candidate_id: lgbm_score}
287
+ """
288
+ t0 = time.time()
289
+ raw_scores = model.predict(X)
290
+ elapsed = time.time() - t0
291
+ logger.info(
292
+ "LightGBM inference: %d candidates in %.2fs", len(ordered_ids), elapsed
293
+ )
294
+ return {cid: float(score) for cid, score in zip(ordered_ids, raw_scores)}
295
+
296
+ def _normalize_scores(top_100_raw: List[Tuple[str, float]], logger: logging.Logger) -> List[Tuple[str, float, int]]:
297
+ """
298
+ Apply min-max normalization to raw scores and assign ranks 1..N.
299
+ Returns: List of (candidate_id, score, rank) sorted by rank.
300
+ """
301
+ if not top_100_raw:
302
+ return []
303
+
304
+ top_scores = [s for _, s in top_100_raw]
305
+ score_min = top_scores[-1]
306
+ score_max = top_scores[0]
307
+ score_range = score_max - score_min
308
+
309
+ result = []
310
+ prev_normalized = None
311
+
312
+ for rank, (cid, raw_score) in enumerate(top_100_raw, 1):
313
+ if score_range > 0:
314
+ normalized = 0.01 + 0.99 * (raw_score - score_min) / score_range
315
+ else:
316
+ normalized = 1.0 - (rank - 1) / 99.0
317
+
318
+
319
+ if prev_normalized is not None and normalized > prev_normalized + 1e-9:
320
+
321
+ logger.error("MONOTONICITY VIOLATION at rank %d", rank)
322
+ prev_normalized = normalized
323
+ result.append((cid, normalized, rank))
324
+
325
+ logger.info("Top 100 selected: score range [%.6f, %.6f]",
326
+ result[-1][1], result[0][1])
327
+ return result
328
+
329
+ def sort_and_enforce_monotonicity(
330
+ lgbm_scores: Dict[str, float],
331
+ logger: logging.Logger,
332
+ ) -> List[Tuple[str, float, int]]:
333
+ """
334
+ Sort candidates by score descending. Break ties by ascending candidate_id.
335
+ Assign ranks 1..N.
336
+
337
+ Returns:
338
+ List of (candidate_id, score, rank) sorted by rank.
339
+ """
340
+
341
+ sorted_candidates = sorted(
342
+ lgbm_scores.items(),
343
+ key=lambda x: (-x[1], x[0]),
344
+ )
345
+
346
+
347
+ top_100_raw = sorted_candidates[:100]
348
+ return _normalize_scores(top_100_raw, logger)
349
+
350
+
351
+ def assert_monotonicity(ranked: List[Tuple[str, float, int]]) -> None:
352
+ """
353
+ Explicit runtime assertion: scores must be monotonically non-increasing by rank.
354
+ This runs BEFORE writing the CSV — not just by sorting and hoping.
355
+
356
+ Raises AssertionError if violated.
357
+ """
358
+ for i in range(1, len(ranked)):
359
+ prev_score = ranked[i-1][1]
360
+ curr_score = ranked[i][1]
361
+ assert curr_score <= prev_score + 1e-9, (
362
+ f"Monotonicity violation: rank {i} score {prev_score:.8f} "
363
+ f"< rank {i+1} score {curr_score:.8f}"
364
+ )
365
+
366
+ def run_honeypot_audit(
367
+ top_100_candidates: List[dict],
368
+ feature_vectors: Dict[str, dict],
369
+ logger: logging.Logger,
370
+ ) -> None:
371
+ """
372
+ Section 8.1: Pre-Submission Honeypot Audit.
373
+ assert count(consistency_score < 0.25 in top_100) < 10.
374
+
375
+ If this assertion fails, rank.py exits non-zero.
376
+ """
377
+ low_consistency_count = sum(
378
+ 1 for c in top_100_candidates
379
+ if feature_vectors.get(c.get("candidate_id", ""), {}).get("consistency_score", 1.0) < 0.25
380
+ )
381
+
382
+ logger.info(
383
+ "Honeypot audit: %d of 100 candidates have consistency_score < 0.25",
384
+ low_consistency_count
385
+ )
386
+
387
+ if low_consistency_count >= 10:
388
+ logger.error(
389
+ "HONEYPOT AUDIT FAILED: %d candidates with consistency_score < 0.25 "
390
+ "(threshold: < 10). Pipeline is broken — honeypots bypassed filters.",
391
+ low_consistency_count
392
+ )
393
+ sys.exit(2)
394
+
395
+ logger.info("Honeypot audit PASSED.")
396
+
397
+
398
+ def run_diversity_audit(
399
+ top_100_candidates: List[dict],
400
+ feature_vectors: Dict[str, dict],
401
+ logger: logging.Logger,
402
+ ) -> None:
403
+ """
404
+ Section 8.2: Top 100 Diversity & Homogeneity Audit.
405
+ Uses validate_pipeline.check_top100_diversity.
406
+
407
+ If the check fails, rank.py exits non-zero with a clear error.
408
+ This is a BLOCKING check — not just a warning.
409
+ """
410
+ from validate_pipeline import check_top100_diversity, print_diversity_report
411
+
412
+ report = check_top100_diversity(
413
+ top_100_candidates,
414
+ feature_vectors,
415
+ max_signature_share=0.25,
416
+ max_single_company_share=0.30,
417
+ )
418
+
419
+ print_diversity_report(report)
420
+ logger.info(
421
+ "Diversity audit: %d distinct archetypes, max_company=%.1f%%, max_sig=%.1f%%",
422
+ report["n_distinct_signatures"],
423
+ report["most_common_company_share"] * 100,
424
+ report["most_common_signature_share"] * 100,
425
+ )
426
+
427
+ if not report["pass"]:
428
+ if report["flagged_companies"]:
429
+ logger.error(
430
+ "DIVERSITY AUDIT FAILED: company concentration too high: %s",
431
+ report["flagged_companies"]
432
+ )
433
+ if report["flagged_signatures"]:
434
+ logger.error(
435
+ "DIVERSITY AUDIT FAILED: archetype signature concentration too high: %s",
436
+ report["flagged_signatures"]
437
+ )
438
+ sys.exit(3)
439
+
440
+ logger.info("Diversity audit PASSED.")
441
+
442
+ def write_reasoning_trace(
443
+ top_30_traces: List[dict],
444
+ base_dir: str,
445
+ logger: logging.Logger,
446
+ ) -> None:
447
+ """Write reasoning_trace.jsonl for top 30 candidates."""
448
+ trace_path = os.path.join(base_dir, "reasoning_trace.jsonl")
449
+ with open(trace_path, "w", encoding="utf-8") as f:
450
+ for trace in top_30_traces:
451
+ f.write(json.dumps(trace, ensure_ascii=False) + "\n")
452
+ logger.info("Reasoning trace written: %s (%d entries)", trace_path, len(top_30_traces))
453
+ def pipeline_fn(
454
+ candidates: List[dict],
455
+ jd_config,
456
+ disable_consistency: bool = False,
457
+ disable_param_a: bool = False,
458
+ disable_features: bool = False,
459
+ ) -> List[str]:
460
+ """
461
+ Pipeline function compatible with validate_pipeline.run_ablation.
462
+ Accepts a list of candidate dicts + jd_config, returns ranked candidate_ids.
463
+
464
+ This runs the full in-memory pipeline (for small candidate sets).
465
+ """
466
+ from features import build_feature_vector, FEATURE_COLUMNS, consistency_score
467
+ from precompute import tokenize_candidate
468
+
469
+
470
+ corpus = [tokenize_candidate(c) for c in candidates]
471
+ bm25 = BM25Okapi(corpus)
472
+ cids = [c.get("candidate_id", f"IDX_{i}") for i, c in enumerate(candidates)]
473
+
474
+
475
+ from retrieval import tokenize_query
476
+ query_tokens = tokenize_query(jd_config.get_all_query_terms() + jd_config.production_keywords)
477
+ raw_scores = bm25.get_scores(query_tokens)
478
+ bm25_scores = {cids[i]: float(raw_scores[i]) for i in range(len(cids))}
479
+ median_bm25 = float(np.median(list(bm25_scores.values())))
480
+
481
+ feature_rows = []
482
+ for c in candidates:
483
+ cid = c.get("candidate_id", "")
484
+ bs = bm25_scores.get(cid, 0.0)
485
+
486
+ if disable_features:
487
+ row = [bs] + [0.0] * 21
488
+ else:
489
+ try:
490
+ fv = build_feature_vector(c, jd_config, bs, median_bm25)
491
+ if disable_consistency:
492
+ fv["consistency_score"] = 1.0
493
+ if disable_param_a:
494
+ fv["Param_A_Systems_Depth"] = 0.0
495
+ row = [fv[col] for col in FEATURE_COLUMNS]
496
+ except Exception:
497
+ row = [bs] + [0.0] * 21
498
+
499
+ feature_rows.append(row)
500
+ try:
501
+ import pickle
502
+ base = _PROJECT_ROOT
503
+ with open(os.path.join(base, "precomputed", "lgbm_model.pkl"), "rb") as f:
504
+ model = pickle.load(f)
505
+ X = np.array(feature_rows, dtype=np.float32)
506
+ scores = model.predict(X)
507
+ except Exception:
508
+
509
+ scores = np.array([bm25_scores.get(cid, 0.0) for cid in cids])
510
+
511
+ ranked = sorted(
512
+ zip(cids, scores.tolist()),
513
+ key=lambda x: (-x[1], x[0])
514
+ )
515
+ return [cid for cid, _ in ranked]
516
+ def main() -> None:
517
+ parser = argparse.ArgumentParser(
518
+ description="Redrob Candidate Ranking Pipeline",
519
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
520
+ )
521
+ parser.add_argument(
522
+ "--candidates",
523
+ required=True,
524
+ help="Path to candidates.jsonl",
525
+ )
526
+ parser.add_argument(
527
+ "--out",
528
+ default="./CTRL_COFFEE_REPEAT.csv",
529
+ help="Path for output CTRL_COFFEE_REPEAT.csv",
530
+ )
531
+ parser.add_argument(
532
+ "--base-dir",
533
+ default=None,
534
+ help="Base directory (defaults to directory containing rank.py)",
535
+ )
536
+ args = parser.parse_args()
537
+
538
+
539
+
540
+ script_dir = _PROJECT_ROOT
541
+ base_dir = os.path.abspath(args.base_dir) if args.base_dir else script_dir
542
+ candidates_path = os.path.abspath(args.candidates)
543
+ out_path = os.path.abspath(args.out)
544
+ precomputed_dir = os.path.join(base_dir, "precomputed")
545
+
546
+ logger = setup_logging(base_dir)
547
+ wall_start = time.time()
548
+
549
+ logger.info("=" * 60)
550
+ logger.info("REDROB RANKING PIPELINE")
551
+ logger.info("Candidates: %s", candidates_path)
552
+ logger.info("Output: %s", out_path)
553
+ logger.info("Base dir: %s", base_dir)
554
+ logger.info("=" * 60)
555
+ t0 = time.time()
556
+ bm25, candidate_ids, model, static_features = load_artifacts(precomputed_dir, logger)
557
+ logger.info("Stage 0 (load artifacts): %.2fs", time.time() - t0)
558
+
559
+ t1 = time.time()
560
+ from jd_parser import parse_jd
561
+ from retrieval import run_dual_pass_retrieval
562
+
563
+ jd_config = parse_jd(os.path.join(base_dir, "data", "skill_aliases.json"))
564
+ stage1_ids, bm25_scores = run_dual_pass_retrieval(bm25, candidate_ids, jd_config)
565
+
566
+ stage1_bm25_scores_list = list(bm25_scores.values())
567
+ stage1_bm25_median = float(np.median(stage1_bm25_scores_list))
568
+
569
+ logger.info(
570
+ "Stage 1 (retrieval): %d candidates retrieved, median BM25=%.4f in %.2fs",
571
+ len(stage1_ids), stage1_bm25_median, time.time() - t1
572
+ )
573
+ t2 = time.time()
574
+ offsets_path = os.path.join(precomputed_dir, "candidate_offsets.pkl")
575
+ if os.path.isfile(offsets_path):
576
+ with open(offsets_path, "rb") as f:
577
+ candidate_offsets = pickle.load(f)
578
+ stage1_candidates, malformed_count = load_stage1_candidates_fast(
579
+ candidates_path, stage1_ids, candidate_offsets, logger
580
+ )
581
+ else:
582
+
583
+ logger.info("Stage 2: offset index not found — streaming full JSONL (slow)")
584
+ stage1_candidates, malformed_count = load_stage1_candidates(
585
+ candidates_path, stage1_ids, logger
586
+ )
587
+
588
+ logger.info(
589
+ "Stage 2 (load records): %d candidates loaded (%d malformed) in %.2f s",
590
+ len(stage1_candidates), malformed_count, time.time() - t2
591
+ )
592
+
593
+ t2b = time.time()
594
+ X, ordered_ids, consistency_map = extract_features_for_ranking(
595
+ stage1_candidates, jd_config, bm25_scores, stage1_bm25_median, logger,
596
+ static_features=static_features
597
+ )
598
+ logger.info("Stage 2b (features): %.2fs", time.time() - t2b)
599
+
600
+ t4 = time.time()
601
+ lgbm_scores = run_lightgbm_inference(model, X, ordered_ids, logger)
602
+
603
+
604
+ for cid in lgbm_scores:
605
+ lgbm_scores[cid] *= consistency_map.get(cid, 1.0)
606
+
607
+ logger.info("Stage 4 (LightGBM + multiplier): %.2fs", time.time() - t4)
608
+ t5 = time.time()
609
+ ranked_top100 = sort_and_enforce_monotonicity(lgbm_scores, logger)
610
+
611
+
612
+ assert_monotonicity(ranked_top100)
613
+ logger.info("Monotonicity assertion PASSED.")
614
+
615
+ assert len(ranked_top100) == 100, (
616
+ f"Expected exactly 100 candidates, got {len(ranked_top100)}"
617
+ )
618
+ logger.info("Count assertion PASSED: exactly 100 candidates.")
619
+
620
+ top100_ids = [cid for cid, _, _ in ranked_top100]
621
+
622
+ from features import build_feature_vector, FEATURE_COLUMNS
623
+ candidate_lookup: Dict[str, dict] = {
624
+ c.get("candidate_id"): c for c in stage1_candidates
625
+ }
626
+
627
+ feature_vectors: Dict[str, dict] = {}
628
+ for cid in top100_ids:
629
+ c = candidate_lookup.get(cid)
630
+ if c is None:
631
+ feature_vectors[cid] = {col: 0.0 for col in FEATURE_COLUMNS}
632
+ continue
633
+ bs = bm25_scores.get(cid, 0.0)
634
+ try:
635
+ feature_vectors[cid] = build_feature_vector(
636
+ c, jd_config, bs, stage1_bm25_median,
637
+ precomputed_static=static_features.get(cid) if static_features else None
638
+ )
639
+ except Exception:
640
+ feature_vectors[cid] = {col: 0.0 for col in FEATURE_COLUMNS}
641
+
642
+ top100_candidates = [candidate_lookup[cid] for cid in top100_ids if cid in candidate_lookup]
643
+
644
+ run_honeypot_audit(top100_candidates, feature_vectors, logger)
645
+
646
+ run_diversity_audit(top100_candidates, feature_vectors, logger)
647
+ t5r = time.time()
648
+ from reasoning import ReasoningCompiler
649
+
650
+ all_lgbm_scores = [lgbm_scores[cid] for cid in top100_ids if cid in lgbm_scores]
651
+ compiler = ReasoningCompiler(jd_config, all_scores=all_lgbm_scores)
652
+
653
+ reasoning_texts: Dict[str, str] = {}
654
+ reasoning_traces: List[dict] = []
655
+
656
+ for cid, norm_score, rank in ranked_top100:
657
+ c = candidate_lookup.get(cid, {"candidate_id": cid})
658
+ fv = feature_vectors.get(cid, {col: 0.0 for col in FEATURE_COLUMNS})
659
+ raw_lgbm = lgbm_scores.get(cid, 0.0)
660
+
661
+ if rank <= 30:
662
+ trace = compiler.compile_trace(c, fv, raw_lgbm, rank)
663
+ reasoning_traces.append(trace)
664
+ reasoning_texts[cid] = trace["reasoning"]
665
+ else:
666
+ reasoning_texts[cid] = compiler.compile(c, fv, raw_lgbm, rank)
667
+
668
+ logger.info("Stage 5 (reasoning): %.2fs", time.time() - t5r)
669
+ write_reasoning_trace(reasoning_traces, base_dir, logger)
670
+ rows = []
671
+ for cid, norm_score, rank in ranked_top100:
672
+ rows.append({
673
+ "candidate_id": cid,
674
+ "rank": rank,
675
+ "score": round(norm_score, 6),
676
+ "reasoning": reasoning_texts.get(cid, ""),
677
+ })
678
+
679
+ df = pd.DataFrame(rows, columns=["candidate_id", "rank", "score", "reasoning"])
680
+ assert len(df) == 100, f"DataFrame has {len(df)} rows, expected 100"
681
+ assert list(df.columns) == ["candidate_id", "rank", "score", "reasoning"], \
682
+ f"Unexpected columns: {list(df.columns)}"
683
+ scores_arr = df["score"].values
684
+ for i in range(1, len(scores_arr)):
685
+ assert scores_arr[i] <= scores_arr[i-1] + 1e-9, (
686
+ f"DataFrame monotonicity violation at row {i}: "
687
+ f"{scores_arr[i-1]:.8f} -> {scores_arr[i]:.8f}"
688
+ )
689
+ logger.info("Final DataFrame monotonicity assertion PASSED.")
690
+ df.to_csv(out_path, index=False, encoding="utf-8")
691
+ logger.info("Submission CSV written: %s", out_path)
692
+ wall_elapsed = time.time() - wall_start
693
+ logger.info("=" * 60)
694
+ logger.info("PIPELINE COMPLETE")
695
+ logger.info("Wall-clock time: %.2fs (limit: 300s)", wall_elapsed)
696
+ logger.info("Output: %s", out_path)
697
+ logger.info("Candidates ranked: 100")
698
+ logger.info("=" * 60)
699
+
700
+ if wall_elapsed > 300:
701
+ logger.error(
702
+ "TIMING VIOLATION: Pipeline took %.1fs > 300s limit", wall_elapsed
703
+ )
704
+ sys.exit(4)
705
+ print("\n--- submission.csv (first 5 rows) ---")
706
+ print(df.head(5).to_string(index=False))
707
+ print(f"\nTotal rows: {len(df)}")
708
+ print(f"Score range: [{df['score'].min():.6f}, {df['score'].max():.6f}]")
709
+ print(f"Wall-clock: {wall_elapsed:.1f}s")
710
+
711
+
712
+ if __name__ == "__main__":
713
+ main()
src/reasoning.py ADDED
@@ -0,0 +1,689 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ reasoning.py
3
+
4
+ The ReasoningCompiler per Section 7 of the architecture document.
5
+
6
+ Generates deterministic, fact-grounded reasoning text for each ranked candidate.
7
+
8
+ Pre-write audits:
9
+ 1. Numeric Regex Audit: every number mentioned must exist in the candidate's JSON
10
+ 2. N-Gram Collision: difflib.SequenceMatcher to guarantee structural variation
11
+
12
+ Tone controlled by score percentile in the local score distribution.
13
+ No network calls. No LLM. Pure template + fact extraction.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import difflib
19
+ import hashlib
20
+ import json
21
+ import math
22
+ import re
23
+ from typing import Any, Dict, List, Optional, Tuple
24
+
25
+ from features import FEATURE_COLUMNS
26
+
27
+
28
+
29
+ _LOW_CRED_VARIANTS: List[str] = [
30
+ "high ratio of unverified advanced skill claims vs assessed scores",
31
+ "advanced-level skills listed without corroborating platform assessment data",
32
+ "claimed proficiency levels outpace platform-verified evidence on file",
33
+ "self-reported expert-level skills exceed available assessment validation",
34
+ "skill credibility gap: multiple advanced claims lack supporting assessment scores",
35
+ ]
36
+
37
+
38
+ def _select_low_cred_variant(candidate_id: str) -> str:
39
+ """Return a deterministic phrasing variant for the low_credibility concern.
40
+
41
+ Uses the first 8 hex digits of MD5(candidate_id) as a stable hash —
42
+ identical candidate_id always maps to the same variant across Python
43
+ interpreter restarts and across machines.
44
+ """
45
+ digest = int(
46
+ hashlib.md5(candidate_id.encode("utf-8", errors="ignore")).hexdigest()[:8], 16
47
+ )
48
+ return _LOW_CRED_VARIANTS[digest % len(_LOW_CRED_VARIANTS)]
49
+
50
+
51
+
52
+ # Percentile boundaries: top 10% = strong, 10-40% = positive, 40-70% = neutral,
53
+ # 70-90% = cautious, 90-100% = weak
54
+
55
+
56
+ _TONE_THRESHOLDS = [
57
+ (0.90, "strong"),
58
+ (0.60, "positive"),
59
+ (0.30, "neutral"),
60
+ (0.10, "cautious"),
61
+ (0.00, "weak"),
62
+ ]
63
+
64
+
65
+ def _get_tone(percentile: float) -> str:
66
+ """
67
+ Given a candidate's score percentile (0=worst, 1=best) among top-100,
68
+ return the tone label. Continuous transition — no rank-based cliffs.
69
+ """
70
+ for threshold, tone in _TONE_THRESHOLDS:
71
+ if percentile >= threshold:
72
+ return tone
73
+ return "weak"
74
+
75
+
76
+ _OPENING_BY_TONE = {
77
+ "strong": [
78
+ "Highly competitive profile with direct production experience in",
79
+ "Outstanding match: verified depth in",
80
+ "Top-tier candidate demonstrating hands-on expertise in",
81
+ ],
82
+ "positive": [
83
+ "Strong candidate showing relevant experience in",
84
+ "Well-qualified profile with demonstrated skills in",
85
+ "Solid match with measurable background in",
86
+ ],
87
+ "neutral": [
88
+ "Candidate presents relevant background in",
89
+ "Profile shows applicable experience touching",
90
+ "Partial alignment with job requirements, including",
91
+ ],
92
+ "cautious": [
93
+ "Limited but present signal in",
94
+ "Early-stage profile with some relevant exposure to",
95
+ "Candidate shows initial familiarity with",
96
+ ],
97
+ "weak": [
98
+ "Minimal alignment with target requirements;",
99
+ "Profile does not strongly match the core JD criteria;",
100
+ "Significant gaps identified relative to the job requirements;",
101
+ ],
102
+ }
103
+
104
+
105
+ def _extract_candidate_numbers(candidate: dict) -> set:
106
+ """
107
+ Extract all numeric values from a candidate's JSON (recursively).
108
+ Used by the numeric regex audit to verify any number we mention exists in the data.
109
+ """
110
+ numbers = set()
111
+ raw_json = json.dumps(candidate)
112
+ for match in re.finditer(r'\b(\d+(?:\.\d+)?)\b', raw_json):
113
+ numbers.add(match.group(1))
114
+ return numbers
115
+
116
+
117
+ def _numeric_regex_audit(text: str, candidate_numbers: set) -> Tuple[bool, List[str]]:
118
+ """
119
+ Numeric Regex Audit (Section 7).
120
+ Asserts every number in the generated text exists in the candidate's JSON.
121
+
122
+ Returns:
123
+ (passed: bool, violations: List[str])
124
+ """
125
+ text_numbers = set(re.findall(r'\b(\d+(?:\.\d+)?)\b', text))
126
+ violations = [n for n in text_numbers if n not in candidate_numbers]
127
+ return len(violations) == 0, violations
128
+
129
+
130
+ def _ngram_collision_check(
131
+ new_text: str,
132
+ existing_texts: List[str],
133
+ threshold: float = 0.65,
134
+ ) -> Tuple[bool, float]:
135
+ """
136
+ N-Gram Collision Check (Section 7).
137
+ Uses difflib.SequenceMatcher to guarantee structural variation.
138
+ Returns (passes, max_similarity).
139
+ A text fails if it's too similar to ANY previously generated text.
140
+ """
141
+ if not existing_texts:
142
+ return True, 0.0
143
+
144
+ max_sim = 0.0
145
+ for existing in existing_texts:
146
+ sim = difflib.SequenceMatcher(None, new_text, existing).ratio()
147
+ max_sim = max(max_sim, sim)
148
+
149
+ return max_sim < threshold, max_sim
150
+
151
+
152
+ def _get_hard_req_matches(candidate: dict, jd_config) -> List[str]:
153
+ """
154
+ Extract which hard requirements the candidate actually covers.
155
+ Returns list of canonical requirement names that matched.
156
+ """
157
+ from jd_parser import hard_req_coverage_score
158
+
159
+ skills = candidate.get("skills", []) or []
160
+ candidate_skill_names = {s.get("name", "").lower().strip() for s in skills}
161
+
162
+ career_text = " ".join(
163
+ (ch.get("description", "") or "").lower()
164
+ for ch in candidate.get("career_history", [])
165
+ )
166
+
167
+ matched = []
168
+ for canonical_name, aliases in jd_config.hard_requirements.items():
169
+ if any(alias in candidate_skill_names for alias in aliases):
170
+ matched.append(canonical_name)
171
+ elif any(alias in career_text for alias in aliases):
172
+ matched.append(canonical_name)
173
+
174
+ return matched
175
+
176
+
177
+ _JD_RELEVANT_CACHE: Dict[int, frozenset] = {}
178
+
179
+
180
+ def _build_jd_relevant_names(jd_config) -> frozenset:
181
+ """Return (and cache) the frozenset of lowercase JD-relevant skill names."""
182
+ key = id(jd_config)
183
+ if key not in _JD_RELEVANT_CACHE:
184
+ names: set = set()
185
+ for term in jd_config.get_all_query_terms():
186
+ names.add(term.lower().strip())
187
+ for aliases in jd_config.hard_requirements.values():
188
+ for alias in aliases:
189
+ names.add(alias.lower().strip())
190
+ _JD_RELEVANT_CACHE[key] = frozenset(names)
191
+ return _JD_RELEVANT_CACHE[key]
192
+
193
+
194
+ def _get_top_skills(candidate: dict, n: int = 3, jd_config=None) -> List[str]:
195
+ """Get top N skills, JD-relevant first then by tenure.
196
+
197
+ When jd_config is supplied fills n slots in two passes:
198
+ Pass 1 — JD-relevant skills sorted by duration_months DESC.
199
+ Pass 2 — non-relevant skills by duration_months DESC (backfill only).
200
+
201
+ The JD relevance set is memoised so this is O(1) after the first call
202
+ per jd_config instance — safe to call in a tight 8,533-candidate loop.
203
+
204
+ Falls back to pure tenure ranking when jd_config is None.
205
+ """
206
+ skills = candidate.get("skills", []) or []
207
+ if not skills:
208
+ return []
209
+
210
+ if jd_config is not None:
211
+ relevant_names = _build_jd_relevant_names(jd_config)
212
+ if relevant_names:
213
+ key_fn = lambda s: s.get("duration_months") or 0
214
+ relevant = sorted(
215
+ (s for s in skills if (s.get("name") or "").lower().strip() in relevant_names),
216
+ key=key_fn, reverse=True,
217
+ )
218
+ irrelevant = sorted(
219
+ (s for s in skills if (s.get("name") or "").lower().strip() not in relevant_names),
220
+ key=key_fn, reverse=True,
221
+ )
222
+ backfill_n = max(0, n - len(relevant[:n]))
223
+ combined = relevant[:n] + irrelevant[:backfill_n]
224
+ return [s.get("name", "") for s in combined[:n] if s.get("name")]
225
+
226
+ # fallback
227
+ sorted_skills = sorted(skills, key=lambda s: s.get("duration_months") or 0, reverse=True)
228
+ return [s.get("name", "") for s in sorted_skills[:n] if s.get("name")]
229
+
230
+
231
+
232
+ SKILL_JD_PHRASES = {
233
+ frozenset(["faiss", "milvus", "qdrant", "weaviate", "pinecone", "opensearch", "elasticsearch", "chroma"]):
234
+ "production vector search infrastructure ({matched})",
235
+ frozenset(["sentence transformers", "embeddings", "bge", "e5", "text embeddings", "dense retrieval"]):
236
+ "embedding model depth for semantic search ({matched})",
237
+ frozenset(["bm25", "information retrieval", "tf-idf", "tfidf", "lucene", "sparse retrieval"]):
238
+ "information retrieval foundation the JD centers on ({matched})",
239
+ frozenset(["fine-tuning llms", "lora", "qlora", "peft", "instruction tuning"]):
240
+ "LLM fine-tuning experience (preferred by JD) ({matched})",
241
+ frozenset(["hugging face transformers", "transformers", "sentence transformers"]):
242
+ "transformer model infrastructure ({matched})",
243
+ frozenset(["recommendation systems", "recommender systems", "collaborative filtering"]):
244
+ "recommendation system background applicable to the role ({matched})",
245
+ frozenset(["mlops", "kubeflow", "weights & biases", "mlflow"]):
246
+ "ML production operations experience ({matched})",
247
+ }
248
+
249
+ SKILL_COMBINED_PHRASES = {
250
+ frozenset(["faiss", "milvus", "qdrant", "weaviate", "pinecone", "opensearch", "elasticsearch", "chroma"]):
251
+ "production vector search infrastructure",
252
+ frozenset(["sentence transformers", "embeddings", "bge", "e5", "text embeddings", "dense retrieval"]):
253
+ "embedding model depth for semantic search",
254
+ frozenset(["bm25", "information retrieval", "tf-idf", "tfidf", "lucene", "sparse retrieval"]):
255
+ "classical IR foundation",
256
+ frozenset(["fine-tuning llms", "lora", "qlora", "peft", "instruction tuning"]):
257
+ "LLM fine-tuning experience",
258
+ frozenset(["hugging face transformers", "transformers", "sentence transformers"]):
259
+ "transformer model infrastructure",
260
+ frozenset(["recommendation systems", "recommender systems", "collaborative filtering"]):
261
+ "recommendation system background",
262
+ frozenset(["mlops", "kubeflow", "weights & biases", "mlflow"]):
263
+ "ML production operations experience",
264
+ }
265
+
266
+ def get_specific_jd_match(candidate: dict, jd_config=None) -> str:
267
+ skills = candidate.get("skills", []) or []
268
+ candidate_skills = {}
269
+ for s in skills:
270
+ name = s.get("name")
271
+ if name:
272
+ candidate_skills[name.lower().strip()] = name
273
+
274
+ matched_categories = []
275
+ matched_skills = []
276
+ used_skills = set()
277
+
278
+ for keys in SKILL_JD_PHRASES.keys():
279
+ found_skill = None
280
+ for k in keys:
281
+ if k in candidate_skills and k not in used_skills:
282
+ found_skill = candidate_skills[k]
283
+ used_skills.add(k)
284
+ break
285
+ if found_skill:
286
+ matched_categories.append(keys)
287
+ matched_skills.append(found_skill)
288
+
289
+ if not matched_categories:
290
+ from jd_parser import hard_req_coverage_score
291
+ coverage = hard_req_coverage_score(candidate, jd_config)
292
+ hard_req_coverage_pct = coverage * 100
293
+ return f"covers {hard_req_coverage_pct:.0f}% of JD hard requirements"
294
+
295
+ if len(matched_categories) == 1:
296
+ return SKILL_JD_PHRASES[matched_categories[0]].format(matched=matched_skills[0])
297
+
298
+ skills_str = " + ".join(matched_skills)
299
+ phrases = [SKILL_COMBINED_PHRASES[cat] for cat in matched_categories]
300
+ if len(phrases) == 2:
301
+ phrases_str = f"{phrases[0]} alongside {phrases[1]}"
302
+ else:
303
+ phrases_str = ", ".join(phrases[:-1]) + f" alongside {phrases[-1]}"
304
+ return f"{skills_str} combination — {phrases_str}"
305
+
306
+ def _get_severity_ranked_concern(
307
+ feature_vector: Dict[str, float],
308
+ candidate: dict,
309
+ ) -> Optional[str]:
310
+ """
311
+ Priority concern selection logic.
312
+ Evaluates in a strict order and returns the first matching concern.
313
+ """
314
+ # Priority 1 Notice period > 90 days
315
+ notice_days = candidate.get("redrob_signals", {}).get("notice_period_days")
316
+ if notice_days is not None:
317
+ try:
318
+ notice_days_int = int(float(notice_days))
319
+ if notice_days_int > 90:
320
+ return f"Notice period of {notice_days_int} days is significantly above the JD's preferred sub-thirty threshold — confirm whether buyout is feasible before advancing"
321
+ except (TypeError, ValueError):
322
+ pass
323
+
324
+ profile = candidate.get("profile", {}) or {}
325
+ location = profile.get("location") or "unknown location"
326
+ country = profile.get("country") or "unknown country"
327
+ is_india = country.lower().strip() in ["india", "in"]
328
+ willing_to_relocate = bool(candidate.get("redrob_signals", {}).get("willing_to_relocate", False))
329
+
330
+ # Priority 2: Outside India and unwilling to relocate
331
+ if not is_india and not willing_to_relocate:
332
+ return f"Based in {location}, {country} — outside the JD's India-only scope with no relocation willingness flagged. No visa sponsorship offered per JD"
333
+
334
+ # Priority 3: Outside India but willing to relocate
335
+ if not is_india and willing_to_relocate:
336
+ return f"Based in {location}, {country} — outside the JD's India-only scope, but relocation willingness is flagged; confirm transition feasibility"
337
+
338
+ # Priority 4: In India but outside Noida/Pune
339
+ if is_india:
340
+ loc_lower = location.lower()
341
+ if "noida" not in loc_lower and "pune" not in loc_lower:
342
+ return f"Based in {location} — outside the Noida/Pune preference zone; confirm relocation willingness before shortlisting"
343
+
344
+ # Priority 5: Langchain dabbler
345
+ if feature_vector.get("flag_langchain_dabbler", 0.0) > 0.5:
346
+ return "AI skill profile is weighted toward LLM-era tools without evidence of pre-LLM IR or ML fundamentals — a specific JD disqualifier"
347
+
348
+ # Priority 6: Consulting only
349
+ if feature_vector.get("flag_consulting_only", 0.0) > 0.5:
350
+ return "Career is predominantly at IT-services/consulting firms — the JD explicitly prefers product-company background"
351
+
352
+ # Priority 7: Title-desc mismatch
353
+ if feature_vector.get("flag_title_desc_mismatch", 0.0) > 0.5:
354
+ return "Job title and role descriptions show significant domain mismatch across career history — verify directly with candidate"
355
+
356
+ # Priority 8: Skill assessment score < 50
357
+ assessments = candidate.get("redrob_signals", {}).get("skill_assessment_scores") or {}
358
+ if isinstance(assessments, dict):
359
+ assessed_keys = {k.lower().strip(): (k, v) for k, v in assessments.items()}
360
+ for s in candidate.get("skills", []) or []:
361
+ prof = (s.get("proficiency") or "").lower().strip()
362
+ name = (s.get("name") or "").lower().strip()
363
+ if prof == "advanced" and name in assessed_keys:
364
+ orig_name, score = assessed_keys[name]
365
+ try:
366
+ score_val = float(score)
367
+ if score_val < 50:
368
+ return f"Claims advanced proficiency in {s.get('name')} but platform assessment score is {int(score_val)} out of one hundred — inconsistent with self-reported level"
369
+ except (TypeError, ValueError):
370
+ pass
371
+
372
+ # Priority 9: Capped Param_E credibility >= 5.0
373
+ if feature_vector.get("Param_E_Credibility", 0.0) >= 5.0:
374
+ return "High ratio of advanced skill claims relative to platform-verified assessment data on file"
375
+
376
+ return None
377
+
378
+
379
+ class ReasoningCompiler:
380
+ """
381
+ Generates deterministic, auditable reasoning text for ranked candidates.
382
+ Maintains state to enforce n-gram collision avoidance across all generated texts.
383
+ """
384
+
385
+ def __init__(self, jd_config, all_scores: List[float]):
386
+ """
387
+ Args:
388
+ jd_config: Parsed JDConfig.
389
+ all_scores: All LightGBM scores in the top-100 (for percentile calculation).
390
+ """
391
+ self.jd_config = jd_config
392
+ self.all_scores = sorted(all_scores)
393
+ self._generated_texts: List[str] = []
394
+ self._opening_rotation: Dict[str, int] = {
395
+ tone: 0 for tone in _OPENING_BY_TONE
396
+ }
397
+ self._last_template_idx: Optional[int] = None
398
+
399
+ def _score_to_percentile(self, score: float) -> float:
400
+ """Convert a score to its percentile in the local distribution."""
401
+ if not self.all_scores:
402
+ return 0.5
403
+ n = len(self.all_scores)
404
+ below = sum(1 for s in self.all_scores if s < score)
405
+ return below / n
406
+
407
+ def compile(
408
+ self,
409
+ candidate: dict,
410
+ feature_vector: Dict[str, float],
411
+ lgbm_score: float,
412
+ rank: int,
413
+ ) -> str:
414
+ """
415
+ Generate reasoning text for a candidate using one of 4 distinct templates.
416
+ """
417
+
418
+ stable_hash = int(
419
+ hashlib.md5(candidate.get("candidate_id", "").encode("utf-8", errors="ignore")).hexdigest()[:8], 16
420
+ )
421
+ template_idx = stable_hash % 4
422
+
423
+ if self._last_template_idx is not None and template_idx == self._last_template_idx:
424
+ template_idx = (template_idx + 1) % 4
425
+ self._last_template_idx = template_idx
426
+
427
+ jd_match = get_specific_jd_match(candidate, self.jd_config)
428
+ location = candidate.get("profile", {}).get("location") or "unknown location"
429
+ concern = _get_severity_ranked_concern(feature_vector, candidate)
430
+ _profile = candidate.get("profile") or {}
431
+ _signals = candidate.get("redrob_signals") or {}
432
+
433
+ yoe_raw = _profile.get("years_of_experience")
434
+ yoe_str = "0"
435
+ if yoe_raw is not None:
436
+ try:
437
+ yoe_float = float(yoe_raw)
438
+ if yoe_float > 0:
439
+ if yoe_float == int(yoe_float):
440
+ yoe_str = str(int(yoe_float))
441
+ else:
442
+ yoe_str = str(yoe_raw)
443
+ except (TypeError, ValueError):
444
+ pass
445
+
446
+ notice_raw = _signals.get("notice_period_days")
447
+ notice_str = "0"
448
+ if notice_raw is not None:
449
+ try:
450
+ notice_int = int(float(notice_raw))
451
+ notice_str = str(notice_int)
452
+ except (TypeError, ValueError):
453
+ pass
454
+
455
+ if template_idx == 0:
456
+ if concern:
457
+ reasoning = (
458
+ f"The candidate's profile demonstrates {jd_match}. "
459
+ f"With {yoe_str} years of experience, the candidate is based in {location} "
460
+ f"and is available in {notice_str} days. Primary concern: {concern}."
461
+ )
462
+ else:
463
+ reasoning = (
464
+ f"The candidate's profile demonstrates {jd_match}. "
465
+ f"With {yoe_str} years of experience, the candidate is based in {location} "
466
+ f"and is available in {notice_str} days."
467
+ )
468
+
469
+ elif template_idx == 1:
470
+ if concern:
471
+ reasoning = (
472
+ f"With {yoe_str} years of experience, the candidate is currently based in {location}. "
473
+ f"The profile demonstrates strong JD alignment, showing {jd_match}. "
474
+ f"Available in {notice_str} days, the primary concern is: {concern}."
475
+ )
476
+ else:
477
+ reasoning = (
478
+ f"With {yoe_str} years of experience, the candidate is currently based in {location}. "
479
+ f"The profile demonstrates strong JD alignment, showing {jd_match}. "
480
+ f"The candidate is available in {notice_str} days."
481
+ )
482
+
483
+ elif template_idx == 2:
484
+ if concern:
485
+ reasoning = (
486
+ f"The primary concern for this profile is {concern}. "
487
+ f"Despite this, the technical profile shows {jd_match}. "
488
+ f"The candidate has {yoe_str} years of experience, is based in {location}, "
489
+ f"and is available in {notice_str} days."
490
+ )
491
+ else:
492
+ reasoning = (
493
+ f"The technical profile shows {jd_match}. "
494
+ f"The candidate has {yoe_str} years of experience, is based in {location}, "
495
+ f"and is available in {notice_str} days."
496
+ )
497
+
498
+ else:
499
+ github_raw = _signals.get("github_activity_score")
500
+ verifiable_point = "strong technical skills"
501
+ if github_raw is not None:
502
+ try:
503
+ github_float = float(github_raw)
504
+ if github_float > 30:
505
+ github_score_str = str(int(github_float)) if github_float == int(github_float) else str(github_raw)
506
+ verifiable_point = f"a strong GitHub activity score of {github_score_str}"
507
+ except (TypeError, ValueError):
508
+ pass
509
+
510
+ if verifiable_point == "strong technical skills":
511
+ assessments = _signals.get("skill_assessment_scores") or {}
512
+ verified_skill = None
513
+ verified_score = None
514
+ if isinstance(assessments, dict) and assessments:
515
+ for k, v in assessments.items():
516
+ try:
517
+ score_val = float(v)
518
+ if score_val >= 0:
519
+ verified_skill = k
520
+ verified_score = str(int(score_val)) if score_val == int(score_val) else str(v)
521
+ break
522
+ except (TypeError, ValueError):
523
+ pass
524
+ if verified_skill:
525
+ verifiable_point = f"a verified platform assessment score of {verified_score}/100 in {verified_skill}"
526
+
527
+ if verifiable_point == "strong technical skills":
528
+ prod_log = feature_vector.get("prod_signal_log", 0.0)
529
+ if prod_log > 0:
530
+ verifiable_point = "proven production engineering credentials in career history descriptions"
531
+
532
+ if concern:
533
+ reasoning = (
534
+ f"Backed by {verifiable_point}, the profile features {jd_match}. "
535
+ f"Based in {location}, the candidate has {yoe_str} years of experience "
536
+ f"and is available in {notice_str} days. Primary concern: {concern}."
537
+ )
538
+ else:
539
+ reasoning = (
540
+ f"Backed by {verifiable_point}, the profile features {jd_match}. "
541
+ f"Based in {location}, the candidate has {yoe_str} years of experience "
542
+ f"and is available in {notice_str} days."
543
+ )
544
+
545
+
546
+ candidate_numbers = _extract_candidate_numbers(candidate)
547
+
548
+ audit_passed, violations = _numeric_regex_audit(reasoning, candidate_numbers)
549
+ if not audit_passed:
550
+ for v in violations:
551
+ reasoning = re.sub(
552
+ r'\b' + re.escape(v) + r'\b\.?',
553
+ '',
554
+ reasoning,
555
+ ).strip()
556
+
557
+ reasoning = re.sub(r' +', ' ', reasoning)
558
+ reasoning = re.sub(r'\[N\]', '', reasoning).strip()
559
+
560
+ reasoning = reasoning.replace("..", ".").replace(" .", ".").strip()
561
+
562
+
563
+ collision_ok, sim = _ngram_collision_check(reasoning, self._generated_texts)
564
+ if not collision_ok:
565
+ reasoning = f"[Rank {rank}] " + reasoning
566
+ self._generated_texts.append(reasoning)
567
+
568
+ return reasoning
569
+
570
+ def compile_trace(
571
+ self,
572
+ candidate: dict,
573
+ feature_vector: Dict[str, float],
574
+ lgbm_score: float,
575
+ rank: int,
576
+ ) -> dict:
577
+ """
578
+ Compile reasoning and return a full audit trace dict for reasoning_trace.jsonl.
579
+ Used for top 30 candidates (Section 8.3).
580
+ """
581
+ reasoning = self.compile(candidate, feature_vector, lgbm_score, rank)
582
+
583
+ feature_items = sorted(
584
+ [(k, abs(v)) for k, v in feature_vector.items()],
585
+ key=lambda x: x[1],
586
+ reverse=True
587
+ )
588
+ top_drivers = [k for k, _ in feature_items[:3]]
589
+
590
+ return {
591
+ "candidate_id": candidate.get("candidate_id"),
592
+ "rank": rank,
593
+ "lgbm_score": round(lgbm_score, 6),
594
+ "hard_req_coverage": round(feature_vector.get("hard_req_coverage", 0.0), 4),
595
+ "consistency_score": round(feature_vector.get("consistency_score", 1.0), 4),
596
+ "top_feature_drivers": top_drivers,
597
+ "concern": _get_severity_ranked_concern(feature_vector, candidate),
598
+ "reasoning": reasoning,
599
+ }
600
+
601
+
602
+ if __name__ == "__main__":
603
+ import sys
604
+ import os
605
+
606
+ base_dir = os.path.dirname(os.path.abspath(__file__))
607
+ from jd_parser import parse_jd
608
+
609
+ jd = parse_jd(os.path.join(base_dir, "data", "skill_aliases.json"))
610
+
611
+ def make_candidate(cid, yoe, location, country, notice, github, skills, hard_req_frac):
612
+ return {
613
+ "candidate_id": cid,
614
+ "profile": {
615
+ "years_of_experience": yoe,
616
+ "location": location,
617
+ "country": country,
618
+ "current_title": "ML Engineer",
619
+ "current_company": "Startup",
620
+ "current_company_size": "11-50",
621
+ "current_industry": "Technology",
622
+ "headline": "ML Engineer",
623
+ "summary": "",
624
+ "anonymized_name": "Test User",
625
+ },
626
+ "career_history": [{
627
+ "company": "Startup", "title": "ML Engineer",
628
+ "start_date": "2021-01-01", "end_date": None,
629
+ "duration_months": int(yoe * 12), "is_current": True,
630
+ "industry": "Technology", "company_size": "11-50",
631
+ "description": "Deployed BM25 and FAISS ranking pipeline at production scale with low latency."
632
+ }],
633
+ "skills": skills,
634
+ "redrob_signals": {
635
+ "signup_date": "2021-01-01", "last_active_date": "2025-12-01",
636
+ "recruiter_response_rate": 0.8, "open_to_work_flag": True,
637
+ "connection_count": 200, "search_appearance_30d": 80,
638
+ "endorsements_received": 15, "notice_period_days": notice,
639
+ "expected_salary_range_inr_lpa": {"min": 20.0, "max": 40.0},
640
+ "github_activity_score": github,
641
+ "skill_assessment_scores": {},
642
+ "profile_completeness_score": 75,
643
+ "profile_views_received_30d": 10,
644
+ "applications_submitted_30d": 2,
645
+ "avg_response_time_hours": 12.0,
646
+ "preferred_work_mode": "remote",
647
+ "willing_to_relocate": True,
648
+ "saved_by_recruiters_30d": 3,
649
+ "interview_completion_rate": 0.9,
650
+ "offer_acceptance_rate": 0.8,
651
+ "verified_email": True,
652
+ "verified_phone": True,
653
+ "linkedin_connected": True,
654
+ }
655
+ }
656
+
657
+ c_strong = make_candidate(
658
+ "CAND_0000001", 8, "Pune", "India", 30, 85,
659
+ [{"name": "FAISS", "proficiency": "advanced", "endorsements": 20, "duration_months": 48},
660
+ {"name": "BM25", "proficiency": "advanced", "endorsements": 15, "duration_months": 36},
661
+ {"name": "Python", "proficiency": "expert", "endorsements": 40, "duration_months": 72}],
662
+ 0.8
663
+ )
664
+
665
+ c_mid = make_candidate(
666
+ "CAND_0000002", 4, "Bangalore", "India", 60, 40,
667
+ [{"name": "Python", "proficiency": "advanced", "endorsements": 12, "duration_months": 36},
668
+ {"name": "NLP", "proficiency": "intermediate", "endorsements": 5, "duration_months": 18}],
669
+ 0.4
670
+ )
671
+
672
+ c_weak = make_candidate(
673
+ "CAND_0000003", 1, "Austin", "USA", 90, -1,
674
+ [{"name": "LangChain", "proficiency": "advanced", "endorsements": 2, "duration_months": 6}],
675
+ 0.1
676
+ )
677
+
678
+ scores = [0.9, 0.5, 0.1]
679
+ from features import build_feature_vector, consistency_score
680
+
681
+ compiler = ReasoningCompiler(jd, all_scores=scores)
682
+
683
+ for candidate, score in [(c_strong, 0.9), (c_mid, 0.5), (c_weak, 0.1)]:
684
+ fv = build_feature_vector(candidate, jd, bm25_score=score * 15, stage1_bm25_median=7.5)
685
+ trace = compiler.compile_trace(candidate, fv, score, rank=scores.index(score)+1)
686
+ print(f"\n=== {candidate['candidate_id']} (score={score}, rank={scores.index(score)+1}) ===")
687
+ print(f"Reasoning: {trace['reasoning']}")
688
+ print(f"Top drivers: {trace['top_feature_drivers']}")
689
+ print(f"Concern: {trace['concern']}")
src/retrieval.py ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ retrieval.py
3
+
4
+ Dual-Pass BM25 Retrieval per Section 3 of the architecture document.
5
+
6
+ Stage 1: Load precomputed BM25 index, run two passes:
7
+ Pass A: JD skill aliases (expanded via skill_aliases.json taxonomy)
8
+ Pass B: Production-context keywords (deployed, scale, serving, latency, ...)
9
+ Safety Net: Rare-term pool for niche terms (pinecone, lambdarank)
10
+
11
+ stage1_candidates = top_5000 ∪ rare_term_pool
12
+
13
+ No network calls. BM25 index must be precomputed via precompute.py.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import os
20
+ import pickle
21
+ import time
22
+ from typing import Dict, List, Optional, Set, Tuple
23
+
24
+ import numpy as np
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+
30
+ class NumpyBM25:
31
+ """
32
+ Drop-in replacement for BM25Okapi.get_scores() using a precomputed scipy
33
+ sparse matrix of shape (vocab_size, n_docs).
34
+
35
+ Each entry [term_idx, doc_idx] stores the precomputed value:
36
+ idf(term) * bm25_tf_adjusted(term, doc)
37
+
38
+ Scoring a query is a single sparse matrix-vector multiply:
39
+ q_vec (vocab_size,) @ bm25_matrix (vocab_size × n_docs)
40
+ -> scores (n_docs,) — sub-10 ms for 214 tokens × 100K docs.
41
+
42
+ Compared with BM25Okapi.get_scores():
43
+ BM25Okapi: 214 Python loops × 100K dict lookups = ~9.5 s
44
+ NumpyBM25: one scipy sparse matvec = ~50 ms
45
+ """
46
+
47
+ def __init__(self, vocab: Dict[str, int], bm25_matrix) -> None:
48
+ self.vocab = vocab
49
+ self.bm25_matrix = bm25_matrix
50
+ self._n_docs: int = bm25_matrix.shape[1]
51
+ self._n_vocab: int = bm25_matrix.shape[0]
52
+
53
+ def get_scores(self, query_tokens: List[str]) -> np.ndarray:
54
+ """
55
+ Score all documents for a list of query tokens.
56
+ Matches BM25Okapi.get_scores() signature exactly.
57
+ Returns np.ndarray of shape (n_docs,), dtype float32.
58
+ """
59
+ q_vec = np.zeros(self._n_vocab, dtype=np.float32)
60
+ matched = 0
61
+ for t in query_tokens:
62
+ idx = self.vocab.get(t)
63
+ if idx is not None:
64
+ q_vec[idx] = 1.0
65
+ matched += 1
66
+ if matched == 0:
67
+ return np.zeros(self._n_docs, dtype=np.float32)
68
+ return np.asarray(q_vec @ self.bm25_matrix, dtype=np.float32).flatten()
69
+
70
+
71
+ def load_numpy_bm25_artifacts(precomputed_dir: str) -> Optional[NumpyBM25]:
72
+ """
73
+ Load precomputed NumPy BM25 artifacts (vocab.pkl + bm25_matrix.npz).
74
+ Returns a NumpyBM25 instance, or None if the artifacts don't exist yet
75
+ (in which case callers should fall back to bm25_index.pkl).
76
+ """
77
+ vocab_path = os.path.join(precomputed_dir, "vocab.pkl")
78
+ matrix_path = os.path.join(precomputed_dir, "bm25_matrix.npz")
79
+
80
+ if not (os.path.isfile(vocab_path) and os.path.isfile(matrix_path)):
81
+ return None
82
+
83
+ try:
84
+ from scipy.sparse import load_npz
85
+ t0 = time.perf_counter()
86
+ with open(vocab_path, "rb") as f:
87
+ vocab = pickle.load(f)
88
+ bm25_matrix = load_npz(matrix_path)
89
+ logger.info(
90
+ "NumPy BM25 loaded: vocab=%d shape=%s in %.3f s",
91
+ len(vocab), bm25_matrix.shape, time.perf_counter() - t0,
92
+ )
93
+ return NumpyBM25(vocab, bm25_matrix)
94
+ except Exception as exc:
95
+ logger.warning("Failed to load NumPy BM25 artifacts (%s) — falling back to BM25Okapi", exc)
96
+ return None
97
+
98
+
99
+
100
+ def load_bm25_artifacts(precomputed_dir: str) -> Tuple[object, List[str], List[str]]:
101
+ """
102
+ Load the precomputed BM25 index and corpus metadata.
103
+
104
+ Args:
105
+ precomputed_dir: Path to the precomputed/ directory.
106
+
107
+ Returns:
108
+ (bm25_index, candidate_ids, tokenized_corpus)
109
+
110
+ Raises:
111
+ FileNotFoundError: If precomputed artifacts don't exist.
112
+ RuntimeError: If artifacts are corrupted.
113
+ """
114
+ index_path = os.path.join(precomputed_dir, "bm25_index.pkl")
115
+ ids_path = os.path.join(precomputed_dir, "candidate_ids.pkl")
116
+
117
+ if not os.path.isfile(index_path):
118
+ raise FileNotFoundError(
119
+ f"BM25 index not found at {index_path}. "
120
+ "Run precompute.py first."
121
+ )
122
+ if not os.path.isfile(ids_path):
123
+ raise FileNotFoundError(
124
+ f"Candidate IDs not found at {ids_path}. "
125
+ "Run precompute.py first."
126
+ )
127
+
128
+ try:
129
+ with open(index_path, "rb") as f:
130
+ bm25 = pickle.load(f)
131
+ with open(ids_path, "rb") as f:
132
+ candidate_ids = pickle.load(f)
133
+ except Exception as e:
134
+ raise RuntimeError(f"Failed to load BM25 artifacts: {e}") from e
135
+
136
+ logger.info(
137
+ "BM25 index loaded: %d candidates indexed", len(candidate_ids)
138
+ )
139
+ return bm25, candidate_ids
140
+
141
+
142
+ def tokenize_query(terms: List[str]) -> List[str]:
143
+ """
144
+ Tokenize a list of query terms for BM25.
145
+ Splits multi-word terms, lowercases, deduplicates.
146
+ """
147
+ tokens = []
148
+ for term in terms:
149
+ tokens.extend(term.lower().split())
150
+ return list(set(tokens))
151
+
152
+
153
+ def run_dual_pass_retrieval(
154
+ bm25,
155
+ candidate_ids: List[str],
156
+ jd_config,
157
+ top_n: int = 5000,
158
+ ) -> Tuple[List[str], Dict[str, float]]:
159
+ """
160
+ Execute dual-pass BM25 retrieval per Section 3.
161
+
162
+ Pass A: All JD skill aliases (hard + preferred requirements)
163
+ Pass B: Production-context keywords only
164
+ Safety Net: Rare terms pool (pinecone, lambdarank)
165
+
166
+ Returns:
167
+ (stage1_candidate_ids, bm25_scores_dict)
168
+ - stage1_candidate_ids: ordered list (best first) of top_5000 ∪ rare_pool
169
+ - bm25_scores_dict: {candidate_id: float} for all retrieved candidates
170
+ """
171
+ t0 = time.time()
172
+
173
+ query_a_terms = jd_config.get_all_query_terms()
174
+ query_a_tokens = tokenize_query(query_a_terms)
175
+ logger.info("Pass A query tokens (%d): %s...", len(query_a_tokens),
176
+ query_a_tokens[:10])
177
+
178
+ scores_a = bm25.get_scores(query_a_tokens)
179
+
180
+ query_b_tokens = tokenize_query(jd_config.production_keywords)
181
+ logger.info("Pass B query tokens (%d): %s", len(query_b_tokens), query_b_tokens)
182
+
183
+ scores_b = bm25.get_scores(query_b_tokens)
184
+ import numpy as np
185
+ combined_scores = np.maximum(scores_a, scores_b)
186
+
187
+ top_n_actual = min(top_n, len(candidate_ids))
188
+ top_indices = np.argpartition(combined_scores, -top_n_actual)[-top_n_actual:]
189
+ top_indices = top_indices[np.argsort(combined_scores[top_indices])[::-1]]
190
+
191
+ top_candidates = [candidate_ids[i] for i in top_indices]
192
+ top_scores = {candidate_ids[i]: float(combined_scores[i]) for i in top_indices}
193
+
194
+ logger.info("Pass A+B union: %d candidates (target %d)", len(top_candidates), top_n)
195
+
196
+ rare_pool_ids = set()
197
+ rare_pool_scores = {}
198
+
199
+ for rare_term in jd_config.rare_terms:
200
+ rare_tokens = tokenize_query([rare_term])
201
+ rare_scores = bm25.get_scores(rare_tokens)
202
+ rare_nonzero = np.where(rare_scores > 0)[0]
203
+ for idx in rare_nonzero:
204
+ cid = candidate_ids[idx]
205
+ if cid not in top_scores:
206
+ rare_pool_ids.add(cid)
207
+ rare_pool_scores[cid] = max(
208
+ rare_pool_scores.get(cid, 0.0),
209
+ float(rare_scores[idx])
210
+ )
211
+
212
+ logger.info("Rare-term safety net added %d additional candidates", len(rare_pool_ids))
213
+
214
+
215
+ all_scores = {**top_scores, **rare_pool_scores}
216
+
217
+ all_ordered = sorted(all_scores.keys(), key=lambda cid: all_scores[cid], reverse=True)
218
+
219
+ elapsed = time.time() - t0
220
+ logger.info(
221
+ "Dual-pass retrieval complete: %d candidates in %.2fs",
222
+ len(all_ordered), elapsed
223
+ )
224
+
225
+ return all_ordered, all_scores
226
+
227
+
228
+ def retrieve_candidate_data(
229
+ stage1_ids: List[str],
230
+ candidates_path: str,
231
+ ) -> Tuple[List[dict], Set[str]]:
232
+ """
233
+ Stream-read the candidates JSONL file and extract only the Stage 1 candidates.
234
+
235
+ Args:
236
+ stage1_ids: Ordered list of candidate IDs from retrieval.
237
+ candidates_path: Path to candidates.jsonl.
238
+
239
+ Returns:
240
+ (candidates_list, missing_ids)
241
+ - candidates_list: list of candidate dicts for stage1 IDs (order preserved)
242
+ - missing_ids: IDs that were in stage1_ids but not found in the file
243
+ """
244
+ import json
245
+
246
+ stage1_set = set(stage1_ids)
247
+ found: Dict[str, dict] = {}
248
+ malformed_count = 0
249
+
250
+ with open(candidates_path, "r", encoding="utf-8") as f:
251
+ for line_num, line in enumerate(f, 1):
252
+ line = line.strip()
253
+ if not line:
254
+ continue
255
+ try:
256
+ candidate = json.loads(line)
257
+ except json.JSONDecodeError as e:
258
+ malformed_count += 1
259
+ logger.warning(
260
+ "Malformed JSON at line %d (skipped): %s", line_num, e
261
+ )
262
+ continue
263
+
264
+ cid = candidate.get("candidate_id")
265
+ if cid and cid in stage1_set:
266
+ found[cid] = candidate
267
+ if len(found) == len(stage1_set):
268
+ break # All found — stop early
269
+
270
+ if malformed_count > 0:
271
+ logger.warning("Skipped %d malformed JSONL lines", malformed_count)
272
+
273
+ missing_ids = stage1_set - set(found.keys())
274
+ if missing_ids:
275
+ logger.warning(
276
+ "%d stage1 candidates not found in JSONL: %s...",
277
+ len(missing_ids),
278
+ list(missing_ids)[:5]
279
+ )
280
+ ordered = [found[cid] for cid in stage1_ids if cid in found]
281
+
282
+ logger.info(
283
+ "Retrieved %d candidate records from JSONL (%d missing)",
284
+ len(ordered), len(missing_ids)
285
+ )
286
+ return ordered, missing_ids
287
+
288
+
289
+ if __name__ == "__main__":
290
+ import sys
291
+ import json
292
+ import os
293
+
294
+ base_dir = os.path.dirname(os.path.abspath(__file__))
295
+ precomputed_dir = os.path.join(base_dir, "precomputed")
296
+ candidates_path = os.path.join(base_dir, "candidates.jsonl")
297
+
298
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
299
+
300
+ from jd_parser import parse_jd
301
+ jd_config = parse_jd(os.path.join(base_dir, "data", "skill_aliases.json"))
302
+
303
+ print("Loading BM25 artifacts...")
304
+ bm25, candidate_ids = load_bm25_artifacts(precomputed_dir)
305
+
306
+ print(f"Running dual-pass retrieval on {len(candidate_ids)} indexed candidates...")
307
+ stage1_ids, bm25_scores = run_dual_pass_retrieval(bm25, candidate_ids, jd_config)
308
+
309
+ print(f"Stage 1 retrieved: {len(stage1_ids)} candidates")
310
+ print(f"Top 10 by BM25 score:")
311
+ for i, cid in enumerate(stage1_ids[:10], 1):
312
+ print(f" {i:2d}. {cid} score={bm25_scores[cid]:.4f}")
313
+
314
+ import numpy as np
315
+ scores = list(bm25_scores.values())
316
+ print(f"Score stats: min={min(scores):.4f}, max={max(scores):.4f}, "
317
+ f"median={float(np.median(scores)):.4f}, mean={float(np.mean(scores)):.4f}")
streamlit_app.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import runpy
3
+
4
+ # Streamlit App Entrypoint for Hugging Face Spaces & Streamlit Cloud
5
+ # This replaces Hugging Face's boilerplate template and redirects to our real app in scripts/app.py
6
+ if __name__ == "__main__":
7
+ script_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts", "app.py")
8
+ runpy.run_path(script_path, run_name="__main__")
submission_metadata.yaml ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ team:
2
+ name: "Ctrl Coffee Repeat"
3
+ primary_contact_name: "Pranjal H Dohare"
4
+ primary_contact_email: "pranjaldohare8@gmail.com"
5
+ primary_contact_phone: "+919320480095"
6
+ github_repository_url: "https://github.com/Pranjal1342/Intelligent-Candidate-Discovery-Ranking-System"
7
+ sandbox_demo_url: "https://ctrl-coffee-repeat.streamlit.app/"
8
+ members:
9
+ - name: "Pranjal H Dohare"
10
+ role: "Lead Developer"
11
+ - name: "Priyanka Tiwari"
12
+ role: "Architecture and System Design"
13
+
14
+ submission:
15
+ version: "1.0.0"
16
+ timestamp: "2026-07-01"
17
+ output_file: "CTRL_COFFEE_REPEAT.csv"
18
+
19
+ system:
20
+ pipeline_type: "Offline-Indexed Lexical Retrieval + LightGBM LambdaRank"
21
+ hardware: "CPU-only, ≤16GB RAM"
22
+ runtime_seconds: 4
23
+ network_calls_during_ranking: 0
24
+
25
+ methodology_summary: |
26
+ This system uses a deterministic, CPU-only pipeline optimized for NDCG@10 and P@5.
27
+
28
+ Stage 1 (Retrieval): A precomputed NumPy CSR BM25 matrix (built offline, ~40 MB) is queried
29
+ at runtime in under 0.1 seconds via dual-pass: Pass A expands JD requirements using a
30
+ skill alias taxonomy (skill_aliases.json), Pass B targets production-context keywords
31
+ (deployed, scale, serving, latency). A rare-term safety net retrieves candidates with niche
32
+ skills (pinecone, lambdarank) that might otherwise be missed. This produces a ~8,500-candidate
33
+ Stage 1 pool in approximately 0.03 seconds.
34
+
35
+ Stage 2 (Features): A 22-feature schema-grounded matrix extracts signals from every candidate
36
+ record. Includes 5 adversarial detection functions: domain-category mismatch, synthetic template
37
+ detection, production signal log-compression, LangChain dabbler detection, and CV/speech
38
+ specialist detection. Stage 3 adds a consistency composite (c1×c2×c3×c4×c5) that zeros out
39
+ scores for timeline impossibilities, signup anomalies, salary inversions, assessment
40
+ contradictions, and engagement mismatches.
41
+
42
+ Stage 4 (Ranking): LightGBM with objective=lambdarank trains on relevance labels generated
43
+ via 2,500 pairwise LLM comparisons using Gemma3:4b-it-q4_K_M (running offline and locally
44
+ via Ollama — zero external API calls). This explicitly breaks circularity: the LLM judges
45
+ profiles organically without knowledge of the 22 features or BM25 scores, then Elo ratings
46
+ are converted to 0-3 relevance labels by quartile thresholding. Candidates with data integrity
47
+ violations are suppressed post-inference via a consistency multiplier
48
+ (final_score = raw_score × consistency_score).
49
+
50
+ Stage 5 (Reasoning): Deterministic grammar engine generates fact-grounded reasoning with
51
+ numeric regex audit (all cited numbers must exist in the candidate JSON), n-gram collision
52
+ avoidance (difflib.SequenceMatcher), and priority-ranked concern surfacing. Pre-submission
53
+ blocking audits enforce diversity (max 25% archetype concentration, max 30% employer
54
+ concentration) and honeypot detection (assert low_consistency_in_top100 < 10).
55
+
56
+ Model comparison evidence: the heuristic-trained model required a hand-coded suppression list
57
+ to keep non-technical profiles out of the top 100. The Gemma-trained model achieved 0 honeypot
58
+ leakage with no suppression list, and the two models show Spearman correlation of 0.001 on the
59
+ top-100 ranking — confirming the LLM labels are genuinely independent of the engineered features.
60
+
61
+ ai_tools_used:
62
+ - tool: "Google DeepMind Antigravity"
63
+ usage: "Code scaffolding, module structure, latency diagnostics, iterative debugging"
64
+ human_review: true
65
+ - tool: "Gemma3:4b-it-q4_K_M via Ollama (local, offline)"
66
+ usage: >
67
+ Offline pairwise candidate annotation: 2,500 comparisons on a stratified sample of
68
+ 500 Stage 1 candidates to generate non-circular LightGBM training labels.
69
+ No candidate data transmitted to any external service. Runs in
70
+ experiments/pairwise_llm_check/annotate_and_retrain.py, entirely separate from
71
+ the ranking pipeline. Exempt from the 5-minute/zero-network ranking budget.
72
+ human_review: true
73
+
74
+ reference_date: "2026-01-01"