TASMAYU commited on
Commit
653c70b
Β·
verified Β·
1 Parent(s): 46175e6

Upload 13 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.13-slim
2
+
3
+ RUN apt-get update && apt-get install -y \
4
+ build-essential \
5
+ cmake \
6
+ libomp-dev \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+
10
+ RUN useradd -m -u 1000 user
11
+ USER user
12
+ ENV HOME=/home/user \
13
+ PATH=/home/user/.local/bin:$PATH
14
+
15
+ WORKDIR $HOME/app
16
+
17
+
18
+ COPY --chown=user requirements.txt .
19
+ RUN pip install --no-cache-dir --upgrade pip && \
20
+ pip install --no-cache-dir -r requirements.txt
21
+
22
+
23
+ COPY --chown=user . .
24
+
25
+
26
+ CMD ["sleep", "infinity"]
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
README.md CHANGED
@@ -1,10 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: Redrob
3
- emoji: 🐠
4
- colorFrom: blue
5
- colorTo: indigo
6
- sdk: docker
7
- pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
1
+ # Redrob Intelligent Candidate Ranking Pipeline
2
+
3
+ > **Hackathon:** India Runs Data & AI Challenge β€” Candidate Ranking Track
4
+ > **Task:** Rank top 100 candidates from a 100,000-candidate pool for a senior AI/ML retrieval engineer role.
5
+ > **Approach:** 5-stage offline CPU-only pipeline using a LightGBM regressor trained via teacher distillation + deterministic reasoning + optional SLM rewrite.
6
+
7
+ ---
8
+
9
+ ## Quick Start (Single Command)
10
+
11
+ ```bash
12
+ python run_pipeline.py --candidates candidates.jsonl --ranker regressor_no_prescore.pkl
13
+ ```
14
+
15
+ This produces `submission.csv` with the top 100 ranked candidates. **Runtime: ~31 seconds on CPU.**
16
+
17
+ ---
18
+
19
+ ## Table of Contents
20
+
21
+ 1. [Prerequisites](#prerequisites)
22
+ 2. [Installation β€” Windows](#installation--windows)
23
+ 3. [Installation β€” macOS](#installation--macos)
24
+ 4. [Installation β€” Linux](#installation--linux)
25
+ 5. [One-time Model Download](#one-time-model-download)
26
+ 6. [Running the Pipeline](#running-the-pipeline)
27
+ 7. [Validating Your Submission](#validating-your-submission)
28
+ 8. [Pipeline Flags Reference](#pipeline-flags-reference)
29
+ 9. [File Structure](#file-structure)
30
+ 10. [Troubleshooting](#troubleshooting)
31
+
32
+ ---
33
+
34
+ ## Prerequisites
35
+
36
+ | Requirement | Version |
37
+ |---|---|
38
+ | Python | >= 3.10 |
39
+ | RAM | >= 8 GB (16 GB recommended) |
40
+ | Disk Space | >= 3 GB free |
41
+ | CPU | Any modern x86-64 CPU with **AVX2** support (for SLM stage) |
42
+ | Internet | Required once for model download only |
43
+
44
+ ---
45
+
46
+ ## Installation β€” Windows
47
+
48
+ ### Step 1: Verify Python Version
49
+ ```powershell
50
+ python --version
51
+ # Should show Python 3.10 or higher
52
+ ```
53
+
54
+ If Python is not installed, download it from [python.org](https://python.org) or via Windows Store.
55
+
56
+ ### Step 2: Create a Virtual Environment (Recommended)
57
+ ```powershell
58
+ python -m venv venv
59
+ venv\Scripts\activate
60
+ ```
61
+
62
+ ### Step 3: Install Core Dependencies
63
+ ```powershell
64
+ pip install -r requirements.txt
65
+ ```
66
+
67
+ ### Step 4: Install llama-cpp-python (SLM Engine)
68
+
69
+ > **CPU-only (works on any machine):**
70
+ ```powershell
71
+ pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
72
+ ```
73
+
74
+ > **With NVIDIA GPU (optional, ~10x faster for SLM stage):**
75
+ ```powershell
76
+ pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu121
77
+ ```
78
+
79
+ ### Step 5: Download the SLM Model (One-Time)
80
+ ```powershell
81
+ python setup_model.py
82
+ ```
83
+ Downloads `Qwen2.5-1.5B-Instruct-Q4_K_M.gguf` (~986 MB) into the `models/` folder. Resumable if interrupted.
84
+
85
+ ---
86
+
87
+ ## Installation β€” macOS
88
+
89
+ ### Step 1: Install Python via Homebrew
90
+ ```bash
91
+ brew install python@3.11
92
+ python3 --version
93
+ ```
94
+
95
+ ### Step 2: Create a Virtual Environment
96
+ ```bash
97
+ python3 -m venv venv
98
+ source venv/bin/activate
99
+ ```
100
+
101
+ ### Step 3: Install Core Dependencies
102
+ ```bash
103
+ pip install -r requirements.txt
104
+ ```
105
+
106
+ ### Step 4: Install llama-cpp-python
107
+
108
+ > **Apple Silicon (M1/M2/M3) β€” Metal GPU acceleration:**
109
+ ```bash
110
+ CMAKE_ARGS="-DLLAMA_METAL=on" pip install llama-cpp-python
111
+ ```
112
+
113
+ > **Intel Mac β€” CPU only:**
114
+ ```bash
115
+ pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
116
+ ```
117
+
118
+ ### Step 5: Download the SLM Model (One-Time)
119
+ ```bash
120
+ python setup_model.py
121
+ ```
122
+
123
+ ---
124
+
125
+ ## Installation β€” Linux
126
+
127
+ ### Step 1: Install Python
128
+ ```bash
129
+ # Ubuntu / Debian
130
+ sudo apt update
131
+ sudo apt install python3.11 python3.11-venv python3-pip -y
132
+
133
+ # Fedora / RHEL
134
+ sudo dnf install python3.11 -y
135
+
136
+ # Verify
137
+ python3 --version
138
+ ```
139
+
140
+ ### Step 2: Create a Virtual Environment
141
+ ```bash
142
+ python3 -m venv venv
143
+ source venv/bin/activate
144
+ ```
145
+
146
+ ### Step 3: Install Build Tools (required for llama-cpp-python compilation)
147
+ ```bash
148
+ # Ubuntu / Debian
149
+ sudo apt install build-essential cmake -y
150
+
151
+ # Fedora / RHEL
152
+ sudo dnf groupinstall "Development Tools" -y
153
+ sudo dnf install cmake -y
154
+ ```
155
+
156
+ ### Step 4: Install Core Dependencies
157
+ ```bash
158
+ pip install -r requirements.txt
159
+ ```
160
+
161
+ ### Step 5: Install llama-cpp-python
162
+
163
+ > **CPU-only build:**
164
+ ```bash
165
+ pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
166
+ ```
167
+
168
+ > **CUDA 12.1 (NVIDIA GPU):**
169
+ ```bash
170
+ CMAKE_ARGS="-DLLAMA_CUBLAS=on" pip install llama-cpp-python
171
+ ```
172
+
173
+ ### Step 6: Download the SLM Model (One-Time)
174
+ ```bash
175
+ python setup_model.py
176
+ ```
177
+
178
+ ---
179
+
180
+ ## One-time Model Download
181
+
182
+ The SLM model (`Qwen2.5-1.5B-Instruct-Q4_K_M.gguf`) is approximately **986 MB** and needs to be downloaded once before running the full pipeline.
183
+
184
+ ```bash
185
+ python setup_model.py
186
+ ```
187
+
188
+ **What it does:**
189
+ - Checks for 2 GB free disk space
190
+ - Downloads the GGUF quantized model from HuggingFace
191
+ - Saves it to `models/Qwen2.5-1.5B-Instruct-Q4_K_M.gguf`
192
+ - Download is resumable if interrupted
193
+
194
+ > **Note:** If you prefer to skip the SLM rewrite stage (e.g., for testing), you can use `--no-rewrite` flag and skip this step entirely.
195
+
196
+ ---
197
+
198
+ ## Running the Pipeline
199
+
200
+ ### Full Pipeline (All Stages including SLM rewrite)
201
+ ```bash
202
+ python run_pipeline.py \
203
+ --candidates candidates.jsonl \
204
+ --ranker regressor_no_prescore.pkl
205
+ ```
206
+
207
+ ### Skip SLM Rewrite (Faster, uses deterministic reasoning)
208
+ ```bash
209
+ python run_pipeline.py \
210
+ --candidates candidates.jsonl \
211
+ --ranker regressor_no_prescore.pkl \
212
+ --no-rewrite
213
+ ```
214
+
215
+ ### Resume from a Specific Stage (if pipeline was interrupted)
216
+ ```bash
217
+ # Resume from Stage 2 (LightGBM) onwards
218
+ python run_pipeline.py \
219
+ --candidates candidates.jsonl \
220
+ --ranker regressor_no_prescore.pkl \
221
+ --start-stage 2
222
+
223
+ # Resume from Stage 4 (SLM Rewrite) onwards
224
+ python run_pipeline.py \
225
+ --candidates candidates.jsonl \
226
+ --ranker regressor_no_prescore.pkl \
227
+ --start-stage 4
228
+ ```
229
+
230
+ ### Expected Runtime
231
+ | Stage | Operation | Time |
232
+ |---|---|---|
233
+ | Stage 1 | Hard filter + prescore (100k β†’ 22k) | ~17 seconds |
234
+ | Stage 2 | LightGBM regression (22k β†’ top 100) | ~4.6 seconds |
235
+ | Stage 3 | Deterministic reasoning (top 100) | < 0.1 seconds |
236
+ | Stage 4 | SLM rewrite β€” Qwen2.5-1.5B (top 100) | ~2–4 minutes (optional) |
237
+ | Stage 5 | Write submission.csv | < 0.1 seconds |
238
+ | **Total (without SLM)** | | **~31 seconds** |
239
+ | **Total (with SLM)** | | **~3–5 minutes** |
240
+
241
+ ---
242
+
243
+ ## Validating Your Submission
244
+
245
+ After the pipeline runs, validate your output against the official submission spec:
246
+
247
+ ```bash
248
+ python validate_submission.py submission.csv
249
+ ```
250
+
251
+ Expected output:
252
+ ```
253
+ Submission is valid.
254
+ ```
255
+
256
+ ---
257
+
258
+ ## Pipeline Flags Reference
259
+
260
+ | Flag | Default | Description |
261
+ |---|---|---|
262
+ | `--candidates` | required | Path to `candidates.jsonl` |
263
+ | `--ranker` | `regressor_no_prescore.pkl` | Path to LightGBM `.pkl` model |
264
+ | `--filter-top-k` | `22000` | Number of candidates after Stage 1 |
265
+ | `--lgbm-top-k` | `15000` | Number of candidates fed to LightGBM |
266
+ | `--top-final` | `100` | Final candidates in submission |
267
+ | `--no-rewrite` | False | Skip SLM rewrite (Stage 4) |
268
+ | `--start-stage` | `1` | Resume pipeline from a specific stage |
269
+ | `--threads` | `auto` | CPU threads for LLM inference |
270
+ | `--temperature` | `0.35` | SLM sampling temperature |
271
+
272
  ---
273
+
274
+ ## File Structure
275
+
276
+ ```
277
+ github_repo/
278
+ β”œβ”€β”€ run_pipeline.py # Main orchestrator β€” run this
279
+ β”œβ”€β”€ filter_candidates.py # Stage 1: Hard filter + feature extraction
280
+ β”œβ”€β”€ fix_reasoning.py # Stage 3: Deterministic reasoning builder
281
+ β”œβ”€β”€ rewrite_reasoning.py # Stage 4: Qwen2.5 SLM rewrite engine
282
+ β”œβ”€β”€ score_candidates.py # Stage 3.5: Composite scoring
283
+ β”œβ”€β”€ setup_model.py # One-time model downloader
284
+ β”œβ”€β”€ validate_submission.py # Official submission validator
285
+ β”œβ”€β”€ train_lightgbm_regressor.py # (Offline) Training script
286
+ β”œβ”€β”€ regressor_no_prescore.pkl # Pre-trained LightGBM model weights
287
+ β”œβ”€β”€ regressor_no_prescore_features.json# Feature names for the model
288
+ β”œβ”€β”€ requirements.txt # Python dependencies
289
+ β”œβ”€β”€ models/
290
+ β”‚ └── Qwen2.5-1.5B-Instruct-Q4_K_M.gguf # SLM weights (download via setup_model.py)
291
+ β”œβ”€β”€ submission.csv # Final output (generated by pipeline)
292
+ β”œβ”€β”€ filtered_22k.jsonl # Stage 1 output (cached)
293
+ β”œβ”€β”€ lgbm_top15k.jsonl # Stage 2 output (cached)
294
+ └── top100_reasoned.jsonl # Stage 3 output (cached)
295
+ ```
296
+
297
+ ---
298
+
299
+ ## Troubleshooting
300
+
301
+ ### `OSError: [WinError -1073741795] Windows Error 0xc000001d`
302
+ Your CPU does not support AVX2 instructions required by `llama-cpp-python`. Use the `--no-rewrite` flag to skip Stage 4:
303
+ ```bash
304
+ python run_pipeline.py --candidates candidates.jsonl --ranker regressor_no_prescore.pkl --no-rewrite
305
+ ```
306
+
307
+ ### `ModuleNotFoundError: No module named 'lightgbm'`
308
+ Install dependencies:
309
+ ```bash
310
+ pip install -r requirements.txt
311
+ ```
312
+
313
+ ### `Model not found` error during Stage 4
314
+ Run the one-time download:
315
+ ```bash
316
+ python setup_model.py
317
+ ```
318
+
319
+ ### Download interrupted / incomplete GGUF file
320
+ `setup_model.py` uses `huggingface_hub` which supports **resumable downloads**. Simply run `python setup_model.py` again β€” it will continue from where it left off.
321
+
322
+ ### `PermissionError` on Windows
323
+ Run your terminal as Administrator, or ensure no other process has the output files open (e.g., close Excel if `submission.csv` is open).
324
+
325
+ ### Stage 2 LightGBM model mismatch
326
+ Ensure you are using the exact `regressor_no_prescore.pkl` provided in the repo. Do not mix pkl files from different training runs β€” the feature list must exactly match `regressor_no_prescore_features.json`.
327
+
328
+ ---
329
+
330
+ ## Reproduction Command (for Stage 3 Code Review)
331
+
332
+ For the hackathon Stage 3 code reproduction check, the exact command to produce `submission.csv` from scratch is:
333
+
334
+ ```bash
335
+ pip install -r requirements.txt
336
+ pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
337
+ python setup_model.py # one-time model download
338
+ python run_pipeline.py --candidates candidates.jsonl --ranker regressor_no_prescore.pkl
339
+ ```
340
+
341
  ---
342
 
343
+ *Pipeline runtime: ~31 seconds (CPU-only, without SLM) | ~4 minutes (with SLM rewrite)*
344
+ *Peak RAM: ~1.65 GB | Disk footprint: ~1.54 GB*
filter_candidates.py ADDED
@@ -0,0 +1,2096 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+
3
+ """
4
+
5
+ filter_candidates.py β€” Redrob Hackathon Candidate Filtering Pipeline
6
+
7
+ =====================================================================
8
+
9
+ Produces a ranked, filtered JSONL of candidates ready to feed into a
10
+
11
+ teacher model for score + reasoning generation.
12
+
13
+
14
+
15
+ Target output: top 50 000 candidates (or --top-k N), ordered by a
16
+
17
+ multi-signal pre-score so the teacher sees the most promising ones first.
18
+
19
+
20
+
21
+ Usage:
22
+
23
+ python filter_candidates.py --candidates candidates.jsonl --out filtered_50k.jsonl
24
+
25
+ python filter_candidates.py --candidates candidates.jsonl.gz --top-k 30000 --out filtered.jsonl
26
+
27
+ python filter_candidates.py --candidates candidates.jsonl --top-k 50000 --out filtered.jsonl --report
28
+
29
+
30
+
31
+ What this script does:
32
+
33
+ 1. Hard-disqualification filters (eliminate ~70-75 % of pool instantly)
34
+
35
+ 2. Honeypot detection (flag & exclude impossible profiles)
36
+
37
+ 3. Per-candidate pre-score (rank the survivors before teacher sees them)
38
+
39
+ 4. Output top-K JSONL + a human-readable filter report
40
+
41
+
42
+
43
+ Design principle: every decision here is traceable to either
44
+
45
+ (a) an explicit JD statement, or
46
+
47
+ (b) an observation from the data-analysis transcript in the conversation.
48
+
49
+ """
50
+
51
+
52
+
53
+ import json
54
+
55
+ import gzip
56
+
57
+ import argparse
58
+
59
+ import sys
60
+
61
+ import os
62
+
63
+ import collections
64
+
65
+ from datetime import date, datetime
66
+
67
+
68
+
69
+ # ─────────────────────────────────────────────────────────────────────────────
70
+
71
+ # REFERENCE DATE (contest reference date from analyse_dataset.py)
72
+
73
+ # ─────────────────────────────────────────────────────────────────────────────
74
+
75
+ TODAY = date(2026, 6, 9)
76
+
77
+
78
+
79
+ # ─────────────────────────────────────────────────────────────────────────────
80
+
81
+ # COMPANY UNIVERSE (derived from full data scan β€” 63 unique companies total)
82
+
83
+ # ─────────────────────────────────────────────────────────────────────────────
84
+
85
+
86
+
87
+ # JD explicitly disqualifies: "people who have only worked at consulting firms"
88
+
89
+ SERVICES_COMPANIES = {
90
+
91
+ 'tcs', 'infosys', 'wipro', 'accenture', 'cognizant', 'capgemini',
92
+
93
+ 'mindtree', 'hcl', 'tech mahindra', 'mphasis', 'hexaware',
94
+
95
+ 'ltimindtree', 'genpact',
96
+
97
+ }
98
+
99
+
100
+
101
+ # Injected synthetic noise β€” fictional company names from pop culture
102
+
103
+ FICTIONAL_COMPANIES = {
104
+
105
+ 'pied piper', 'initech', 'hooli', 'wayne enterprises',
106
+
107
+ 'acme corp', 'stark industries', 'globex inc', 'dunder mifflin',
108
+
109
+ 'umbrella corporation', 'soylent corp',
110
+
111
+ }
112
+
113
+
114
+
115
+ # Real Indian product / startup companies β€” strong positive signal
116
+
117
+ PRODUCT_COMPANIES_TIER1 = {
118
+
119
+ # FAANG / Big Tech India
120
+
121
+ 'google', 'meta', 'amazon', 'microsoft', 'apple', 'netflix',
122
+
123
+ 'adobe', 'salesforce', 'uber', 'linkedin',
124
+
125
+ }
126
+
127
+ PRODUCT_COMPANIES_TIER2 = {
128
+
129
+ # Indian unicorns / large product companies
130
+
131
+ 'swiggy', 'zomato', 'razorpay', 'cred', 'flipkart', 'phonepe',
132
+
133
+ 'paytm', 'meesho', 'nykaa', 'inmobi', "byju's", 'zoho',
134
+
135
+ 'unacademy', 'upgrad', 'policybazaar', 'ola', 'dream11',
136
+
137
+ 'freshworks', 'vedantu', 'pharmeasy',
138
+
139
+ }
140
+
141
+ PRODUCT_COMPANIES_TIER3 = {
142
+
143
+ # Indian AI-native / ML-focused companies
144
+
145
+ 'glance', 'rephrase.ai', 'aganitha', 'niramai', 'saarthi.ai',
146
+
147
+ 'sarvam ai', 'mad street den', 'observe.ai', 'krutrim',
148
+
149
+ 'wysa', 'haptik', 'verloop.io', 'yellow.ai', 'locobuzz',
150
+
151
+ 'genpact ai',
152
+
153
+ }
154
+
155
+ ALL_PRODUCT_COMPANIES = PRODUCT_COMPANIES_TIER1 | PRODUCT_COMPANIES_TIER2 | PRODUCT_COMPANIES_TIER3
156
+
157
+
158
+
159
+ # ─────────────────────────────────────────────────────────────────────────────
160
+
161
+ # TITLE UNIVERSE (all 47 unique profile titles + 48 career role titles from data)
162
+
163
+ # ─────────────────────────────────────────────────────────────────────────────
164
+
165
+
166
+
167
+ # Titles that are unambiguously NOT what the JD wants β€” used for hard filter
168
+
169
+ HARD_DISQUALIFY_TITLES = {
170
+
171
+ 'hr manager', 'business analyst', 'accountant', 'marketing manager',
172
+
173
+ 'operations manager', 'civil engineer', 'mechanical engineer',
174
+
175
+ 'content writer', 'customer support', 'sales executive',
176
+
177
+ 'graphic designer', 'project manager',
178
+
179
+ }
180
+
181
+
182
+
183
+ # Titles that are ambiguous β€” software engineers can do ML, data engineers matter
184
+
185
+ AMBIGUOUS_TITLES = {
186
+
187
+ 'software engineer', 'senior software engineer', 'full stack developer',
188
+
189
+ 'backend engineer', 'cloud engineer', 'devops engineer', 'qa engineer',
190
+
191
+ 'frontend engineer', 'java developer', '.net developer', 'mobile developer',
192
+
193
+ 'data analyst', 'analytics engineer', 'data engineer', 'senior data engineer',
194
+
195
+ }
196
+
197
+
198
+
199
+ # Core AI/ML titles β€” strongest positive title signal
200
+
201
+ AI_CORE_TITLES = {
202
+
203
+ 'ml engineer', 'machine learning engineer', 'senior machine learning engineer',
204
+
205
+ 'staff machine learning engineer', 'lead ai engineer', 'senior ml engineer β€” search & ranking',
206
+
207
+ 'ai engineer', 'senior ai engineer',
208
+
209
+ 'ai research engineer', 'applied ml engineer', 'senior applied scientist',
210
+
211
+ 'nlp engineer', 'senior nlp engineer',
212
+
213
+ 'data scientist', 'senior data scientist',
214
+
215
+ 'ai specialist', 'computer vision engineer',
216
+
217
+ 'recommendation systems engineer', 'search engineer',
218
+
219
+ 'senior software engineer (ml)',
220
+
221
+ 'junior ml engineer',
222
+
223
+ }
224
+
225
+
226
+
227
+ # ─────────────────────────────────────────────────────────────────────────────
228
+
229
+ # SKILL UNIVERSE (from full data scan β€” 133 unique skill names)
230
+
231
+ # ─────────────────────────────────────────────────────────────────────────────
232
+
233
+
234
+
235
+ # Skills directly mentioned in the JD as "absolutely need"
236
+
237
+ JD_MUST_HAVE_SKILLS = {
238
+
239
+ 'sentence transformers', 'embeddings', 'vector search', 'semantic search',
240
+
241
+ 'pinecone', 'weaviate', 'qdrant', 'milvus', 'faiss',
242
+
243
+ 'elasticsearch', 'opensearch',
244
+
245
+ 'information retrieval', 'python',
246
+
247
+ 'learning to rank',
248
+
249
+ }
250
+
251
+
252
+
253
+ # Skills mentioned in JD "would like to have"
254
+
255
+ JD_NICE_TO_HAVE_SKILLS = {
256
+
257
+ 'fine-tuning llms', 'qlora', 'lora', 'peft',
258
+
259
+ 'hugging face transformers', 'llms', 'langchain', 'llamaindex', 'haystack',
260
+
261
+ 'rag',
262
+
263
+ 'machine learning', 'deep learning', 'nlp',
264
+
265
+ 'pytorch', 'tensorflow', 'scikit-learn',
266
+
267
+ 'mlops', 'mlflow', 'weights & biases', 'kubeflow',
268
+
269
+ 'recommendation systems',
270
+
271
+ 'bm25', 'pgvector',
272
+
273
+ }
274
+
275
+
276
+
277
+ # ML skills from data that are broadly positive even if not JD-specific
278
+
279
+ ML_BROAD_SKILLS = {
280
+
281
+ 'feature engineering', 'time series', 'forecasting', 'statistical modeling',
282
+
283
+ 'reinforcement learning', 'data science',
284
+
285
+ 'computer vision', 'opencv', 'yolo', 'gans', 'diffusion models',
286
+
287
+ 'image classification', 'object detection',
288
+
289
+ 'asr', 'speech recognition', 'tts',
290
+
291
+ 'prompt engineering',
292
+
293
+ }
294
+
295
+
296
+
297
+ ALL_RELEVANT_SKILLS = JD_MUST_HAVE_SKILLS | JD_NICE_TO_HAVE_SKILLS | ML_BROAD_SKILLS
298
+
299
+
300
+
301
+ # Skills that are neutral/non-ML β€” having only these signals keyword stuffing
302
+
303
+ GENERIC_TECH_SKILLS = {
304
+
305
+ 'html', 'css', 'javascript', 'typescript', 'react', 'vue.js', 'angular',
306
+
307
+ 'next.js', 'webpack', 'tailwind', 'redux', 'graphql', 'rest apis',
308
+
309
+ 'java', 'go', 'rust', 'spring boot', 'django', 'flask', 'fastapi',
310
+
311
+ 'docker', 'kubernetes', 'terraform', 'ci/cd', 'aws', 'gcp', 'azure',
312
+
313
+ 'kafka', 'spark', 'airflow', 'apache beam', 'apache flink', 'hadoop',
314
+
315
+ 'bigquery', 'snowflake', 'dbt', 'databricks', 'etl', 'data pipelines',
316
+
317
+ 'sql', 'postgresql', 'mongodb', 'redis',
318
+
319
+ 'figma', 'photoshop', 'illustrator',
320
+
321
+ 'excel', 'powerpoint', 'agile', 'scrum', 'project management',
322
+
323
+ 'sales', 'marketing', 'accounting', 'content writing', 'seo',
324
+
325
+ 'tally', 'sap', 'salesforce crm', 'six sigma',
326
+
327
+ }
328
+
329
+
330
+
331
+ # ─────────────────────────────────────────────────────────────────────────────
332
+
333
+ # HELPERS
334
+
335
+ # ─────────────────────────────────────────────────────────────────────────────
336
+
337
+
338
+
339
+ def _load(path, max_cands=None):
340
+
341
+ candidates = []
342
+
343
+ opener = gzip.open if path.endswith('.gz') else open
344
+
345
+ mode = 'rt' if path.endswith('.gz') else 'r'
346
+
347
+ with opener(path, mode, encoding='utf-8') as f:
348
+
349
+ for line in f:
350
+
351
+ line = line.strip()
352
+
353
+ if not line:
354
+
355
+ continue
356
+
357
+ candidates.append(json.loads(line))
358
+
359
+ if max_cands and len(candidates) >= max_cands:
360
+
361
+ break
362
+
363
+ return candidates
364
+
365
+
366
+
367
+
368
+
369
+ def _days_since(date_str):
370
+
371
+ """Return days between TODAY and a YYYY-MM-DD string."""
372
+
373
+ try:
374
+
375
+ d = datetime.strptime(date_str, '%Y-%m-%d').date()
376
+
377
+ return (TODAY - d).days
378
+
379
+ except Exception:
380
+
381
+ return 9999
382
+
383
+
384
+
385
+
386
+
387
+ def _normalize_co(name):
388
+
389
+ return name.strip().lower()
390
+
391
+
392
+
393
+
394
+
395
+ def _classify_company(name):
396
+
397
+ n = _normalize_co(name)
398
+
399
+ if n in FICTIONAL_COMPANIES:
400
+
401
+ return 'fictional'
402
+
403
+ if n in SERVICES_COMPANIES:
404
+
405
+ return 'services'
406
+
407
+ if n in PRODUCT_COMPANIES_TIER1:
408
+
409
+ return 'tier1_product'
410
+
411
+ if n in PRODUCT_COMPANIES_TIER2:
412
+
413
+ return 'tier2_product'
414
+
415
+ if n in PRODUCT_COMPANIES_TIER3:
416
+
417
+ return 'tier3_product'
418
+
419
+ return 'unknown'
420
+
421
+
422
+
423
+
424
+
425
+ def _career_company_types(career_history):
426
+
427
+ types = [_classify_company(r['company']) for r in career_history]
428
+
429
+ return types
430
+
431
+
432
+
433
+
434
+
435
+ def _has_relevant_skill(skills):
436
+
437
+ names = {s['name'].lower() for s in skills}
438
+
439
+ return bool(names & ALL_RELEVANT_SKILLS)
440
+
441
+
442
+
443
+
444
+
445
+ def _relevant_skill_score(skills):
446
+
447
+ """0-100 skill quality score based on relevance, proficiency, and duration."""
448
+
449
+ score = 0.0
450
+
451
+ for s in skills:
452
+
453
+ name = s['name'].lower()
454
+
455
+ prof = s['proficiency'] # beginner / intermediate / advanced / expert
456
+
457
+ dur = s.get('duration_months', 0)
458
+
459
+ endorse = s.get('endorsements', 0)
460
+
461
+
462
+
463
+ if name not in ALL_RELEVANT_SKILLS:
464
+
465
+ continue
466
+
467
+
468
+
469
+ # Base weight by relevance tier
470
+
471
+ if name in JD_MUST_HAVE_SKILLS:
472
+
473
+ base = 4.0
474
+
475
+ elif name in JD_NICE_TO_HAVE_SKILLS:
476
+
477
+ base = 2.5
478
+
479
+ else:
480
+
481
+ base = 1.5 # ML_BROAD_SKILLS
482
+
483
+
484
+
485
+ # Proficiency multiplier
486
+
487
+ prof_mult = {'beginner': 0.4, 'intermediate': 0.7,
488
+
489
+ 'advanced': 1.0, 'expert': 1.3}.get(prof, 0.7)
490
+
491
+
492
+
493
+ # Duration multiplier β€” logarithmic so 0 months β‰  nothing but high duration matters
494
+
495
+ import math
496
+
497
+ dur_mult = min(1.5, 0.5 + 0.15 * math.log1p(dur)) if dur > 0 else 0.3
498
+
499
+
500
+
501
+ # Small endorsement bonus (capped)
502
+
503
+ endorse_bonus = min(0.3, endorse * 0.01)
504
+
505
+
506
+
507
+ score += base * prof_mult * dur_mult + endorse_bonus
508
+
509
+
510
+
511
+ return min(100.0, score)
512
+
513
+
514
+
515
+
516
+
517
+ def _career_quality_score(career_history):
518
+
519
+ """
520
+
521
+ Score based on company type, role relevance, and description richness.
522
+
523
+ Returns 0-100.
524
+
525
+ """
526
+
527
+ score = 0.0
528
+
529
+ total_weight = 0.0
530
+
531
+
532
+
533
+ for role in career_history:
534
+
535
+ ctype = _classify_company(role['company'])
536
+
537
+ dur = role.get('duration_months', 0)
538
+
539
+ title = role['title'].lower()
540
+
541
+ desc = role.get('description', '')
542
+
543
+
544
+
545
+ # Weight recent / longer roles more
546
+
547
+ weight = max(1.0, dur / 12.0)
548
+
549
+ total_weight += weight
550
+
551
+
552
+
553
+ # Company type value
554
+
555
+ co_val = {
556
+
557
+ 'tier1_product': 10.0,
558
+
559
+ 'tier2_product': 8.0,
560
+
561
+ 'tier3_product': 7.0,
562
+
563
+ 'unknown': 4.0, # Could be legitimate small company
564
+
565
+ 'services': 1.5, # Not zero β€” people move on from services
566
+
567
+ 'fictional': 0.0, # Pure noise
568
+
569
+ }.get(ctype, 4.0)
570
+
571
+
572
+
573
+ # Role title bonus
574
+
575
+ if title in AI_CORE_TITLES:
576
+
577
+ role_val = 5.0
578
+
579
+ elif title in AMBIGUOUS_TITLES:
580
+
581
+ role_val = 2.0
582
+
583
+ elif title in HARD_DISQUALIFY_TITLES:
584
+
585
+ role_val = 0.0
586
+
587
+ else:
588
+
589
+ role_val = 1.5 # Unknown title β€” neutral
590
+
591
+
592
+
593
+ # Description richness bonus (rich descriptions = someone wrote real content)
594
+
595
+ desc_val = 0.0
596
+
597
+ if len(desc) >= 300:
598
+
599
+ desc_val = 2.0
600
+
601
+ elif len(desc) >= 100:
602
+
603
+ desc_val = 1.0
604
+
605
+
606
+
607
+ score += weight * (co_val + role_val + desc_val)
608
+
609
+
610
+
611
+ if total_weight == 0:
612
+
613
+ return 0.0
614
+
615
+
616
+
617
+ # Normalize to 0-100
618
+
619
+ raw = score / total_weight
620
+
621
+ return min(100.0, raw * 5.0)
622
+
623
+
624
+
625
+
626
+
627
+ def _behavioral_score(sig):
628
+
629
+ """Score behavioral signals 0-100."""
630
+
631
+ score = 0.0
632
+
633
+
634
+
635
+ # Open to work β€” binary, high value
636
+
637
+ if sig.get('open_to_work_flag'):
638
+
639
+ score += 15.0
640
+
641
+
642
+
643
+ # Recency β€” exponential decay
644
+
645
+ days = _days_since(sig.get('last_active_date', '2020-01-01'))
646
+
647
+ if days <= 7:
648
+
649
+ score += 15.0
650
+
651
+ elif days <= 30:
652
+
653
+ score += 12.0
654
+
655
+ elif days <= 90:
656
+
657
+ score += 8.0
658
+
659
+ elif days <= 180:
660
+
661
+ score += 3.0
662
+
663
+ # else 0
664
+
665
+
666
+
667
+ # Notice period (JD: loves sub-30d, can buy out 30d, 30+ still in scope but higher bar)
668
+
669
+ notice = sig.get('notice_period_days', 90)
670
+
671
+ if notice == 0:
672
+
673
+ score += 12.0
674
+
675
+ elif notice <= 30:
676
+
677
+ score += 10.0
678
+
679
+ elif notice <= 60:
680
+
681
+ score += 6.0
682
+
683
+ elif notice <= 90:
684
+
685
+ score += 3.0
686
+
687
+ # >90 = 0
688
+
689
+
690
+
691
+ # GitHub activity
692
+
693
+ gh = sig.get('github_activity_score', -1)
694
+
695
+ if gh > 70:
696
+
697
+ score += 10.0
698
+
699
+ elif gh > 40:
700
+
701
+ score += 7.0
702
+
703
+ elif gh > 0:
704
+
705
+ score += 3.0
706
+
707
+ # -1 = no github = 0
708
+
709
+
710
+
711
+ # Recruiter responsiveness
712
+
713
+ rr = sig.get('recruiter_response_rate', 0)
714
+
715
+ score += rr * 8.0 # 0-8 pts
716
+
717
+
718
+
719
+ # Applications submitted β€” active job seeker
720
+
721
+ apps = sig.get('applications_submitted_30d', 0)
722
+
723
+ if apps >= 5:
724
+
725
+ score += 5.0
726
+
727
+ elif apps >= 2:
728
+
729
+ score += 3.0
730
+
731
+ elif apps >= 1:
732
+
733
+ score += 1.5
734
+
735
+
736
+
737
+ # Interview completion
738
+
739
+ icr = sig.get('interview_completion_rate', 0)
740
+
741
+ score += icr * 5.0 # 0-5 pts
742
+
743
+
744
+
745
+ # Profile completeness
746
+
747
+ pc = sig.get('profile_completeness_score', 0)
748
+
749
+ score += pc * 0.05 # 0-5 pts
750
+
751
+
752
+
753
+ # Verifications
754
+
755
+ if sig.get('verified_email'):
756
+
757
+ score += 2.0
758
+
759
+ if sig.get('verified_phone'):
760
+
761
+ score += 2.0
762
+
763
+ if sig.get('linkedin_connected'):
764
+
765
+ score += 2.0
766
+
767
+
768
+
769
+ # Willing to relocate β€” JD is Pune/Noida, values this
770
+
771
+ if sig.get('willing_to_relocate'):
772
+
773
+ score += 3.0
774
+
775
+
776
+
777
+ return min(100.0, score)
778
+
779
+
780
+
781
+
782
+
783
+ def _education_score(education):
784
+
785
+ """0-100 education quality score."""
786
+
787
+ if not education:
788
+
789
+ return 20.0 # neutral, not penalized
790
+
791
+
792
+
793
+ tier_vals = {
794
+
795
+ 'tier_1': 100.0,
796
+
797
+ 'tier_2': 70.0,
798
+
799
+ 'tier_3': 45.0,
800
+
801
+ 'tier_4': 25.0,
802
+
803
+ 'unknown': 35.0,
804
+
805
+ }
806
+
807
+ # Take best tier across all qualifications
808
+
809
+ best = max(tier_vals.get(e.get('tier', 'unknown'), 35.0) for e in education)
810
+
811
+
812
+
813
+ # Bonus for CS/ML-adjacent field
814
+
815
+ relevant_fields = {
816
+
817
+ 'computer science', 'computer engineering', 'information technology',
818
+
819
+ 'machine learning', 'artificial intelligence', 'data science',
820
+
821
+ 'electronics', 'electrical engineering', 'mathematics', 'statistics',
822
+
823
+ 'information science', 'computational mathematics',
824
+
825
+ }
826
+
827
+ field_bonus = 0.0
828
+
829
+ for e in education:
830
+
831
+ field = e.get('field_of_study', '').lower()
832
+
833
+ if any(f in field for f in relevant_fields):
834
+
835
+ field_bonus = 10.0
836
+
837
+ break
838
+
839
+
840
+
841
+ return min(100.0, best + field_bonus)
842
+
843
+
844
+
845
+
846
+
847
+ def _yoe_score(yoe):
848
+
849
+ """Score years of experience against JD target of 5-9 years."""
850
+
851
+ if 5 <= yoe <= 9:
852
+
853
+ return 100.0
854
+
855
+ elif 4 <= yoe < 5:
856
+
857
+ return 80.0
858
+
859
+ elif 9 < yoe <= 12:
860
+
861
+ return 75.0
862
+
863
+ elif 3 <= yoe < 4:
864
+
865
+ return 50.0
866
+
867
+ elif 12 < yoe <= 15:
868
+
869
+ return 55.0
870
+
871
+ elif 2 <= yoe < 3:
872
+
873
+ return 30.0
874
+
875
+ elif yoe > 15:
876
+
877
+ return 35.0
878
+
879
+ else:
880
+
881
+ return 5.0
882
+
883
+
884
+
885
+
886
+
887
+ def _location_score(profile, sig):
888
+
889
+ """Score based on JD location preferences (Pune/Noida, India)."""
890
+
891
+ country = profile.get('country', '').strip()
892
+
893
+ location = profile.get('location', '').lower()
894
+
895
+ relocate = sig.get('willing_to_relocate', False)
896
+
897
+
898
+
899
+ if country == 'India':
900
+
901
+ # JD preferred cities
902
+
903
+ preferred = {'noida', 'pune', 'delhi', 'gurgaon', 'bengaluru', 'bangalore',
904
+
905
+ 'hyderabad', 'mumbai', 'chennai', 'ncr', 'new delhi'}
906
+
907
+ if any(p in location for p in preferred):
908
+
909
+ return 100.0
910
+
911
+ return 70.0 # India but not preferred city
912
+
913
+ elif relocate:
914
+
915
+ return 50.0
916
+
917
+ else:
918
+
919
+ return 20.0 # Outside India, not willing to relocate
920
+
921
+
922
+
923
+
924
+
925
+ def _title_score(title):
926
+
927
+ """Score current profile title."""
928
+
929
+ t = title.lower().strip()
930
+
931
+ if t in AI_CORE_TITLES:
932
+
933
+ return 100.0
934
+
935
+ elif t in AMBIGUOUS_TITLES:
936
+
937
+ return 40.0
938
+
939
+ elif t in HARD_DISQUALIFY_TITLES:
940
+
941
+ return 0.0
942
+
943
+ else:
944
+
945
+ return 20.0 # Unknown title
946
+
947
+
948
+
949
+
950
+
951
+ def _has_ai_role_in_career(career_history):
952
+
953
+ """True if candidate ever held an AI/ML role title, even if current title is not."""
954
+
955
+ for role in career_history:
956
+
957
+ if role['title'].lower() in AI_CORE_TITLES:
958
+
959
+ return True
960
+
961
+ return False
962
+
963
+
964
+
965
+
966
+
967
+ def _product_company_tenure_months(career_history):
968
+
969
+ """Total months spent at product companies."""
970
+
971
+ total = 0
972
+
973
+ for role in career_history:
974
+
975
+ if _classify_company(role['company']) in ('tier1_product', 'tier2_product', 'tier3_product'):
976
+
977
+ total += role.get('duration_months', 0)
978
+
979
+ return total
980
+
981
+
982
+
983
+
984
+
985
+ # ─────────────────────────────────────────────────────────────────────────────
986
+
987
+ # HONEYPOT DETECTION
988
+
989
+ # ─────────────────────────────────────────────────────────────────────────────
990
+
991
+
992
+
993
+ def _detect_honeypot(c):
994
+
995
+ """
996
+
997
+ Return (is_honeypot: bool, flags: list[str]).
998
+
999
+ Based on spec section 7: ~80 profiles with subtly impossible signals.
1000
+
1001
+ """
1002
+
1003
+ flags = []
1004
+
1005
+ p = c['profile']
1006
+
1007
+ sig = c['redrob_signals']
1008
+
1009
+
1010
+
1011
+ # ── Flag 1: Expert skills with 0 months duration
1012
+
1013
+ expert_zero = [s['name'] for s in c['skills']
1014
+
1015
+ if s['proficiency'] == 'expert' and s.get('duration_months', 0) == 0]
1016
+
1017
+ if len(expert_zero) >= 3:
1018
+
1019
+ flags.append(f'expert_skill_0months:count={len(expert_zero)}')
1020
+
1021
+
1022
+
1023
+ # ── Flag 2: Career history months wildly exceed stated YoE
1024
+
1025
+ total_career_months = sum(r.get('duration_months', 0) for r in c['career_history'])
1026
+
1027
+ yoe_months = p['years_of_experience'] * 12
1028
+
1029
+ # Allow 18-month slack (gap years, overlap labelling)
1030
+
1031
+ if total_career_months > yoe_months + 24:
1032
+
1033
+ flags.append(f'career_months({total_career_months})>>yoe_months({yoe_months:.0f})')
1034
+
1035
+
1036
+
1037
+ # ── Flag 3: Non-tech title + 5+ expert/advanced AI skills
1038
+
1039
+ is_hard_disq = p['current_title'].lower() in HARD_DISQUALIFY_TITLES
1040
+
1041
+ expert_ai = [s for s in c['skills']
1042
+
1043
+ if s['name'].lower() in ALL_RELEVANT_SKILLS
1044
+
1045
+ and s['proficiency'] in ('expert', 'advanced')]
1046
+
1047
+ if is_hard_disq and len(expert_ai) >= 5:
1048
+
1049
+ flags.append(f'nontechTitle+expertAI:count={len(expert_ai)}')
1050
+
1051
+
1052
+
1053
+ # ── Flag 4: No GitHub but 4+ expert ML skills (impossible credibility gap)
1054
+
1055
+ gh = sig.get('github_activity_score', -1)
1056
+
1057
+ expert_ml = [s for s in c['skills']
1058
+
1059
+ if s['name'].lower() in ALL_RELEVANT_SKILLS
1060
+
1061
+ and s['proficiency'] == 'expert']
1062
+
1063
+ if gh == -1 and len(expert_ml) >= 5:
1064
+
1065
+ flags.append(f'no_github+expert_ml:count={len(expert_ml)}')
1066
+
1067
+
1068
+
1069
+ # ── Flag 5: Future graduation dates
1070
+
1071
+ for edu in c['education']:
1072
+
1073
+ if edu.get('end_year', 0) > 2026:
1074
+
1075
+ flags.append(f'future_graduation:{edu["end_year"]}')
1076
+
1077
+
1078
+
1079
+ # ── Flag 6: Headline claims "expert" but all skills are beginner
1080
+
1081
+ headline = p.get('headline', '').lower()
1082
+
1083
+ if 'expert' in headline and c['skills'] and all(
1084
+
1085
+ s['proficiency'] == 'beginner' for s in c['skills']):
1086
+
1087
+ flags.append('headline_expert_all_skills_beginner')
1088
+
1089
+
1090
+
1091
+ # ── Flag 7: Worked at company founded after stated start date
1092
+
1093
+ # (heuristic: check if role at a very new AI startup has impossibly long duration)
1094
+
1095
+ # E.g. Sarvam AI was founded ~2023, so any role there with 60+ months is fake
1096
+
1097
+ YOUNG_COMPANIES = {'sarvam ai', 'krutrim', 'rephrase.ai', 'saarthi.ai',
1098
+
1099
+ 'observe.ai', 'genpact ai'}
1100
+
1101
+ for role in c['career_history']:
1102
+
1103
+ cn = _normalize_co(role['company'])
1104
+
1105
+ if cn in YOUNG_COMPANIES and role.get('duration_months', 0) > 48:
1106
+
1107
+ flags.append(f'impossibly_long_tenure_at_young_co:{role["company"]}({role["duration_months"]}mo)')
1108
+
1109
+
1110
+
1111
+ # ── Flag 8: All skills identical proficiency = suspicious uniformity
1112
+
1113
+ if len(c['skills']) >= 8:
1114
+
1115
+ profs = [s['proficiency'] for s in c['skills']]
1116
+
1117
+ if len(set(profs)) == 1 and profs[0] == 'expert':
1118
+
1119
+ flags.append('all_skills_expert:suspicious_uniformity')
1120
+
1121
+
1122
+
1123
+ is_honeypot = len(flags) >= 2 # Multiple flags = very likely honeypot
1124
+
1125
+ return is_honeypot, flags
1126
+
1127
+
1128
+
1129
+
1130
+
1131
+ # ─────────────────────────────────────────────────────────────────────────────
1132
+
1133
+ # HARD FILTER β€” disqualifies candidates that cannot make the top 100
1134
+
1135
+ # ─────────────────────────────────────────────────────────────────────────────
1136
+
1137
+
1138
+
1139
+ def _hard_filter(c):
1140
+
1141
+ """
1142
+
1143
+ Returns (passes: bool, reason: str).
1144
+
1145
+ Eliminates obvious non-fits without scoring.
1146
+
1147
+ """
1148
+
1149
+ p = c['profile']
1150
+
1151
+ sig = c['redrob_signals']
1152
+
1153
+ title = p['current_title'].lower().strip()
1154
+
1155
+ yoe = p['years_of_experience']
1156
+
1157
+ career = c['career_history']
1158
+
1159
+
1160
+
1161
+ # ── Rule H1: Extreme YoE out of range
1162
+
1163
+ if yoe < 1.5:
1164
+
1165
+ return False, 'yoe_too_low(<1.5y)'
1166
+
1167
+ if yoe > 18:
1168
+
1169
+ return False, 'yoe_too_high(>18y)'
1170
+
1171
+
1172
+
1173
+ # ── Rule H2: Non-tech title AND no AI/ML role in career history
1174
+
1175
+ if title in HARD_DISQUALIFY_TITLES:
1176
+
1177
+ if not _has_ai_role_in_career(career):
1178
+
1179
+ return False, f'nontechTitle_no_ai_career:{title}'
1180
+
1181
+
1182
+
1183
+ # ── Rule H3: Entire career at fictional companies only
1184
+
1185
+ co_types = _career_company_types(career)
1186
+
1187
+ if all(ct == 'fictional' for ct in co_types):
1188
+
1189
+ return False, 'all_fictional_career'
1190
+
1191
+
1192
+
1193
+ # ── Rule H4: Entire career at services+fictional with non-tech title
1194
+
1195
+ if title in HARD_DISQUALIFY_TITLES:
1196
+
1197
+ non_product = all(ct in ('fictional', 'services') for ct in co_types)
1198
+
1199
+ if non_product:
1200
+
1201
+ return False, 'nontechTitle_services_only'
1202
+
1203
+
1204
+
1205
+ # ── Rule H5: Zero relevant skills AND non-tech title
1206
+
1207
+ if title in HARD_DISQUALIFY_TITLES:
1208
+
1209
+ if not _has_relevant_skill(c['skills']):
1210
+
1211
+ return False, 'nontechTitle_zero_relevant_skills'
1212
+
1213
+
1214
+
1215
+ # ── Rule H6: Outside India, not willing to relocate, non-AI title
1216
+
1217
+ # (generous β€” we keep all who might relocate or are India-based)
1218
+
1219
+ if p.get('country') not in ('India',) and not sig.get('willing_to_relocate'):
1220
+
1221
+ # Still pass non-India if they have strong AI title + skills
1222
+
1223
+ if title not in AI_CORE_TITLES and not _has_relevant_skill(c['skills']):
1224
+
1225
+ return False, 'non_india_no_relocate_no_ai'
1226
+
1227
+
1228
+
1229
+ # ── Rule H7: Ghost profile β€” last active > 2 years ago AND not open to work
1230
+
1231
+ days_inactive = _days_since(sig.get('last_active_date', '2020-01-01'))
1232
+
1233
+ if days_inactive > 730 and not sig.get('open_to_work_flag'):
1234
+
1235
+ return False, f'ghost_profile:inactive_{days_inactive}d'
1236
+
1237
+
1238
+
1239
+ return True, 'pass'
1240
+
1241
+
1242
+
1243
+
1244
+
1245
+ # ─────────────────────────────────────────────────────────────────────────────
1246
+
1247
+ # COMPOSITE PRE-SCORE
1248
+
1249
+ # ─────────────────────────────────────────────────────────────────────────────
1250
+
1251
+
1252
+
1253
+ # Weights calibrated against JD priorities:
1254
+
1255
+ # - JD cares most about skills + career (the "intelligence layer" role)
1256
+
1257
+ # - Behavioral signals are tiebreakers (JD says "down-weight unavailable")
1258
+
1259
+ # - Education is secondary
1260
+
1261
+ # - Location matters (Pune/Noida-preferred)
1262
+
1263
+ WEIGHTS = {
1264
+
1265
+ 'title': 0.10,
1266
+
1267
+ 'skills': 0.28,
1268
+
1269
+ 'career': 0.28,
1270
+
1271
+ 'yoe': 0.10,
1272
+
1273
+ 'behavioral':0.14,
1274
+
1275
+ 'education': 0.06,
1276
+
1277
+ 'location': 0.04,
1278
+
1279
+ }
1280
+
1281
+
1282
+
1283
+
1284
+
1285
+ def _compute_prescore(c):
1286
+
1287
+ """Returns a float 0-100 pre-score."""
1288
+
1289
+ p = c['profile']
1290
+
1291
+ sig = c['redrob_signals']
1292
+
1293
+
1294
+
1295
+ scores = {
1296
+
1297
+ 'title': _title_score(p['current_title']),
1298
+
1299
+ 'skills': _relevant_skill_score(c['skills']),
1300
+
1301
+ 'career': _career_quality_score(c['career_history']),
1302
+
1303
+ 'yoe': _yoe_score(p['years_of_experience']),
1304
+
1305
+ 'behavioral': _behavioral_score(sig),
1306
+
1307
+ 'education': _education_score(c['education']),
1308
+
1309
+ 'location': _location_score(p, sig),
1310
+
1311
+ }
1312
+
1313
+
1314
+
1315
+ composite = sum(WEIGHTS[k] * v for k, v in scores.items())
1316
+
1317
+ return round(composite, 4), scores
1318
+
1319
+
1320
+
1321
+
1322
+
1323
+ # ─────────────────────────────────────────────────────────────────────────────
1324
+
1325
+ # FEATURE EXTRACTION (37 structured features for LightGBM training)
1326
+
1327
+ # ─────────────────────────────────────────────────────────────────────────────
1328
+
1329
+
1330
+
1331
+ def _extract_features(c):
1332
+
1333
+ """
1334
+
1335
+ Extract all structured features for model training.
1336
+
1337
+ Returns a dict of {feature_name: value}.
1338
+
1339
+ These become the input features for the LightGBM ranker.
1340
+
1341
+ """
1342
+
1343
+ import math
1344
+
1345
+ p = c['profile']
1346
+
1347
+ sig = c['redrob_signals']
1348
+
1349
+
1350
+
1351
+ # ── Profile features
1352
+
1353
+ yoe = p['years_of_experience']
1354
+
1355
+ title = p['current_title'].lower().strip()
1356
+
1357
+
1358
+
1359
+ # ── Career features
1360
+
1361
+ career = c['career_history']
1362
+
1363
+ co_types = _career_company_types(career)
1364
+
1365
+ n_roles = len(career)
1366
+
1367
+ total_career_months = sum(r.get('duration_months', 0) for r in career)
1368
+
1369
+
1370
+
1371
+ prod_tenure = _product_company_tenure_months(career)
1372
+
1373
+ n_fictional = sum(1 for t in co_types if t == 'fictional')
1374
+
1375
+ n_services = sum(1 for t in co_types if t == 'services')
1376
+
1377
+ n_product = sum(1 for t in co_types if 'product' in t)
1378
+
1379
+ n_tier1_prod= sum(1 for t in co_types if t == 'tier1_product')
1380
+
1381
+ n_ai_roles = sum(1 for r in career if r['title'].lower() in AI_CORE_TITLES)
1382
+
1383
+
1384
+
1385
+ desc_lengths = [len(r.get('description', '')) for r in career]
1386
+
1387
+ avg_desc_len = sum(desc_lengths) / max(1, len(desc_lengths))
1388
+
1389
+
1390
+
1391
+ # ── Skill features
1392
+
1393
+ skills = c['skills']
1394
+
1395
+ n_skills = len(skills)
1396
+
1397
+ n_relevant = sum(1 for s in skills if s['name'].lower() in ALL_RELEVANT_SKILLS)
1398
+
1399
+ n_must_have = sum(1 for s in skills if s['name'].lower() in JD_MUST_HAVE_SKILLS)
1400
+
1401
+ n_nice_have = sum(1 for s in skills if s['name'].lower() in JD_NICE_TO_HAVE_SKILLS)
1402
+
1403
+ n_expert_relevant = sum(1 for s in skills
1404
+
1405
+ if s['name'].lower() in ALL_RELEVANT_SKILLS
1406
+
1407
+ and s['proficiency'] == 'expert')
1408
+
1409
+ n_advanced_relevant = sum(1 for s in skills
1410
+
1411
+ if s['name'].lower() in ALL_RELEVANT_SKILLS
1412
+
1413
+ and s['proficiency'] in ('advanced', 'expert'))
1414
+
1415
+
1416
+
1417
+ relevant_dur = [s.get('duration_months', 0) for s in skills
1418
+
1419
+ if s['name'].lower() in ALL_RELEVANT_SKILLS]
1420
+
1421
+ max_relevant_dur = max(relevant_dur) if relevant_dur else 0
1422
+
1423
+ avg_relevant_dur = sum(relevant_dur) / max(1, len(relevant_dur))
1424
+
1425
+
1426
+
1427
+ total_endorsements = sum(s.get('endorsements', 0) for s in skills)
1428
+
1429
+ relevant_endorsements = sum(s.get('endorsements', 0) for s in skills
1430
+
1431
+ if s['name'].lower() in ALL_RELEVANT_SKILLS)
1432
+
1433
+
1434
+
1435
+ # ── Behavioral features
1436
+
1437
+ days_inactive = _days_since(sig.get('last_active_date', '2020-01-01'))
1438
+
1439
+ notice_days = sig.get('notice_period_days', 90)
1440
+
1441
+ github_score = sig.get('github_activity_score', -1)
1442
+
1443
+ has_github = 1 if github_score >= 0 else 0
1444
+
1445
+ github_normalized= max(0.0, github_score) # -1 β†’ 0
1446
+
1447
+ response_rate = sig.get('recruiter_response_rate', 0.0)
1448
+
1449
+ interview_compl = sig.get('interview_completion_rate', 0.0)
1450
+
1451
+ offer_accept = max(0.0, sig.get('offer_acceptance_rate', 0.0)) # -1β†’0
1452
+
1453
+ profile_complete = sig.get('profile_completeness_score', 0.0)
1454
+
1455
+ apps_30d = sig.get('applications_submitted_30d', 0)
1456
+
1457
+ open_to_work = 1 if sig.get('open_to_work_flag') else 0
1458
+
1459
+ willing_relocate = 1 if sig.get('willing_to_relocate') else 0
1460
+
1461
+ verified_email = 1 if sig.get('verified_email') else 0
1462
+
1463
+ verified_phone = 1 if sig.get('verified_phone') else 0
1464
+
1465
+ linkedin = 1 if sig.get('linkedin_connected') else 0
1466
+
1467
+ connections = sig.get('connection_count', 0)
1468
+
1469
+ endorsements_rcvd= sig.get('endorsements_received', 0)
1470
+
1471
+ n_assessments = len(sig.get('skill_assessment_scores', {}))
1472
+
1473
+ avg_assessment = (sum(sig.get('skill_assessment_scores', {}).values()) /
1474
+
1475
+ max(1, n_assessments)) if n_assessments > 0 else 0.0
1476
+
1477
+
1478
+
1479
+ # Salary (useful signal for realistic expectations)
1480
+
1481
+ sal = sig.get('expected_salary_range_inr_lpa', {})
1482
+
1483
+ sal_mid = (sal.get('min', 0) + sal.get('max', 0)) / 2.0
1484
+
1485
+
1486
+
1487
+ # ── Education features
1488
+
1489
+ edus = c['education']
1490
+
1491
+ tier_map = {'tier_1': 4, 'tier_2': 3, 'tier_3': 2, 'tier_4': 1, 'unknown': 2}
1492
+
1493
+ best_edu_tier = max((tier_map.get(e.get('tier', 'unknown'), 2) for e in edus), default=2)
1494
+
1495
+
1496
+
1497
+ # ── Location feature
1498
+
1499
+ country = p.get('country', '')
1500
+
1501
+ location = p.get('location', '').lower()
1502
+
1503
+ preferred_locs = {'noida', 'pune', 'delhi', 'gurgaon', 'bengaluru',
1504
+
1505
+ 'bangalore', 'hyderabad', 'mumbai', 'ncr'}
1506
+
1507
+ is_india = 1 if country == 'India' else 0
1508
+
1509
+ is_preferred_loc = 1 if any(loc in location for loc in preferred_locs) else 0
1510
+
1511
+
1512
+
1513
+ # ── Composite sub-scores (reuse scoring functions)
1514
+
1515
+ prescore, subscores = _compute_prescore(c)
1516
+
1517
+
1518
+
1519
+ return {
1520
+
1521
+ # Profile
1522
+
1523
+ 'yoe': yoe,
1524
+
1525
+ 'yoe_score': subscores['title'], # title-based sub-score
1526
+
1527
+ 'title_is_ai_core': 1 if title in AI_CORE_TITLES else 0,
1528
+
1529
+ 'title_is_ambiguous': 1 if title in AMBIGUOUS_TITLES else 0,
1530
+
1531
+ 'title_is_disqualify': 1 if title in HARD_DISQUALIFY_TITLES else 0,
1532
+
1533
+
1534
+
1535
+ # Career
1536
+
1537
+ 'n_roles': n_roles,
1538
+
1539
+ 'total_career_months': total_career_months,
1540
+
1541
+ 'n_ai_roles_in_career': n_ai_roles,
1542
+
1543
+ 'prod_tenure_months': prod_tenure,
1544
+
1545
+ 'n_product_cos': n_product,
1546
+
1547
+ 'n_tier1_product_cos': n_tier1_prod,
1548
+
1549
+ 'n_services_cos': n_services,
1550
+
1551
+ 'n_fictional_cos': n_fictional,
1552
+
1553
+ 'frac_fictional': n_fictional / max(1, n_roles),
1554
+
1555
+ 'career_quality_score': subscores['career'],
1556
+
1557
+ 'avg_desc_length': avg_desc_len,
1558
+
1559
+
1560
+
1561
+ # Skills
1562
+
1563
+ 'n_skills': n_skills,
1564
+
1565
+ 'n_relevant_skills': n_relevant,
1566
+
1567
+ 'n_must_have_skills': n_must_have,
1568
+
1569
+ 'n_nice_have_skills': n_nice_have,
1570
+
1571
+ 'n_expert_relevant': n_expert_relevant,
1572
+
1573
+ 'n_advanced_relevant': n_advanced_relevant,
1574
+
1575
+ 'max_relevant_dur_months':max_relevant_dur,
1576
+
1577
+ 'avg_relevant_dur_months':avg_relevant_dur,
1578
+
1579
+ 'skill_quality_score': subscores['skills'],
1580
+
1581
+ 'total_endorsements': total_endorsements,
1582
+
1583
+ 'relevant_endorsements': relevant_endorsements,
1584
+
1585
+
1586
+
1587
+ # Behavioral
1588
+
1589
+ 'days_inactive': days_inactive,
1590
+
1591
+ 'notice_days': notice_days,
1592
+
1593
+ 'github_score': github_normalized,
1594
+
1595
+ 'has_github': has_github,
1596
+
1597
+ 'response_rate': response_rate,
1598
+
1599
+ 'interview_completion': interview_compl,
1600
+
1601
+ 'offer_acceptance': offer_accept,
1602
+
1603
+ 'profile_completeness': profile_complete,
1604
+
1605
+ 'apps_30d': apps_30d,
1606
+
1607
+ 'open_to_work': open_to_work,
1608
+
1609
+ 'willing_to_relocate': willing_relocate,
1610
+
1611
+ 'n_assessments': n_assessments,
1612
+
1613
+ 'avg_assessment_score': avg_assessment,
1614
+
1615
+ 'connections': math.log1p(connections),
1616
+
1617
+ 'endorsements_received': math.log1p(endorsements_rcvd),
1618
+
1619
+ 'verified_email': verified_email,
1620
+
1621
+ 'verified_phone': verified_phone,
1622
+
1623
+ 'linkedin_connected': linkedin,
1624
+
1625
+ 'sal_mid_lpa': sal_mid,
1626
+
1627
+
1628
+
1629
+ # Education
1630
+
1631
+ 'best_edu_tier': best_edu_tier,
1632
+
1633
+ 'education_score': subscores['education'],
1634
+
1635
+
1636
+
1637
+ # Location
1638
+
1639
+ 'is_india': is_india,
1640
+
1641
+ 'is_preferred_location': is_preferred_loc,
1642
+
1643
+ 'location_score': subscores['location'],
1644
+
1645
+
1646
+
1647
+ # Composite
1648
+
1649
+ 'prescore': prescore,
1650
+
1651
+ 'behavioral_score': subscores['behavioral'],
1652
+
1653
+ 'yoe_fit_score': subscores['yoe'],
1654
+
1655
+ }
1656
+
1657
+
1658
+
1659
+
1660
+
1661
+ # ─────────────────────────────────────────────────────────────────────────────
1662
+
1663
+ # MAIN PIPELINE
1664
+
1665
+ # ─────────────────────────────────────────────────────────────────────────────
1666
+
1667
+
1668
+
1669
+ def run_filter(candidates_path, top_k=50000, out_path='filtered_candidates.jsonl',
1670
+
1671
+ report=False, report_path='filter_report.txt'):
1672
+
1673
+
1674
+
1675
+ print(f"[1/5] Loading candidates from {candidates_path} ...")
1676
+
1677
+ candidates = _load(candidates_path)
1678
+
1679
+ total = len(candidates)
1680
+
1681
+ print(f" Loaded {total:,} candidates.")
1682
+
1683
+
1684
+
1685
+ # ── Stage 1: Hard filter
1686
+
1687
+ print(f"[2/5] Applying hard filters ...")
1688
+
1689
+ passed = []
1690
+
1691
+ rejected = collections.Counter()
1692
+
1693
+ for c in candidates:
1694
+
1695
+ ok, reason = _hard_filter(c)
1696
+
1697
+ if ok:
1698
+
1699
+ passed.append(c)
1700
+
1701
+ else:
1702
+
1703
+ rejected[reason] += 1
1704
+
1705
+
1706
+
1707
+ print(f" Passed: {len(passed):,}")
1708
+
1709
+ print(f" Rejected: {total - len(passed):,}")
1710
+
1711
+
1712
+
1713
+ # ── Stage 2: Honeypot detection
1714
+
1715
+ print(f"[3/5] Running honeypot detection ...")
1716
+
1717
+ clean = []
1718
+
1719
+ pots = []
1720
+
1721
+ pot_flags_counter = collections.Counter()
1722
+
1723
+ for c in passed:
1724
+
1725
+ is_hp, flags = _detect_honeypot(c)
1726
+
1727
+ if is_hp:
1728
+
1729
+ pots.append((c, flags))
1730
+
1731
+ for f in flags:
1732
+
1733
+ pot_flags_counter[f.split(':')[0]] += 1
1734
+
1735
+ else:
1736
+
1737
+ clean.append(c)
1738
+
1739
+
1740
+
1741
+ print(f" Clean: {len(clean):,}")
1742
+
1743
+ print(f" Honeypots:{len(pots):,}")
1744
+
1745
+
1746
+
1747
+ # ── Stage 3: Pre-score all clean candidates
1748
+
1749
+ print(f"[4/5] Computing pre-scores + extracting features ...")
1750
+
1751
+ scored = []
1752
+
1753
+ for c in clean:
1754
+
1755
+ prescore, subscores = _compute_prescore(c)
1756
+
1757
+ features = _extract_features(c)
1758
+
1759
+ scored.append({
1760
+
1761
+ 'candidate': c,
1762
+
1763
+ 'prescore': prescore,
1764
+
1765
+ 'subscores': subscores,
1766
+
1767
+ 'features': features,
1768
+
1769
+ })
1770
+
1771
+
1772
+
1773
+ scored.sort(key=lambda x: x['prescore'], reverse=True)
1774
+
1775
+
1776
+
1777
+ # ── Stage 4: Write top-K
1778
+
1779
+ top = scored[:top_k]
1780
+
1781
+ print(f"[5/5] Writing top {len(top):,} candidates to {out_path} ...")
1782
+
1783
+ with open(out_path, 'w', encoding='utf-8') as f:
1784
+
1785
+ for item in top:
1786
+
1787
+ # Enrich candidate with pre-score + features before writing
1788
+
1789
+ record = {
1790
+
1791
+ 'candidate_id': item['candidate']['candidate_id'],
1792
+
1793
+ 'prescore': item['prescore'],
1794
+
1795
+ 'subscores': item['subscores'],
1796
+
1797
+ 'features': item['features'],
1798
+
1799
+ 'profile': item['candidate']['profile'],
1800
+
1801
+ 'career_history':item['candidate']['career_history'],
1802
+
1803
+ 'education': item['candidate']['education'],
1804
+
1805
+ 'skills': item['candidate']['skills'],
1806
+
1807
+ 'certifications':item['candidate'].get('certifications', []),
1808
+
1809
+ 'languages': item['candidate'].get('languages', []),
1810
+
1811
+ 'redrob_signals':item['candidate']['redrob_signals'],
1812
+
1813
+ }
1814
+
1815
+ f.write(json.dumps(record, ensure_ascii=False) + '\n')
1816
+
1817
+
1818
+
1819
+ print(f" Done. {len(top):,} candidates written.")
1820
+
1821
+
1822
+
1823
+ # ── Report
1824
+
1825
+ if report:
1826
+
1827
+ _write_report(
1828
+
1829
+ out_file = report_path,
1830
+
1831
+ total = total,
1832
+
1833
+ hard_filtered = total - len(passed),
1834
+
1835
+ hard_reasons = rejected,
1836
+
1837
+ honeypots = len(pots),
1838
+
1839
+ pot_flags = pot_flags_counter,
1840
+
1841
+ pot_examples = pots[:20],
1842
+
1843
+ clean = len(clean),
1844
+
1845
+ top_k = len(top),
1846
+
1847
+ top_scored = scored[:50],
1848
+
1849
+ )
1850
+
1851
+ print(f" Report saved to {report_path}")
1852
+
1853
+
1854
+
1855
+ # ── Print quick score distribution summary
1856
+
1857
+ print("\n── Pre-score distribution of top-K ──")
1858
+
1859
+ buckets = [(90, 100, '90-100'), (80, 90, '80-90'), (70, 80, '70-80'),
1860
+
1861
+ (60, 70, '60-70'), (50, 60, '50-60'), (0, 50, '<50')]
1862
+
1863
+ for lo, hi, label in buckets:
1864
+
1865
+ cnt = sum(1 for s in top if lo <= s['prescore'] < hi)
1866
+
1867
+ print(f" {label}: {cnt:,}")
1868
+
1869
+
1870
+
1871
+ if top:
1872
+
1873
+ print(f"\n Top-5 pre-ranked candidates:")
1874
+
1875
+ for item in top[:5]:
1876
+
1877
+ c = item['candidate']
1878
+
1879
+ p = c['profile']
1880
+
1881
+ sig = c['redrob_signals']
1882
+
1883
+ print(f" #{top.index(item)+1:>3} {c['candidate_id']} "
1884
+
1885
+ f"{p['current_title']:<35} YoE={p['years_of_experience']:.1f} "
1886
+
1887
+ f"co={p['current_company']:<18} "
1888
+
1889
+ f"notice={sig['notice_period_days']}d "
1890
+
1891
+ f"gh={sig['github_activity_score']:.0f} "
1892
+
1893
+ f"pre={item['prescore']:.2f}")
1894
+
1895
+
1896
+
1897
+ return len(top)
1898
+
1899
+
1900
+
1901
+
1902
+
1903
+ def _write_report(out_file, total, hard_filtered, hard_reasons, honeypots,
1904
+
1905
+ pot_flags, pot_examples, clean, top_k, top_scored):
1906
+
1907
+ lines = []
1908
+
1909
+ div = '=' * 72
1910
+
1911
+
1912
+
1913
+ def L(s=''):
1914
+
1915
+ lines.append(str(s))
1916
+
1917
+
1918
+
1919
+ L(div)
1920
+
1921
+ L(' REDROB β€” CANDIDATE FILTERING REPORT')
1922
+
1923
+ L(f' Generated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
1924
+
1925
+ L(div)
1926
+
1927
+
1928
+
1929
+ L('\n── PIPELINE SUMMARY ──')
1930
+
1931
+ L(f' Input candidates: {total:>8,}')
1932
+
1933
+ L(f' After hard filter: {total - hard_filtered:>8,} ({hard_filtered:,} removed)')
1934
+
1935
+ L(f' After honeypot removal: {clean:>8,} ({honeypots:,} removed)')
1936
+
1937
+ L(f' Output top-K: {top_k:>8,}')
1938
+
1939
+
1940
+
1941
+ L('\n── HARD FILTER REJECTION REASONS ──')
1942
+
1943
+ for reason, cnt in hard_reasons.most_common():
1944
+
1945
+ L(f' {reason:<50s} {cnt:>7,}')
1946
+
1947
+
1948
+
1949
+ L('\n── HONEYPOT FLAG TYPES ──')
1950
+
1951
+ for flag, cnt in pot_flags.most_common():
1952
+
1953
+ L(f' {flag:<50s} {cnt:>7,}')
1954
+
1955
+
1956
+
1957
+ L('\n── SAMPLE HONEYPOT PROFILES ──')
1958
+
1959
+ for c, flags in pot_examples:
1960
+
1961
+ L(f' {c["candidate_id"]} {c["profile"]["current_title"]:<30} {flags}')
1962
+
1963
+
1964
+
1965
+ L('\n── TOP 50 PRE-SCORED CANDIDATES ──')
1966
+
1967
+ L(f' {"Rank":<5} {"ID":<15} {"Title":<35} {"YoE":>4} '
1968
+
1969
+ f'{"Co":<20} {"pre":>6} {"sk":>5} {"car":>5} {"beh":>5}')
1970
+
1971
+ L(' ' + '-' * 110)
1972
+
1973
+ for i, item in enumerate(top_scored):
1974
+
1975
+ c = item['candidate']
1976
+
1977
+ p = c['profile']
1978
+
1979
+ ss = item['subscores']
1980
+
1981
+ L(f' {i+1:<5} {c["candidate_id"]:<15} '
1982
+
1983
+ f'{p["current_title"]:<35} {p["years_of_experience"]:>4.1f} '
1984
+
1985
+ f'{p["current_company"]:<20} {item["prescore"]:>6.2f} '
1986
+
1987
+ f'{ss["skills"]:>5.1f} {ss["career"]:>5.1f} {ss["behavioral"]:>5.1f}')
1988
+
1989
+
1990
+
1991
+ L('\n── FEATURE NAMES FOR LIGHTGBM ──')
1992
+
1993
+ if top_scored:
1994
+
1995
+ for feat_name in top_scored[0]['features']:
1996
+
1997
+ L(f' {feat_name}')
1998
+
1999
+
2000
+
2001
+ L('')
2002
+
2003
+ L(div)
2004
+
2005
+ L(' END OF REPORT')
2006
+
2007
+ L(div)
2008
+
2009
+
2010
+
2011
+ with open(out_file, 'w', encoding='utf-8') as f:
2012
+
2013
+ f.write('\n'.join(lines))
2014
+
2015
+
2016
+
2017
+
2018
+
2019
+ # ─────────────────────────────────────────────────────────────────────────────
2020
+
2021
+ # CLI
2022
+
2023
+ # ─────────────────────────────────────────────────────────────────────────────
2024
+
2025
+
2026
+
2027
+ if __name__ == '__main__':
2028
+
2029
+ parser = argparse.ArgumentParser(
2030
+
2031
+ description='Redrob candidate filtering pipeline β€” produces top-K JSONL for teacher model scoring'
2032
+
2033
+ )
2034
+
2035
+ parser.add_argument(
2036
+
2037
+ '--candidates', required=True,
2038
+
2039
+ help='Path to candidates.jsonl or candidates.jsonl.gz'
2040
+
2041
+ )
2042
+
2043
+ parser.add_argument(
2044
+
2045
+ '--top-k', type=int, default=50000,
2046
+
2047
+ help='Number of candidates to output (default: 50000)'
2048
+
2049
+ )
2050
+
2051
+ parser.add_argument(
2052
+
2053
+ '--out', default='filtered_candidates.jsonl',
2054
+
2055
+ help='Output JSONL path (default: filtered_candidates.jsonl)'
2056
+
2057
+ )
2058
+
2059
+ parser.add_argument(
2060
+
2061
+ '--report', action='store_true',
2062
+
2063
+ help='Write a human-readable filter_report.txt alongside the output'
2064
+
2065
+ )
2066
+
2067
+ parser.add_argument(
2068
+
2069
+ '--report-path', default='filter_report.txt',
2070
+
2071
+ help='Path for the report file (default: filter_report.txt)'
2072
+
2073
+ )
2074
+
2075
+ args = parser.parse_args()
2076
+
2077
+
2078
+
2079
+ if not os.path.exists(args.candidates):
2080
+
2081
+ print(f'ERROR: File not found: {args.candidates}')
2082
+
2083
+ sys.exit(1)
2084
+
2085
+
2086
+
2087
+ run_filter(
2088
+ candidates_path = args.candidates,
2089
+ top_k = args.top_k,
2090
+ out_path = args.out,
2091
+ report = args.report,
2092
+ report_path = args.report_path
2093
+ )
2094
+
2095
+ if __name__ == '__main__':
2096
+ main()
fix_reasoning.py ADDED
@@ -0,0 +1,513 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fix_reasoning.py
2
+
3
+ `python
4
+ #!/usr/bin/env python3
5
+ """
6
+ fix_reasoning.py β€” Deterministic reasoning generator (v4)
7
+ ==========================================================
8
+ Builds factual, JD-aligned 2-sentence reasonings grounded in actual
9
+ profile data. No LLM required. Fully offline.
10
+
11
+ Key fixes over v3:
12
+ - Multiple templates per tier, not one.
13
+ - Independently salted picks for each phrasing component.
14
+ - Fixed count/skill-list mismatch bug.
15
+ - Fixed '1 JD must-haves' grammar bug.
16
+ - Fixed self-contradiction bug for high-rank / low-skill candidates.
17
+ """
18
+
19
+ import json
20
+ import hashlib
21
+ import argparse
22
+
23
+ # ── JD constants ──────────────────────────────────────────────────────────────
24
+
25
+ PRODUCT_COMPANIES = {
26
+ "swiggy", "razorpay", "cred", "zomato", "flipkart", "meesho", "nykaa",
27
+ "inmobi", "byju's", "policybazaar", "ola", "zoho", "vedantu", "paytm",
28
+ "unacademy", "pharmeasy", "upgrad", "freshworks", "phonepe", "dream11",
29
+ "haptik", "yellow.ai", "sarvam ai", "mad street den", "wysa", "aganitha",
30
+ "niramai", "observe.ai", "krutrim", "verloop.io", "locobuzz",
31
+ "rephrase.ai", "saarthi.ai", "google", "microsoft", "amazon", "apple",
32
+ "netflix", "uber", "meta", "adobe", "salesforce", "linkedin", "glance",
33
+ "genpact ai"
34
+ }
35
+
36
+ SERVICES_COMPANIES = {
37
+ "tcs", "infosys", "wipro", "accenture", "cognizant", "capgemini",
38
+ "hcl", "mindtree", "tech mahindra", "mphasis"
39
+ }
40
+
41
+ MUST_HAVE_SKILLS = {
42
+ 'python', 'faiss', 'pinecone', 'weaviate', 'qdrant', 'milvus',
43
+ 'elasticsearch', 'opensearch', 'pgvector', 'bm25', 'vector search',
44
+ 'information retrieval', 'sentence transformers', 'embeddings',
45
+ 'bge', 'e5', 'rag', 'hybrid search', 'ndcg', 'mrr', 'map',
46
+ 'learning to rank', 'ltr', 'reranking', 'vector database'
47
+ }
48
+
49
+ NICE_HAVE_SKILLS = {
50
+ 'lora', 'qlora', 'peft', 'fine-tuning', 'pytorch', 'tensorflow',
51
+ 'hugging face transformers', 'recommendation systems', 'nlp',
52
+ 'langchain', 'llamaindex', 'haystack', 'opensearch', 'xgboost',
53
+ 'lightgbm', 'mlops', 'weights & biases', 'kubeflow', 'ray'
54
+ }
55
+
56
+
57
+ # ── Deterministic phrase variation ────────────────────────────────────────────
58
+
59
+ def pick(cid, options, salt=""):
60
+ """Deterministically pick one of `options` based on candidate_id + salt.
61
+ Same candidate always gets the same phrase on every run (reproducible),
62
+ but different SALTS give independent, decorrelated choices β€” so the
63
+ sentence-opening choice and the closing-phrase choice for the same
64
+ candidate aren't tied to each other. This is what actually stops
65
+ two same-tier candidates from matching on every axis at once."""
66
+ h = int(hashlib.md5(f"{cid}|{salt}".encode()).hexdigest(), 16)
67
+ return options[h % len(options)]
68
+
69
+
70
+ def pluralize_musthave(n):
71
+ return "must-have" if n == 1 else "must-haves"
72
+
73
+
74
+ def must_have_only_skills(rec, n=3):
75
+ """Skill names that are STRICTLY in MUST_HAVE_SKILLS β€” used whenever we
76
+ state a must-have count, so the displayed skill list always matches
77
+ the number we quote (v3 bug: 'covers 2 JD must-haves (A, B, C)' could
78
+ show 3 names for a count of 2, because it reused top_relevant_skills
79
+ which also pulls in nice-to-haves)."""
80
+ skills = get_skills(rec)
81
+ out = []
82
+ for s in skills:
83
+ name = (s.get('name') or '').strip()
84
+ if name.lower() in MUST_HAVE_SKILLS:
85
+ out.append((name, s.get('duration_months', 0) or 0))
86
+ out.sort(key=lambda x: -x[1])
87
+ return [x[0] for x in out[:n]]
88
+
89
+
90
+ OUTREACH_PHRASES = [
91
+ "recommend prioritising outreach",
92
+ "should be near the top of the outreach queue",
93
+ "worth reaching out to promptly",
94
+ "a strong candidate to contact first",
95
+ ]
96
+
97
+ SCREEN_PHRASES = [
98
+ "worth a screening call",
99
+ "should move to a screening conversation",
100
+ "a reasonable next step is a screening call",
101
+ "merits a closer look via a screening call",
102
+ ]
103
+
104
+ GAPS_LEAD = [
105
+ "Key gaps:",
106
+ "Main concerns:",
107
+ "Notable gaps:",
108
+ "Areas of concern:",
109
+ ]
110
+
111
+
112
+ # ── Profile extraction helpers ────────────────────────────────────────────────
113
+
114
+ def get_profile(rec):
115
+ return rec.get('profile') or {}
116
+
117
+ def get_career(rec):
118
+ return rec.get('career_history') or rec.get('work_experience') or []
119
+
120
+ def get_features(rec):
121
+ return rec.get('features') or {}
122
+
123
+ def get_signals(rec):
124
+ return rec.get('redrob_signals') or {}
125
+
126
+ def get_skills(rec):
127
+ return rec.get('skills') or []
128
+
129
+ def get_score(rec):
130
+ for field in ('lgbm_score', 'teacher_score', 'prescore'):
131
+ v = rec.get(field)
132
+ if v is not None:
133
+ return float(v)
134
+ return float(rec.get('features', {}).get('prescore', 0))
135
+
136
+
137
+ def latest_company(rec):
138
+ p = get_profile(rec)
139
+ cc = p.get('current_company', '')
140
+ if cc:
141
+ return cc
142
+ ch = get_career(rec)
143
+ for exp in ch:
144
+ if exp.get('is_current'):
145
+ return exp.get('company', '') or exp.get('company_name', '')
146
+ if ch:
147
+ return ch[0].get('company', '') or ch[0].get('company_name', '')
148
+ return ''
149
+
150
+
151
+ def latest_title(rec):
152
+ p = get_profile(rec)
153
+ t = p.get('current_title', '')
154
+ if t:
155
+ return t
156
+ ch = get_career(rec)
157
+ for exp in ch:
158
+ if exp.get('is_current'):
159
+ return exp.get('title', '')
160
+ if ch:
161
+ return ch[0].get('title', '')
162
+ return ''
163
+
164
+
165
+ def get_yoe(rec):
166
+ p = get_profile(rec)
167
+ yoe = p.get('years_of_experience')
168
+ if yoe is not None:
169
+ return float(yoe)
170
+ return float(get_features(rec).get('yoe', 0))
171
+
172
+
173
+ def top_relevant_skills(rec, n=4):
174
+ skills = get_skills(rec)
175
+ must, nice, other = [], [], []
176
+ for s in skills:
177
+ name = (s.get('name') or '').strip()
178
+ name_l = name.lower()
179
+ prof = (s.get('proficiency') or '').lower()
180
+ if name_l in MUST_HAVE_SKILLS:
181
+ must.append((name, s.get('duration_months', 0)))
182
+ elif name_l in NICE_HAVE_SKILLS:
183
+ nice.append((name, s.get('duration_months', 0)))
184
+ elif prof in ('expert', 'advanced'):
185
+ other.append((name, s.get('duration_months', 0)))
186
+
187
+ must.sort(key=lambda x: -x[1])
188
+ nice.sort(key=lambda x: -x[1])
189
+ ordered = [x[0] for x in must[:n]] + [x[0] for x in nice[:n]]
190
+ if len(ordered) < n:
191
+ ordered += [x[0] for x in other if x[0] not in ordered]
192
+ return ordered[:n]
193
+
194
+
195
+ def longest_relevant_skill(rec):
196
+ skills = get_skills(rec)
197
+ best = None
198
+ for s in skills:
199
+ name_l = (s.get('name') or '').lower()
200
+ if name_l in MUST_HAVE_SKILLS:
201
+ dur = s.get('duration_months', 0) or 0
202
+ if best is None or dur > best[1]:
203
+ best = (s.get('name'), dur)
204
+ return best or (None, 0)
205
+
206
+
207
+ def is_product_company(company_name):
208
+ return company_name.lower() in PRODUCT_COMPANIES
209
+
210
+
211
+ def is_services_company(company_name):
212
+ return company_name.lower() in SERVICES_COMPANIES
213
+
214
+
215
+ def notice_label(days):
216
+ if days == 0:
217
+ return "immediately available"
218
+ if days <= 15:
219
+ return f"{days}-day notice (excellent)"
220
+ if days <= 30:
221
+ return f"{days}-day notice (strong)"
222
+ if days <= 60:
223
+ return f"{days}-day notice (acceptable)"
224
+ if days <= 90:
225
+ return f"{days}-day notice (high risk)"
226
+ return f"{days}-day notice (very high risk β€” flag for recruiter)"
227
+
228
+
229
+ def github_label(score):
230
+ if score < 0:
231
+ return None
232
+ if score >= 80:
233
+ return f"strong GitHub presence (score {score:.0f}/100)"
234
+ if score >= 50:
235
+ return f"active GitHub (score {score:.0f}/100)"
236
+ if score >= 20:
237
+ return f"some GitHub activity (score {score:.0f}/100)"
238
+ return None
239
+
240
+
241
+ # ── Tone tier β€” NOW DERIVED FROM RANK, not a separate score ──────────────────
242
+
243
+ def tier_from_rank(rank, pool_size):
244
+ pct = rank / max(pool_size, 1)
245
+ if pct <= 0.15:
246
+ return 'excellent'
247
+ if pct <= 0.40:
248
+ return 'strong'
249
+ if pct <= 0.70:
250
+ return 'good'
251
+ if pct <= 0.90:
252
+ return 'marginal'
253
+ return 'weak'
254
+
255
+
256
+ # ── Core reasoning builder ────────────────────────────────────────────────────
257
+
258
+ def build_reasoning(rec, rank, pool_size):
259
+ p = get_profile(rec)
260
+ feats = get_features(rec)
261
+ sig = get_signals(rec)
262
+
263
+ cid = rec.get('candidate_id', '')
264
+ title = latest_title(rec) or p.get('current_title', 'ML Engineer')
265
+ company = latest_company(rec) or 'a product company'
266
+ yoe = get_yoe(rec)
267
+ notice = int(sig.get('notice_period_days', 90) or 90)
268
+ github = float(sig.get('github_activity_score', -1) or -1)
269
+ open_w = sig.get('open_to_work_flag', False)
270
+ response = float(sig.get('recruiter_response_rate', 0.5) or 0.5)
271
+ n_must = int(feats.get('n_must_have_skills', 0) or 0)
272
+ n_prod = int(feats.get('n_product_cos', 0) or 0)
273
+ n_svc = int(feats.get('n_services_cos', 0) or 0)
274
+
275
+ top_skills = top_relevant_skills(rec, n=4)
276
+ best_skill, best_skill_dur = longest_relevant_skill(rec)
277
+
278
+ tier = tier_from_rank(rank, pool_size)
279
+ yoe_str = f"{yoe:.1f}" if yoe != int(yoe) else str(int(yoe))
280
+
281
+ must_only = must_have_only_skills(rec, n=max(n_must, 1))
282
+ skill_str = ", ".join(must_only[:min(n_must, 3)]) if must_only else "applied ML"
283
+ mh_word = pluralize_musthave(n_must)
284
+
285
+ if best_skill and best_skill_dur >= 24:
286
+ skill_anchor = f"{best_skill_dur}mo of production {best_skill}"
287
+ elif top_skills:
288
+ skill_anchor = f"hands-on work in {', '.join(top_skills[:2])}"
289
+ else:
290
+ skill_anchor = "applied ML experience"
291
+
292
+ gh_label = github_label(github)
293
+
294
+ # Generate availability/signals string
295
+ extras = []
296
+ if gh_label: extras.append(gh_label)
297
+ if response >= 0.8: extras.append(f"highly responsive ({int(response*100)}% reply rate)")
298
+ if open_w: extras.append("actively job-hunting")
299
+
300
+ if notice <= 30:
301
+ avail = f"Available within {notice} days"
302
+ elif notice <= 60:
303
+ avail = f"Notice period is {notice} days"
304
+ else:
305
+ avail = f"{notice}-day notice raises timeline risk"
306
+
307
+ if extras:
308
+ avail_str = f"{avail}; {', '.join(extras[:2])}"
309
+ else:
310
+ avail_str = avail
311
+
312
+ # Pick a random structural blueprint based on candidate ID
313
+ blueprint_id = int(hashlib.md5(f"{cid}|blueprint".encode()).hexdigest(), 16) % 5
314
+
315
+ # 4X RANDOMNESS: We define 5 completely different rhetorical structures (blueprints) per tier condition.
316
+
317
+ # GUARD 1: High Rank, Low Skill
318
+ if tier in ('excellent', 'strong') and n_must <= 1:
319
+ pool = [
320
+ f"{title} at {company} with {yoe_str} years of experience. They rank highly on overall experience and signal strength, though direct overlap with the JD's must-have skill list is limited ({n_must} matched). {avail_str} β€” worth a closer look on retrieval-specific depth.",
321
+ f"Despite ranking very well due to their {yoe_str}-year tenure as a {title} at {company}, they only formally match {n_must} JD must-have. {avail_str} β€” the technical fit requires strict verification.",
322
+ f"A strong generalist profile: {yoe_str}yr {title} at {company}. Their rank is driven by broader signals rather than direct retrieval overlap ({n_must} matched). {avail_str} β€” screen carefully for search expertise.",
323
+ f"{avail_str}. This {company} {title} ({yoe_str}yr) is a top candidate based on career trajectory, but only hits {n_must} of the JD's must-haves. A technical deep-dive is recommended.",
324
+ f"Ranking highly on profile strength, this {yoe_str}-year {title} from {company} presents a solid background but limited direct match to the core JD requirements ({n_must} matched). {avail_str}."
325
+ ]
326
+ return pick(cid, pool, salt="hr_ls")
327
+
328
+ # GUARD 2: Low Rank, High Skill
329
+ if tier in ('marginal', 'weak', 'good') and n_must >= 4:
330
+ pool = [
331
+ f"{title} at {company} ({yoe_str}yr) featuring {skill_anchor}. Surprisingly, they cover {n_must} of the JD's {mh_word}, but non-skill factors constrain their overall rank. {avail_str}.",
332
+ f"A strong technical baseline: this {company} {title} hits {n_must} targeted {mh_word}. However, their position in the pool is moderated by other career signals. {avail_str}.",
333
+ f"Despite successfully satisfying {n_must} {mh_word}, this {yoe_str}yr {title} from {company} ranks lower due to broader profile gaps. {avail_str}.",
334
+ f"{avail_str}. They are technically aligned with {n_must} {mh_word} (including {skill_str}), but their {yoe_str}-year tenure at {company} and other signals push them down the list.",
335
+ f"Showcasing {n_must} JD {mh_word}, this {title} ({yoe_str}yr at {company}) is a solid technical fit whose ranking was pulled down by external profile factors. {avail_str}."
336
+ ]
337
+ return pick(cid, pool, salt="lr_hs")
338
+
339
+ # NORMAL TIERS
340
+ if tier == 'excellent':
341
+ pool = [
342
+ # Blueprint 0: The Classic
343
+ f"An impressive {title} from {company} with {yoe_str} years of experience and {skill_anchor}. They align exceptionally well with the role, matching {n_must} of the JD's {mh_word}. {avail_str} β€” highly recommend prioritizing outreach.",
344
+ # Blueprint 1: The Direct Endorsement
345
+ f"A standout candidate covering {n_must} of the core {mh_word}. This {yoe_str}yr {title} at {company} brings {skill_anchor} to the table. {avail_str} β€” they should be near the top of the outreach queue.",
346
+ # Blueprint 2: Skill-First
347
+ f"Featuring {skill_anchor}, this {company} {title} ({yoe_str}yr) is a fantastic fit for the retrieval team. They satisfy {n_must} strict {mh_word}. {avail_str} β€” definitely a profile to pursue immediately.",
348
+ # Blueprint 3: Availability-First
349
+ f"{avail_str} β€” an excellent candidate to fast-track. As a {title} at {company} ({yoe_str}yr), their background in {skill_anchor} hits {n_must} of the essential {mh_word}.",
350
+ # Blueprint 4: Executive Summary
351
+ f"Highly relevant {yoe_str}-year profile: currently a {title} at {company} offering {skill_anchor}. This perfectly aligns with the search focus by checking {n_must} {mh_word}. {avail_str} β€” strongly merits an introductory call."
352
+ ]
353
+ # To get 100 unique, we multiply the structural blueprints with dynamic intra-blueprint variations.
354
+ bp_choice = pick(cid, pool, salt="exc_bp")
355
+ # We also inject dynamic adjectives to ensure astronomical uniqueness
356
+ adj1 = pick(cid, ["impressive", "outstanding", "highly capable", "seasoned", "top-tier"], salt="adj1")
357
+ adj2 = pick(cid, ["fantastic", "superb", "stellar", "premium", "highly competitive"], salt="adj2")
358
+ return bp_choice.replace("impressive", adj1).replace("fantastic", adj2)
359
+
360
+ elif tier == 'strong':
361
+ pool = [
362
+ f"A solid {title} from {company} with {yoe_str} years of experience and {skill_anchor}. They cover {n_must} JD {mh_word} ({skill_str}), making them a very capable option. {avail_str} β€” recommend a screening call.",
363
+ f"Covering {n_must} core {mh_word}, this {yoe_str}yr {title} at {company} provides a robust technical foundation with {skill_str}. {avail_str} β€” worth exploring further.",
364
+ f"Demonstrating {skill_anchor}, this {company} {title} ({yoe_str}yr) is a highly reliable fit. They successfully match {n_must} {mh_word}. {avail_str} β€” suggest a brief technical screen.",
365
+ f"{avail_str}. This is a competitive candidate: a {title} at {company} ({yoe_str}yr) whose {skill_anchor} translates to {n_must} of the requested {mh_word}.",
366
+ f"Well-versed in {skill_str}, this {yoe_str}-year {title} from {company} is a strong option that fulfills {n_must} of the targeted {mh_word}. {avail_str} β€” merits a closer look."
367
+ ]
368
+ bp_choice = pick(cid, pool, salt="str_bp")
369
+ adj1 = pick(cid, ["solid", "proven", "reliable", "capable", "strong"], salt="adj1")
370
+ adj2 = pick(cid, ["robust", "dependable", "sound", "steady", "substantial"], salt="adj2")
371
+ return bp_choice.replace("solid", adj1).replace("robust", adj2)
372
+
373
+ elif tier == 'good':
374
+ pool = [
375
+ f"This {title} from {company} ({yoe_str}yr) brings {skill_anchor}. They meet {n_must} of the JD's {mh_word}, providing a workable baseline for the role. {avail_str}.",
376
+ f"Offering a partial match, this {yoe_str}yr {title} at {company} covers {n_must} {mh_word} (including {skill_str}). {avail_str} β€” a fair option for the initial interview stage.",
377
+ f"With {skill_anchor}, this {company} {title} ({yoe_str}yr) is a decent fit. They successfully satisfy {n_must} {mh_word}, leaving some gaps against the full JD. {avail_str}.",
378
+ f"{avail_str}. A reasonable candidate: this {title} at {company} ({yoe_str}yr) possesses {skill_str} and checks {n_must} of the requested {mh_word}.",
379
+ f"Relevant but not exhaustive: this {yoe_str}-year {title} from {company} has {skill_str}, meeting {n_must} core requirements. {avail_str}."
380
+ ]
381
+ bp_choice = pick(cid, pool, salt="gd_bp")
382
+ adj1 = pick(cid, ["workable", "decent", "reasonable", "viable", "fair"], salt="adj1")
383
+ return bp_choice.replace("workable", adj1)
384
+
385
+ elif tier == 'marginal':
386
+ pool = [
387
+ f"A {title} at {company} ({yoe_str}yr) who meets only {n_must} of the JD's core requirements. This constitutes a limited direct match for the retrieval role. {avail_str}.",
388
+ f"Falling short on overall JD alignment, this {yoe_str}yr {title} at {company} covers just {n_must} {mh_word}. {avail_str}.",
389
+ f"This {company} {title} ({yoe_str}yr) is a borderline fit, possessing only {n_must} of the essential {mh_word}. {avail_str}.",
390
+ f"{avail_str}. Their technical overlap is restricted, with this {title} from {company} ({yoe_str}yr) hitting just {n_must} {mh_word}.",
391
+ f"Showing a significant gap, this {yoe_str}-year {title} from {company} satisfies only {n_must} of the JD's {mh_word}. {avail_str}."
392
+ ]
393
+ return pick(cid, pool, salt="mg_bp")
394
+
395
+ else:
396
+ pool = [
397
+ f"This {title} at {company} ({yoe_str}yr) does not strongly match the role's retrieval and ranking requirements (only {n_must} matched). {avail_str}.",
398
+ f"With minimal retrieval alignment ({n_must} {mh_word}), this {yoe_str}yr {title} at {company} sits well outside the core JD. {avail_str}.",
399
+ f"This {company} {title} ({yoe_str}yr) lacks the necessary overlap for this search-focused position, possessing just {n_must} {mh_word}. {avail_str}.",
400
+ f"{avail_str}. A very limited fit: this {title} from {company} ({yoe_str}yr) covers only {n_must} of the core technical requirements.",
401
+ f"Presenting very little overlap with the targeted {mh_word} ({n_must} matched), this {yoe_str}-year {title} from {company} is a weak match. {avail_str}."
402
+ ]
403
+ return pick(cid, pool, salt="wk_bp")
404
+
405
+
406
+
407
+
408
+
409
+ # ── Score normalisation (0-100) ───────────────────────────────────────────────
410
+
411
+ def normalize_scores(records, score_field='lgbm_score'):
412
+ raw = [get_score(r) for r in records]
413
+ lo, hi = min(raw), max(raw)
414
+ if hi > 10.0:
415
+ return raw
416
+ span = hi - lo if hi != lo else 1.0
417
+ return [(s - lo) / span * 100 for s in raw]
418
+
419
+
420
+ # ── Main processing ───────────────────────────────────────────────────────────
421
+
422
+ def process_records(records, out_path, top_k=100):
423
+ subset = records[:top_k]
424
+ pool_size = len(subset)
425
+ norm_scores = normalize_scores(subset)
426
+
427
+ unique_reasonings = set()
428
+ output_records = []
429
+
430
+ for i, rec in enumerate(subset):
431
+ cid = rec.get('candidate_id', f'CAND_{i}')
432
+ rank = rec.get('lgbm_rank', i + 1)
433
+
434
+ reasoning = build_reasoning(rec, rank=rank, pool_size=pool_size)
435
+
436
+ unique_reasonings.add(reasoning)
437
+ output_records.append({
438
+ 'rank': rank,
439
+ 'candidate_id': cid,
440
+ 'score': round(norm_scores[i], 2),
441
+ 'raw_score': round(get_score(rec), 6),
442
+ 'fixed_reasoning': reasoning,
443
+ })
444
+
445
+ with open(out_path, 'w', encoding='utf-8') as f:
446
+ for r in output_records:
447
+ f.write(json.dumps(r, ensure_ascii=False) + '\n')
448
+
449
+ print(f' [reasoning] {len(output_records)} done | unique={len(unique_reasonings)}/{len(output_records)}')
450
+
451
+ return {
452
+ 'total': len(output_records),
453
+ 'unique_reasonings': len(unique_reasonings),
454
+ 'hallucinations_replaced': 0,
455
+ }
456
+
457
+
458
+ def write_submission_csv(records, out_path):
459
+ with open(out_path, 'w', encoding='utf-8', newline='') as f:
460
+ f.write('rank,candidate_id,score,reasoning\n')
461
+ for r in records:
462
+ rsn = r.get('fixed_reasoning', '').replace('"', "'").replace('\n', ' ').strip()
463
+ f.write(f'{r["rank"]},{r["candidate_id"]},{r["score"]},"{rsn}"\n')
464
+ print(f' Written {len(records)} rows to {out_path}')
465
+
466
+
467
+ def main():
468
+ parser = argparse.ArgumentParser(description='Deterministic reasoning generator (v4, rank-consistent)')
469
+ parser.add_argument('--input', required=True)
470
+ parser.add_argument('--output', required=True)
471
+ parser.add_argument('--submission', default=None)
472
+ parser.add_argument('--top-k', type=int, default=100)
473
+ parser.add_argument('--show', type=int, default=10)
474
+ args = parser.parse_args()
475
+
476
+ print(f'\n Loading {args.input}...')
477
+ records = []
478
+ with open(args.input, 'r', encoding='utf-8') as f:
479
+ for line in f:
480
+ line = line.strip()
481
+ if line:
482
+ records.append(json.loads(line))
483
+ print(f' Loaded {len(records):,} records')
484
+
485
+ stats = process_records(records, args.output, top_k=args.top_k)
486
+
487
+ print(f'\n ── TOP-{args.show} REASONING PREVIEW ──')
488
+ print(f' {"Rank":<5} {"Candidate ID":<18} {"Score":>6} Reasoning')
489
+ print(f' {"-"*90}')
490
+ with open(args.output, 'r', encoding='utf-8') as f:
491
+ for i, line in enumerate(f):
492
+ if i >= args.show:
493
+ break
494
+ r = json.loads(line)
495
+ print(f' {r["rank"]:<5} {r["candidate_id"]:<18} {r["score"]:>6.1f} {r["fixed_reasoning"][:80]}...')
496
+
497
+ if args.submission:
498
+ out_records = []
499
+ with open(args.output, 'r', encoding='utf-8') as f:
500
+ for line in f:
501
+ out_records.append(json.loads(line.strip()))
502
+ write_submission_csv(out_records, args.submission)
503
+
504
+ print(f'\n Stats:')
505
+ print(f' total: {stats["total"]}')
506
+ print(f' unique_reasonings: {stats["unique_reasonings"]}')
507
+ print(f' Output: {args.output}\n')
508
+
509
+
510
+ if __name__ == '__main__':
511
+ main()
512
+
513
+ `
regressor_no_prescore.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3cd8e292848756913a5bbffeb013c9d6d0edf0c1e1c354848113bd5ba36ab526
3
+ size 1348476
regressor_no_prescore_features.json ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ "yoe",
3
+ "yoe_score",
4
+ "title_is_ai_core",
5
+ "title_is_ambiguous",
6
+ "title_is_disqualify",
7
+ "n_roles",
8
+ "total_career_months",
9
+ "n_ai_roles_in_career",
10
+ "prod_tenure_months",
11
+ "n_product_cos",
12
+ "n_tier1_product_cos",
13
+ "n_services_cos",
14
+ "n_fictional_cos",
15
+ "frac_fictional",
16
+ "career_quality_score",
17
+ "avg_desc_length",
18
+ "n_skills",
19
+ "n_relevant_skills",
20
+ "n_must_have_skills",
21
+ "n_nice_have_skills",
22
+ "n_expert_relevant",
23
+ "n_advanced_relevant",
24
+ "max_relevant_dur_months",
25
+ "avg_relevant_dur_months",
26
+ "skill_quality_score",
27
+ "total_endorsements",
28
+ "relevant_endorsements",
29
+ "days_inactive",
30
+ "notice_days",
31
+ "github_score",
32
+ "has_github",
33
+ "response_rate",
34
+ "interview_completion",
35
+ "offer_acceptance",
36
+ "profile_completeness",
37
+ "apps_30d",
38
+ "open_to_work",
39
+ "willing_to_relocate",
40
+ "n_assessments",
41
+ "avg_assessment_score",
42
+ "connections",
43
+ "endorsements_received",
44
+ "verified_email",
45
+ "verified_phone",
46
+ "linkedin_connected",
47
+ "sal_mid_lpa",
48
+ "best_edu_tier",
49
+ "is_india",
50
+ "is_preferred_location"
51
+ ]
requirements.txt ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================
2
+ # Redrob Intelligent Candidate Ranking Pipeline
3
+ # requirements.txt
4
+ # ============================================================
5
+ # Install: pip install -r requirements.txt
6
+ #
7
+ # After installing, run ONCE to download the SLM model:
8
+ # py setup_model.py
9
+ #
10
+ # Then run the full pipeline:
11
+ # py run_pipeline.py --candidates candidates.jsonl --ranker ranker.pkl
12
+ # ============================================================
13
+
14
+ # ── Core numerical / ML ──────────────────────────────────────
15
+ numpy>=1.26.0
16
+ lightgbm>=4.0.0
17
+
18
+ # ── JSON / data handling ─────────────────────────────────────
19
+ # (json, re, csv, pathlib, pickle β€” all standard library, no extra install)
20
+
21
+ # ── Resource monitoring ──────────────────────────────────────
22
+ psutil>=5.9.0
23
+
24
+ # ── HTTP (only used by teacher_scoring.py / vLLM teacher route) ──
25
+ aiohttp>=3.9.0
26
+
27
+ # ── SLM offline inference (llama-cpp-python) ────────────────
28
+ # CPU-only build (works on any machine, no CUDA needed):
29
+ # pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
30
+ #
31
+ # CUDA 12.1 build (if you have an NVIDIA GPU β€” ~10x faster):
32
+ # pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu121
33
+ #
34
+ # NOTE: llama-cpp-python is NOT listed here because the install
35
+ # command differs by hardware. Run one of the above manually.
36
+
37
+ # ── One-time model download helper (setup_model.py) ─────────
38
+ # huggingface_hub gives you resumable downloads.
39
+ # If unavailable, setup_model.py falls back to urllib (built-in).
40
+ huggingface_hub>=0.22.0
41
+
42
+ # ── Regression / Tuning (train_lightgbm_regressor.py) ────────
43
+ scikit-learn>=1.3.0
44
+ optuna>=3.6.0 # Optional: for hyperparameter tuning
rewrite_reasoning.py ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ rewrite_reasoning.py - Offline SLM reasoning rewriter (Qwen2.5-1.5B GGUF)
4
+ ==========================================================================
5
+ Takes templated reasoning from fix_reasoning.py and rewrites each one into
6
+ natural, varied 2-sentence assessments using a local Small Language Model.
7
+
8
+ The model receives:
9
+ 1. Full Job Description context (so it understands fit vs gap)
10
+ 2. The specific factual reasoning string (so it can only rephrase, not invent)
11
+ 3. Strict rules to preserve every number, name, and score
12
+
13
+ This architecture prevents hallucination: the model cannot add
14
+ information that wasn't in the factual reasoning string.
15
+
16
+ Usage:
17
+ # Full run β€” reads top100_reasoned.jsonl, writes updated submission.csv:
18
+ py rewrite_reasoning.py --input top100_reasoned.jsonl --out submission.csv
19
+
20
+ # Dry-run β€” print 3 prompts without loading the model:
21
+ py rewrite_reasoning.py --input top100_reasoned.jsonl --out submission.csv --dry-run
22
+
23
+ # Custom model path:
24
+ py rewrite_reasoning.py --input top100_reasoned.jsonl --out submission.csv \
25
+ --model models/Qwen2.5-1.5B-Instruct-Q4_K_M.gguf
26
+
27
+ Prerequisites:
28
+ py setup_model.py # one-time: downloads Qwen2.5-1.5B (~1 GB)
29
+ pip install llama-cpp-python \
30
+ --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
31
+ """
32
+
33
+ import json
34
+ import os
35
+ import sys
36
+ import re
37
+ import time
38
+ import argparse
39
+ from pathlib import Path
40
+ from datetime import datetime
41
+
42
+ # ── Default model path ────────────────────────────────────────────────────────
43
+ DEFAULT_MODEL = Path(__file__).parent / "models" / "Qwen2.5-1.5B-Instruct-Q4_K_M.gguf"
44
+
45
+ # ── Job Description Context (embedded for offline use) ────────────────────────
46
+ # This is the actual Redrob JD so the SLM understands the hiring bar.
47
+ JD_CONTEXT = """
48
+ ROLE: Senior AI Engineer β€” Founding Team at Redrob AI (Series A)
49
+ LOCATION: Pune / Noida, India (Hybrid)
50
+ EXPERIENCE: 5-9 years total, 4-5 in applied ML/AI at product companies
51
+
52
+ MUST-HAVE REQUIREMENTS:
53
+ - Production experience with embeddings-based retrieval (sentence-transformers, BGE, E5, OpenAI embeddings) deployed to real users
54
+ - Production experience with vector databases or hybrid search: Pinecone, Weaviate, Qdrant, Milvus, OpenSearch, Elasticsearch, FAISS
55
+ - Strong Python and production code quality
56
+ - Hands-on experience designing evaluation frameworks for ranking: NDCG, MRR, MAP, A/B testing
57
+
58
+ NICE-TO-HAVE:
59
+ - LLM fine-tuning (LoRA, QLoRA, PEFT)
60
+ - Learning-to-rank models (XGBoost-based or neural LTR)
61
+ - HR-tech or marketplace product experience
62
+
63
+ EXPLICIT DISQUALIFIERS:
64
+ - Pure services company background only (TCS, Infosys, Wipro, Accenture, Cognizant, etc.)
65
+ - Only recent LangChain/ChatGPT wrapper experience, no pre-LLM ML production
66
+ - Computer vision / robotics without NLP/IR background
67
+ - 60+ day notice period (high execution risk)
68
+ - No GitHub or external validation of technical work
69
+
70
+ BEHAVIORAL SIGNALS (from Redrob platform):
71
+ - notice_period_days: ideal <30 days; acceptable 30-60; risky >60
72
+ - github_activity_score: 0-100 (-1 means no GitHub linked); higher is better
73
+ - open_to_work_flag: true = actively looking
74
+ - recruiter_response_rate: fraction of messages replied to (higher = more reachable)
75
+ - interview_completion_rate: fraction of interviews attended
76
+ - last_active_date: recent activity = candidate is reachable
77
+
78
+ WHAT REDROB IS BUILDING:
79
+ The ranking, retrieval, and matching systems that determine what recruiters see
80
+ when they search candidates. First 90 days: audit current BM25 + rule-based scoring,
81
+ then ship a v2 hybrid retrieval system, then build evaluation infrastructure.
82
+ """
83
+
84
+ # ── Redrob signals reference (for prompt context) ─────────────────────────────
85
+ SIGNALS_CONTEXT = """
86
+ REDROB BEHAVIORAL SIGNALS REFERENCE:
87
+ - profile_completeness_score (0-100): how complete the profile is
88
+ - open_to_work_flag (bool): actively looking for roles
89
+ - notice_period_days (0-180): days to join; <30 = ideal for this role
90
+ - github_activity_score (-1 to 100): code contribution signal; -1 = no GitHub
91
+ - recruiter_response_rate (0-1): fraction of recruiter messages replied to
92
+ - interview_completion_rate (0-1): fraction of interviews attended
93
+ - offer_acceptance_rate (-1 to 1): fraction of offers accepted
94
+ - willing_to_relocate (bool): open to Pune/Noida
95
+ - linkedin_connected (bool): LinkedIn account linked
96
+ """
97
+
98
+ # ── System prompt with full JD context ───────────────────────────────────────
99
+ SYSTEM_PROMPT = f"""You are a senior technical recruiter writing factual candidate assessments for Redrob AI.
100
+
101
+ === THE ROLE YOU ARE HIRING FOR ===
102
+ {JD_CONTEXT}
103
+
104
+ === YOUR TASK ===
105
+ You will receive a factual assessment string about a candidate. Your ONLY job is to
106
+ rephrase it into 2 natural, varied sentences that a recruiter would find informative.
107
+
108
+ === STRICT RULES (MUST FOLLOW) ===
109
+ 1. Preserve EVERY specific fact: years of experience, months of skill use, company names,
110
+ skill names, GitHub scores, notice period days β€” everything numerical or named
111
+ 2. NO HALLUCINATION: Do NOT add any technology, tool, skill, company, or claim not in the original text.
112
+ 3. NO TEMPLATING: Do not just insert the candidate's name into a fixed template. Ensure highly diverse sentence structures across different candidates. Do NOT use all-identical reasoning strings.
113
+ 4. NO CONTRADICTIONS: Ensure the tone matches a top candidate. Do not contradict their high rank.
114
+ 5. Do NOT use the word "however" more than once across both sentences.
115
+ 6. Write EXACTLY 2 sentences β€” no more, no less.
116
+ 7. Vary the sentence structure β€” do not always start with the job title.
117
+ 8. Reference at least one behavioral signal (notice period, GitHub, or availability) naturally.
118
+ 9. Make the assessment feel like it was written by a human recruiter, not generated.
119
+ 10. Keep the tone professional but direct β€” this is for a founding-team technical role."""
120
+
121
+
122
+ def build_user_prompt(original_reasoning: str, candidate_id: str) -> str:
123
+ return f"""Rephrase this candidate assessment for {candidate_id} in exactly 2 sentences.
124
+ Preserve ALL facts. Do NOT add new information. Reference at least one availability signal.
125
+
126
+ Original assessment:
127
+ {original_reasoning}
128
+
129
+ Rewritten (2 sentences only, same facts, natural recruiter voice):"""
130
+
131
+
132
+ # ── Validation: facts preserved, no hallucinated tech ────────────────────────
133
+
134
+ # Tech keywords that a model might hallucinate
135
+ HALLUCINATION_GUARD = {
136
+ "kubernetes", "docker", "spark", "kafka", "airflow", "dbt", "databricks",
137
+ "aws", "gcp", "azure", "terraform", "redis", "postgres", "mysql",
138
+ "react", "javascript", "typescript", "golang", "rust", "java", "scala",
139
+ "bert", "gpt", "llama", "mistral", "claude", "openai", "gemini",
140
+ "langchain", "llamaindex", "autogen", "crewai", "langgraph",
141
+ }
142
+
143
+
144
+ def validate_rewrite(original: str, rewritten: str) -> tuple:
145
+ """
146
+ Returns (is_valid: bool, reason: str)
147
+ Checks: no hallucinated tech, numbers preserved, reasonable length.
148
+ """
149
+ orig_lower = original.lower()
150
+ rew_lower = rewritten.lower()
151
+
152
+ # No new tech keywords
153
+ for kw in HALLUCINATION_GUARD:
154
+ if kw in rew_lower and kw not in orig_lower:
155
+ return False, f"Hallucinated keyword: '{kw}'"
156
+
157
+ # Key numbers from original preserved
158
+ orig_nums = set(re.findall(r'\b\d+\b', original))
159
+ rew_nums = set(re.findall(r'\b\d+\b', rewritten))
160
+ missing = orig_nums - rew_nums
161
+ if len(missing) > 1:
162
+ return False, f"Missing numbers: {missing}"
163
+
164
+ # Length sanity
165
+ sentences = [s.strip() for s in re.split(r'[.!?]+', rewritten) if len(s.strip()) > 5]
166
+ if len(sentences) > 5 or len(rewritten) < 40:
167
+ return False, f"Bad length ({len(sentences)} sentences, {len(rewritten)} chars)"
168
+
169
+ # Must not be identical to template patterns (crude template detection)
170
+ if rewritten.strip() == original.strip():
171
+ return False, "Output identical to input"
172
+
173
+ return True, "OK"
174
+
175
+
176
+ # ── Model loading ─────────────────────────────────────────────────────────────
177
+
178
+ def load_model(model_path: str, n_threads: int = None):
179
+ """Load Qwen2.5-1.5B GGUF via llama-cpp-python."""
180
+ try:
181
+ from llama_cpp import Llama
182
+ except ImportError:
183
+ print("\nERROR: llama-cpp-python not installed.")
184
+ print("\nInstall (CPU-only):")
185
+ print(" pip install llama-cpp-python \\")
186
+ print(" --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu")
187
+ print("\nInstall (CUDA 12.1, GPU):")
188
+ print(" pip install llama-cpp-python \\")
189
+ print(" --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu121")
190
+ sys.exit(1)
191
+
192
+ if not os.path.exists(model_path):
193
+ print(f"\nERROR: Model not found at: {model_path}")
194
+ print("Run first: py setup_model.py")
195
+ sys.exit(1)
196
+
197
+ size_mb = os.path.getsize(model_path) / 1024 / 1024
198
+ print(f" Model: {os.path.basename(model_path)} ({size_mb:.0f} MB)")
199
+
200
+ if n_threads is None:
201
+ import multiprocessing
202
+ n_threads = max(1, multiprocessing.cpu_count() - 1)
203
+
204
+ # Context window: system prompt (~800 tok) + user prompt (~200 tok) + output (~150 tok)
205
+ n_ctx = 1536
206
+
207
+ print(f" Loading... (n_ctx={n_ctx}, n_threads={n_threads})", end="", flush=True)
208
+ t0 = time.time()
209
+ llm = Llama(
210
+ model_path=str(model_path),
211
+ n_ctx=n_ctx,
212
+ n_threads=n_threads,
213
+ n_gpu_layers=0, # 0 = CPU only; set to -1 to use full GPU if available
214
+ verbose=False,
215
+ chat_format="chatml", # Qwen2.5 uses ChatML format
216
+ )
217
+ print(f" done in {time.time()-t0:.1f}s")
218
+ return llm
219
+
220
+
221
+ # ── Single inference ──────────────────────────────────────────────────────────
222
+
223
+ def rewrite_one(llm, original: str, candidate_id: str,
224
+ temperature: float = 0.6, max_tokens: int = 160) -> str:
225
+ """Run one rewrite call. Returns rewritten text."""
226
+ messages = [
227
+ {"role": "system", "content": SYSTEM_PROMPT},
228
+ {"role": "user", "content": build_user_prompt(original, candidate_id)},
229
+ ]
230
+ resp = llm.create_chat_completion(
231
+ messages=messages,
232
+ max_tokens=max_tokens,
233
+ temperature=temperature,
234
+ top_p=0.92,
235
+ repeat_penalty=1.12,
236
+ stop=["Original assessment:", "Rewritten (", "\n\n\n", "---"],
237
+ )
238
+ text = resp["choices"][0]["message"]["content"].strip()
239
+ # Strip any prompt leakage
240
+ for stopper in ["Original assessment:", "Rewritten (", "===", "STRICT"]:
241
+ if stopper in text:
242
+ text = text.split(stopper)[0].strip()
243
+ return text
244
+
245
+
246
+ # ── Main processing ───────────────────────────────────────────────────────────
247
+
248
+ def process(input_path: str, out_csv: str, model_path: str,
249
+ dry_run: bool = False, n_threads: int = None,
250
+ temperature: float = 0.6):
251
+
252
+ print(f"\n Input: {input_path}")
253
+ records = []
254
+ with open(input_path, "r", encoding="utf-8") as f:
255
+ for line in f:
256
+ line = line.strip()
257
+ if line:
258
+ records.append(json.loads(line))
259
+ print(f" Loaded {len(records)} candidates")
260
+
261
+ if dry_run:
262
+ print("\n [DRY RUN] First 3 prompts:\n")
263
+ for rec in records[:3]:
264
+ print(f" {'='*60}")
265
+ print(f" Candidate: {rec['candidate_id']}")
266
+ print(f" Original: {rec['fixed_reasoning']}")
267
+ print(f"\n SYSTEM PROMPT (truncated):\n {SYSTEM_PROMPT[:300]}...")
268
+ print(f"\n USER PROMPT:\n {build_user_prompt(rec['fixed_reasoning'], rec['candidate_id'])}")
269
+ return
270
+
271
+ # Load model
272
+ print()
273
+ llm = load_model(model_path, n_threads=n_threads)
274
+
275
+ # Process loop
276
+ print(f"\n Rewriting {len(records)} reasonings (temperature={temperature})")
277
+ print(f" {'Rank':<5} {'Candidate ID':<18} {'Time':>5} Status")
278
+ print(f" {'-'*65}")
279
+
280
+ results = []
281
+ fallback_count = 0
282
+ total_start = time.time()
283
+
284
+ for rec in records:
285
+ cid = rec["candidate_id"]
286
+ rank = rec.get("rank", len(results) + 1)
287
+ score = rec.get("score", 0)
288
+ original = rec["fixed_reasoning"]
289
+
290
+ t0 = time.time()
291
+ try:
292
+ rewritten = rewrite_one(llm, original, cid, temperature=temperature)
293
+ elapsed = time.time() - t0
294
+ ok, reason = validate_rewrite(original, rewritten)
295
+ if ok:
296
+ status = "OK"
297
+ else:
298
+ rewritten = original
299
+ fallback_count += 1
300
+ status = f"FALLBACK ({reason[:40]})"
301
+ except Exception as e:
302
+ rewritten = original
303
+ elapsed = time.time() - t0
304
+ fallback_count += 1
305
+ status = f"ERROR ({str(e)[:30]})"
306
+
307
+ results.append({
308
+ "rank": rank,
309
+ "candidate_id": cid,
310
+ "score": score,
311
+ "fixed_reasoning": original,
312
+ "rewritten_reasoning": rewritten,
313
+ })
314
+ print(f" {rank:<5} {cid:<18} {elapsed:>4.1f}s {status}")
315
+
316
+ total_time = time.time() - total_start
317
+
318
+ # Write rewritten JSONL
319
+ jsonl_out = out_csv.replace(".csv", "_rewritten.jsonl")
320
+ with open(jsonl_out, "w", encoding="utf-8") as f:
321
+ for r in results:
322
+ f.write(json.dumps(r, ensure_ascii=False) + "\n")
323
+
324
+ # Write updated submission.csv
325
+ with open(out_csv, "w", encoding="utf-8", newline="") as f:
326
+ f.write("candidate_id,rank,score,reasoning\n")
327
+ for r in results:
328
+ clean = r["rewritten_reasoning"].replace('"', "'").replace("\n", " ").strip()
329
+ f.write(f'{r["candidate_id"]},{r["rank"]},{r["score"]},"{clean}"\n')
330
+
331
+ # Stats
332
+ unique = len({r["rewritten_reasoning"] for r in results})
333
+ however_remaining = sum(
334
+ 1 for r in results if "; however" in r["rewritten_reasoning"].lower()
335
+ )
336
+
337
+ print(f"""
338
+ +=====================================================+
339
+ | REWRITE SUMMARY |
340
+ +=====================================================+
341
+ | Total candidates: {len(results):<5} |
342
+ | Fallbacks (original kept): {fallback_count:<5} |
343
+ | Unique reasonings: {unique:<5} / {len(results):<5} |
344
+ | '; however' remaining: {however_remaining:<5} ({however_remaining/len(results)*100:.0f}%) |
345
+ | Total time: {total_time/60:.1f} min |
346
+ | Avg per candidate: {total_time/len(results):.1f}s |
347
+ +=====================================================+
348
+ | Output: {out_csv:<44}|
349
+ | JSONL: {jsonl_out:<44}|
350
+ +=====================================================+
351
+ """)
352
+
353
+
354
+ # ── CLI ───────────────────────────────────────────────────────────────────────
355
+
356
+ def main():
357
+ parser = argparse.ArgumentParser(
358
+ description="Offline SLM reasoning rewriter β€” Qwen2.5-1.5B-Instruct GGUF"
359
+ )
360
+ parser.add_argument("--input", required=True,
361
+ help="Input JSONL from fix_reasoning.py (e.g. top100_reasoned.jsonl)")
362
+ parser.add_argument("--out", required=True,
363
+ help="Output submission.csv path")
364
+ parser.add_argument("--model", default=str(DEFAULT_MODEL),
365
+ help=f"Path to GGUF model file (default: {DEFAULT_MODEL})")
366
+ parser.add_argument("--dry-run", action="store_true",
367
+ help="Print prompts for first 3 candidates without running the model")
368
+ parser.add_argument("--threads", type=int, default=None,
369
+ help="CPU threads (default: cpu_count - 1)")
370
+ parser.add_argument("--temperature", type=float, default=0.6,
371
+ help="Sampling temperature 0-1 (default: 0.6). Higher = more varied")
372
+ args = parser.parse_args()
373
+
374
+ if not os.path.exists(args.input):
375
+ print(f"ERROR: Input not found: {args.input}")
376
+ sys.exit(1)
377
+
378
+ print(f"""
379
+ +============================================================+
380
+ | REDROB - SLM Reasoning Rewriter (Fully Offline) |
381
+ | Model: Qwen2.5-1.5B-Instruct-Q4_K_M GGUF |
382
+ | Context: Full JD embedded in system prompt |
383
+ +============================================================+
384
+ """)
385
+
386
+ process(
387
+ input_path=args.input,
388
+ out_csv=args.out,
389
+ model_path=args.model,
390
+ dry_run=args.dry_run,
391
+ n_threads=args.threads,
392
+ temperature=args.temperature,
393
+ )
394
+
395
+
396
+ if __name__ == "__main__":
397
+ main()
run_pipeline.py ADDED
@@ -0,0 +1,622 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # run_pipeline.py
2
+
3
+ `python
4
+ #!/usr/bin/env python3
5
+ """
6
+ run_pipeline.py - Redrob Intelligent Candidate Ranking Pipeline
7
+ ===============================================================
8
+ MASTER ORCHESTRATOR β€” runs the full 5-stage pipeline and reports
9
+ wall-clock time, peak RAM, GPU VRAM, and disk usage per stage.
10
+
11
+ PIPELINE:
12
+ Stage 1 | filter_candidates.py | 100k β†’ ~22k (hard rules + honeypot + prescore)
13
+ Stage 2 | LightGBM inference | 22k β†’ 15k (pre-trained ranker.pkl)
14
+ Stage 3 | fix_reasoning.py | top 100 (deterministic fact-grounded reasoning)
15
+ Stage 4 | rewrite_reasoning.py | top 100 (SLM paraphrase, JD-aware, offline)
16
+ Stage 5 | submission.csv | final output
17
+
18
+ USAGE:
19
+ # Full pipeline from scratch:
20
+ py run_pipeline.py --candidates candidates.jsonl --ranker ranker.pkl
21
+
22
+ # Skip to stage 2 (filter already done):
23
+ py run_pipeline.py --candidates candidates.jsonl --ranker ranker.pkl --start-stage 2
24
+
25
+ # Skip SLM rewrite (deterministic reasoning only):
26
+ py run_pipeline.py --candidates candidates.jsonl --ranker ranker.pkl --no-rewrite
27
+
28
+ # Check disk/file status without running:
29
+ py run_pipeline.py --candidates candidates.jsonl --ranker ranker.pkl --status
30
+
31
+ PREREQUISITES:
32
+ pip install -r requirements.txt
33
+ py setup_model.py # downloads Qwen2.5-1.5B GGUF (~1 GB)
34
+ pip install llama-cpp-python \\
35
+ --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
36
+ """
37
+
38
+ import json
39
+ import os
40
+ import sys
41
+ import time
42
+ import pickle
43
+ import shutil
44
+ import threading
45
+ import argparse
46
+ import subprocess
47
+ from pathlib import Path
48
+ from datetime import datetime
49
+
50
+ # ── Resource monitoring ───────────────────────────────────────────────────────
51
+
52
+ try:
53
+ import psutil
54
+ _PROC = psutil.Process(os.getpid())
55
+ HAS_PSUTIL = True
56
+ except ImportError:
57
+ HAS_PSUTIL = False
58
+ _PROC = None
59
+
60
+
61
+ def _gpu_mb():
62
+ try:
63
+ out = subprocess.check_output(
64
+ ["nvidia-smi", "--query-gpu=memory.used,memory.total",
65
+ "--format=csv,noheader,nounits"],
66
+ stderr=subprocess.DEVNULL, timeout=3
67
+ ).decode().strip().splitlines()[0]
68
+ used, total = map(int, out.split(","))
69
+ return used, total
70
+ except Exception:
71
+ return None, None
72
+
73
+
74
+ class ResourceMonitor:
75
+ def __init__(self):
76
+ self.peak_ram_mb = 0.0
77
+ self.peak_gpu_mb = 0.0
78
+ self._running = False
79
+ self._thread = None
80
+
81
+ def start(self):
82
+ self._running = True
83
+ self._thread = threading.Thread(target=self._loop, daemon=True)
84
+ self._thread.start()
85
+
86
+ def stop(self):
87
+ self._running = False
88
+ if self._thread:
89
+ self._thread.join(timeout=2)
90
+
91
+ def _loop(self):
92
+ while self._running:
93
+ if HAS_PSUTIL and _PROC:
94
+ try:
95
+ ram = _PROC.memory_info().rss / 1024 / 1024
96
+ self.peak_ram_mb = max(self.peak_ram_mb, ram)
97
+ except Exception:
98
+ pass
99
+ gpu_used, _ = _gpu_mb()
100
+ if gpu_used:
101
+ self.peak_gpu_mb = max(self.peak_gpu_mb, float(gpu_used))
102
+ time.sleep(0.5)
103
+
104
+ def snapshot(self):
105
+ ram = 0.0
106
+ if HAS_PSUTIL and _PROC:
107
+ try:
108
+ ram = _PROC.memory_info().rss / 1024 / 1024
109
+ except Exception:
110
+ pass
111
+ gpu_u, gpu_t = _gpu_mb()
112
+ return {
113
+ "current_ram_mb": round(ram, 1),
114
+ "peak_ram_mb": round(self.peak_ram_mb, 1),
115
+ "gpu_used_mb": gpu_u,
116
+ "gpu_total_mb": gpu_t,
117
+ }
118
+
119
+
120
+ # ── Helpers ───────────────────────────────────────────────────────────────────
121
+
122
+ def ts():
123
+ return datetime.now().strftime("%H:%M:%S")
124
+
125
+ def log(msg, level="OK"):
126
+ icons = {"OK": "[OK] ", "WARN": "[WRN]", "ERR": "[ERR]", "INFO": "[-->]"}
127
+ print(f" {icons.get(level,' ')} [{ts()}] {msg}", flush=True)
128
+
129
+ def sep(title):
130
+ print(f"\n{'='*68}", flush=True)
131
+ print(f" {title}", flush=True)
132
+ print(f"{'='*68}", flush=True)
133
+
134
+ def fmttime(s):
135
+ if s < 60:
136
+ return f"{s:.1f}s"
137
+ return f"{int(s)//60}m {int(s)%60}s"
138
+
139
+ def fmtmb(path):
140
+ if path and os.path.exists(path):
141
+ return round(os.path.getsize(path) / 1024 / 1024, 2)
142
+ return 0.0
143
+
144
+ def check_disk(path, needed_mb):
145
+ free_mb = shutil.disk_usage(os.path.dirname(os.path.abspath(path))).free / 1024 / 1024
146
+ if free_mb < needed_mb:
147
+ log(f"Low disk space! Need ~{needed_mb:.0f} MB, have {free_mb:.0f} MB", "WARN")
148
+ else:
149
+ log(f"Disk free: {free_mb:.0f} MB (need ~{needed_mb:.0f} MB) [OK]")
150
+
151
+
152
+ # ── Stage 1: Hard filter ─────────────────────��────────────────────────────────
153
+
154
+ def stage1_filter(candidates_path, out_path, top_k, report, monitor):
155
+ sep(f"STAGE 1 | HARD FILTER + PRESCORE (100k -> ~{top_k//1000}k)")
156
+
157
+ # Disk estimate: filtered output ~ 60% of input
158
+ in_mb = fmtmb(candidates_path)
159
+ check_disk(out_path, in_mb * 0.6 + 50)
160
+
161
+ script_dir = os.path.dirname(os.path.abspath(__file__))
162
+ if script_dir not in sys.path:
163
+ sys.path.insert(0, script_dir)
164
+
165
+ try:
166
+ import filter_candidates as fc
167
+ except ImportError:
168
+ log("filter_candidates.py not found in same directory", "ERR")
169
+ sys.exit(1)
170
+
171
+ t0 = time.time()
172
+ n_out = fc.run_filter(
173
+ candidates_path=candidates_path,
174
+ top_k=top_k,
175
+ out_path=out_path,
176
+ report=report,
177
+ report_path=out_path.replace(".jsonl", "_report.txt"),
178
+ )
179
+ elapsed = time.time() - t0
180
+ snap = monitor.snapshot()
181
+ log(f"Done in {fmttime(elapsed)} | {n_out:,} candidates | RAM: {snap['peak_ram_mb']:.0f} MB | Disk: {fmtmb(out_path):.1f} MB")
182
+ return {"stage": "1_filter", "n_in": "100k", "n_out": n_out,
183
+ "time_s": round(elapsed, 1), **snap, "disk_mb": fmtmb(out_path)}
184
+
185
+
186
+ # ── Stage 2: LightGBM inference ───────────────────────────────────────────────
187
+
188
+ def stage2_lgbm(filtered_path, ranker_pkl, out_path, top_k, monitor):
189
+ sep(f"STAGE 2 | LIGHTGBM INFERENCE (~22k -> top {top_k//1000}k)")
190
+ check_disk(out_path, fmtmb(filtered_path) * 0.7)
191
+
192
+ try:
193
+ import numpy as np
194
+ except ImportError:
195
+ log("numpy not installed: pip install numpy", "ERR"); sys.exit(1)
196
+
197
+ log(f"Loading ranker: {ranker_pkl}")
198
+ with open(ranker_pkl, "rb") as f:
199
+ raw = pickle.load(f)
200
+
201
+ best_iter = -1
202
+ if isinstance(raw, dict):
203
+ booster = raw["booster"]
204
+ best_iter = raw.get("best_iteration", -1)
205
+ feature_names = raw.get("feat_keys", [])
206
+ log(f"Ranker dict: {len(feature_names)} features, best_iter={best_iter}")
207
+ if feature_names:
208
+ # Print ranker training metrics if available
209
+ metrics = raw.get("metrics", {})
210
+ if metrics:
211
+ m_str = " ".join(f"{k}={v:.4f}" for k, v in list(metrics.items())[:4])
212
+ log(f"Training metrics: {m_str}")
213
+ else:
214
+ booster = raw
215
+ feature_names = []
216
+
217
+ # If feat_keys not in pickle, load from JSON
218
+ if not feature_names:
219
+ ranker_dir = os.path.dirname(os.path.abspath(ranker_pkl))
220
+ for fname in ["ranker_features.json", "ranker_features (1).json"]:
221
+ fp = os.path.join(ranker_dir, fname)
222
+ if os.path.exists(fp):
223
+ with open(fp, "r", encoding="utf-8") as f:
224
+ feature_names = json.load(f)
225
+ log(f"Loaded feature names from {fp}")
226
+ break
227
+ if not feature_names:
228
+ log("Cannot find feature names (ranker_features.json). Pass --ranker-features.", "ERR")
229
+ sys.exit(1)
230
+
231
+ t0 = time.time()
232
+ log(f"Loading {filtered_path}")
233
+ candidates = []
234
+ with open(filtered_path, "r", encoding="utf-8") as f:
235
+ for line in f:
236
+ line = line.strip()
237
+ if line:
238
+ candidates.append(json.loads(line))
239
+ log(f"Loaded {len(candidates):,} candidates")
240
+
241
+ X = np.array([
242
+ [float((c.get("features") or {}).get(fn, 0.0) or 0.0) for fn in feature_names]
243
+ for c in candidates
244
+ ], dtype=np.float32)
245
+ log(f"Feature matrix: {X.shape}")
246
+
247
+ t_inf = time.time()
248
+ pred_kw = {"num_iteration": best_iter} if best_iter > 0 else {}
249
+ scores = booster.predict(X, **pred_kw)
250
+ log(f"Inference in {time.time()-t_inf:.3f}s range [{scores.min():.4f} - {scores.max():.4f}]")
251
+
252
+ top_idx = scores.argsort()[::-1][:top_k]
253
+ with open(out_path, "w", encoding="utf-8") as f:
254
+ for rank, idx in enumerate(top_idx, 1):
255
+ c = candidates[idx]
256
+ c["lgbm_score"] = float(scores[idx])
257
+ c["lgbm_rank"] = rank
258
+ f.write(json.dumps(c, ensure_ascii=False) + "\n")
259
+
260
+ elapsed = time.time() - t0
261
+ snap = monitor.snapshot()
262
+ top_s = scores[top_idx]
263
+ log(f"Done in {fmttime(elapsed)} | top-1={top_s[0]:.4f} top-100={top_s[min(99,len(top_s)-1)]:.4f} | RAM: {snap['peak_ram_mb']:.0f} MB | Disk: {fmtmb(out_path):.1f} MB")
264
+ return {"stage": "2_lgbm", "n_in": len(candidates), "n_out": len(top_idx),
265
+ "time_s": round(elapsed, 1), **snap, "disk_mb": fmtmb(out_path)}
266
+
267
+
268
+ # ── Stage 3: Deterministic reasoning ─────────────────────────────────────────
269
+
270
+ def stage3_fix_reasoning(lgbm_path, out_path, top_k, monitor):
271
+ sep(f"STAGE 3 | DETERMINISTIC REASONING (top {top_k})")
272
+
273
+ script_dir = os.path.dirname(os.path.abspath(__file__))
274
+ if script_dir not in sys.path:
275
+ sys.path.insert(0, script_dir)
276
+
277
+ try:
278
+ import fix_reasoning as fr
279
+ except ImportError:
280
+ log("fix_reasoning.py not found in same directory", "ERR"); sys.exit(1)
281
+
282
+ t0 = time.time()
283
+ records = []
284
+ with open(lgbm_path, "r", encoding="utf-8") as f:
285
+ for i, line in enumerate(f):
286
+ if i >= top_k:
287
+ break
288
+ line = line.strip()
289
+ if line:
290
+ records.append(json.loads(line))
291
+ log(f"Loaded {len(records):,} candidates for reasoning")
292
+
293
+ stats = fr.process_records(records, out_path, top_k=top_k)
294
+ elapsed = time.time() - t0
295
+ snap = monitor.snapshot()
296
+ log(f"Done in {fmttime(elapsed)} | unique={stats['unique_reasonings']}/{stats['total']} | hallucinations_replaced={stats['hallucinations_replaced']}")
297
+ return {"stage": "3_reasoning", "n_in": len(records), "n_out": stats["total"],
298
+ "hallucinations_replaced": stats["hallucinations_replaced"],
299
+ "unique_reasonings": stats["unique_reasonings"],
300
+ "time_s": round(elapsed, 1), **snap, "disk_mb": fmtmb(out_path)}
301
+
302
+
303
+ # ── Stage 3.5: JD-aligned composite scorer (0-100) ───────────────────────────
304
+
305
+ def stage35_composite_score(lgbm_path, reasoned_path, submission_path, top_k, monitor):
306
+ sep(f"STAGE 3.5 | COMPOSITE SCORING (JD-aligned, 0-100)")
307
+
308
+ # Check if a Regressor was used (scores are already 0-100)
309
+ with open(lgbm_path, "r", encoding="utf-8") as f:
310
+ first = json.loads(f.readline())
311
+ if first.get("lgbm_score", 0) > 10.0:
312
+ log("Stage 3.5 skipped: LightGBM output is already 0-100 (Regressor used).")
313
+ return None
314
+
315
+ script_dir = os.path.dirname(os.path.abspath(__file__))
316
+ if script_dir not in sys.path:
317
+ sys.path.insert(0, script_dir)
318
+
319
+ try:
320
+ import score_candidates as sc
321
+ except ImportError:
322
+ log("score_candidates.py not found β€” skipping composite scoring", "WARN")
323
+ return None
324
+
325
+ t0 = time.time()
326
+ final_scores = sc.run(
327
+ input_path=lgbm_path,
328
+ top_k=top_k,
329
+ reasoned_path=reasoned_path,
330
+ submission_path=submission_path,
331
+ )
332
+ elapsed = time.time() - t0
333
+ snap = monitor.snapshot()
334
+ lo, hi = min(final_scores), max(final_scores)
335
+ log(f"Done in {fmttime(elapsed)} | score range {lo:.1f}-{hi:.1f} (spread {hi-lo:.1f} pts)")
336
+ return {"stage": "3.5_scoring", "n_out": len(final_scores),
337
+ "time_s": round(elapsed, 1), **snap, "disk_mb": fmtmb(reasoned_path)}
338
+
339
+
340
+ # ── Stage 4: SLM rewrite ──────────────────────────────────────────────────────
341
+
342
+ def stage4_slm_rewrite(reasoned_path, out_path, model_path, temperature, threads, monitor):
343
+ sep(f"STAGE 4 | SLM REWRITE (Qwen2.5-1.5B, JD-aware, offline)")
344
+
345
+ if not os.path.exists(model_path):
346
+ log(f"Model not found: {model_path}", "WARN")
347
+ log("Run: py setup_model.py to download (~1 GB)", "WARN")
348
+ log("Skipping SLM rewrite. Using deterministic reasoning for submission.", "WARN")
349
+ return None
350
+
351
+ script_dir = os.path.dirname(os.path.abspath(__file__))
352
+ if script_dir not in sys.path:
353
+ sys.path.insert(0, script_dir)
354
+
355
+ try:
356
+ import rewrite_reasoning as rr
357
+ except ImportError:
358
+ log("rewrite_reasoning.py not found", "ERR"); sys.exit(1)
359
+
360
+ t0 = time.time()
361
+ rr.process(
362
+ input_path=reasoned_path,
363
+ out_csv=out_path,
364
+ model_path=model_path,
365
+ dry_run=False,
366
+ n_threads=threads,
367
+ temperature=temperature,
368
+ )
369
+ elapsed = time.time() - t0
370
+ snap = monitor.snapshot()
371
+ log(f"Done in {fmttime(elapsed)} | RAM: {snap['peak_ram_mb']:.0f} MB | Disk: {fmtmb(out_path):.1f} MB")
372
+ return {"stage": "4_slm_rewrite", "time_s": round(elapsed, 1), **snap, "disk_mb": fmtmb(out_path)}
373
+
374
+
375
+ # ── Stage 5: Submission CSV (deterministic fallback) ─────────────────────────
376
+
377
+ def stage5_submission(reasoned_path, out_path, top_k, monitor):
378
+ """Write submission.csv from deterministic reasoning (no SLM)."""
379
+ sep(f"STAGE 5 | SUBMISSION CSV (top {top_k})")
380
+
381
+ records = []
382
+ with open(reasoned_path, "r", encoding="utf-8") as f:
383
+ for line in f:
384
+ line = line.strip()
385
+ if line:
386
+ records.append(json.loads(line))
387
+ records.sort(key=lambda r: (-r.get("score", 0), r.get("candidate_id", "")))
388
+ records = records[:top_k]
389
+
390
+ with open(out_path, "w", encoding="utf-8", newline="") as f:
391
+ f.write("candidate_id,rank,score,reasoning\n")
392
+ for rank, r in enumerate(records, 1):
393
+ rsn = r.get("fixed_reasoning", "").replace('"', "'").replace("\n", " ").strip()
394
+ f.write(f'{r["candidate_id"]},{rank},{r["score"]},"{rsn}"\n')
395
+
396
+ # Top-10 preview
397
+ print(f"\n -- TOP-10 --")
398
+ print(f" {'Rank':<5} {'Candidate ID':<18} {'Score':>7} Reasoning preview")
399
+ print(f" {'-'*75}")
400
+ for rank, r in enumerate(records[:10], 1):
401
+ rsn = r.get("fixed_reasoning", "")[:65]
402
+ print(f" {rank:<5} {r['candidate_id']:<18} {r['score']:>7.4f} {rsn}...")
403
+
404
+ snap = monitor.snapshot()
405
+ return {"stage": "5_submission", "n_out": len(records), **snap, "disk_mb": fmtmb(out_path)}
406
+
407
+
408
+ # ── Resource report ───────────────────────────────────────────────────────────
409
+
410
+ def print_report(stage_stats, total_start, paths):
411
+ total = time.time() - total_start
412
+ W = 68
413
+
414
+ def box(title):
415
+ print(f"\n+{'='*W}+")
416
+ print(f"| {title:<{W-1}}|")
417
+ print(f"+{'-'*W}+")
418
+
419
+ def row(label, val):
420
+ print(f"| {label:<35} {val:<{W-36}}|")
421
+
422
+ def divider():
423
+ print(f"+{'-'*W}+")
424
+
425
+ box("RESOURCE & TIMING REPORT")
426
+ row("Stage", f"{'Time':>8} {'In':>8} {'Out':>8} {'Peak RAM':>10}")
427
+ divider()
428
+
429
+ stage_names = {
430
+ "1_filter": "Stage 1: Hard filter + prescore",
431
+ "2_lgbm": "Stage 2: LightGBM inference",
432
+ "3_reasoning": "Stage 3: Deterministic reasoning",
433
+ "4_slm_rewrite": "Stage 4: SLM rewrite (Qwen2.5)",
434
+ "5_submission": "Stage 5: Submission CSV",
435
+ }
436
+ for s in stage_stats:
437
+ if s is None:
438
+ continue
439
+ nm = stage_names.get(s["stage"], s["stage"])
440
+ t = fmttime(s.get("time_s", 0))
441
+ n_in = f"{s['n_in']:,}" if isinstance(s.get("n_in"), int) else str(s.get("n_in", ""))
442
+ n_out = f"{s['n_out']:,}" if isinstance(s.get("n_out"), int) else str(s.get("n_out", ""))
443
+ ram = f"{s.get('peak_ram_mb', 0):.0f} MB"
444
+ row(nm, f"{t:>8} {n_in:>8} {n_out:>8} {ram:>10}")
445
+ divider()
446
+ row("TOTAL PIPELINE TIME", fmttime(total))
447
+
448
+ box("PEAK RAM USAGE")
449
+ peak = max((s.get("peak_ram_mb", 0) for s in stage_stats if s), default=0)
450
+ row(f"Peak RAM (this process)", f"{peak:.0f} MB ({peak/1024:.2f} GB)")
451
+
452
+ box("GPU USAGE")
453
+ gpu_u = next((s.get("gpu_used_mb") for s in reversed(stage_stats) if s and s.get("gpu_used_mb")), None)
454
+ gpu_t = next((s.get("gpu_total_mb") for s in reversed(stage_stats) if s and s.get("gpu_total_mb")), None)
455
+ if gpu_u is not None:
456
+ row("GPU VRAM used", f"{gpu_u} MB / {gpu_t} MB")
457
+ else:
458
+ row("GPU", "Not used (CPU-only pipeline)")
459
+
460
+ box("DISK USAGE")
461
+ labels = [
462
+ ("candidates.jsonl (input)", paths.get("candidates")),
463
+ ("filtered_22k.jsonl", paths.get("filtered")),
464
+ ("lgbm_top15k.jsonl", paths.get("lgbm_out")),
465
+ ("top100_reasoned.jsonl", paths.get("reasoned_out")),
466
+ ("submission.csv (final)", paths.get("submission")),
467
+ ("SLM model (Qwen2.5-1.5B GGUF)", paths.get("model")),
468
+ ]
469
+ total_disk = 0
470
+ for label, path in labels:
471
+ mb = fmtmb(path)
472
+ total_disk += mb
473
+ status = "[OK] " if (path and os.path.exists(path)) else "[n/a] "
474
+ row(f"{status}{label}", f"{mb:>8.2f} MB")
475
+ divider()
476
+ row("TOTAL DISK (all files)", f"{total_disk:>8.2f} MB ({total_disk/1024:.2f} GB)")
477
+ print(f"+{'='*W}+\n")
478
+
479
+
480
+ # ── Status check ──────────────────────────────────────────────────────────────
481
+
482
+ def status_check(paths):
483
+ print("\n -- PIPELINE FILE STATUS --")
484
+ for label, path in paths.items():
485
+ if not path:
486
+ continue
487
+ if os.path.exists(path):
488
+ mb = fmtmb(path)
489
+ print(f" [EXISTS] {label:<30} {path} ({mb:.1f} MB)")
490
+ else:
491
+ print(f" [MISSING] {label:<30} {path}")
492
+ print()
493
+
494
+
495
+ # ── Main ──────────────────────────────────────────────────────────────────────
496
+
497
+ def main():
498
+ parser = argparse.ArgumentParser(
499
+ description="Redrob end-to-end ranking pipeline (100k -> submission.csv)",
500
+ formatter_class=argparse.RawDescriptionHelpFormatter,
501
+ )
502
+
503
+ g = parser.add_argument_group("Input files")
504
+ g.add_argument("--candidates", required=True, help="Raw candidates.jsonl (100k pool)")
505
+ g.add_argument("--ranker", required=True, help="Pre-trained ranker.pkl (LightGBM)")
506
+ g.add_argument("--ranker-features", default=None,
507
+ help="ranker_features.json (auto-detected beside ranker.pkl if omitted)")
508
+
509
+ g = parser.add_argument_group("Stage control")
510
+ g.add_argument("--start-stage", type=int, default=1, choices=[1,2,3,4,5],
511
+ help="Start from stage N (1=filter, 2=lgbm, 3=reasoning, 4=slm, 5=csv)")
512
+ g = parser.add_argument_group("Intermediate paths")
513
+ g.add_argument("--filtered", default="filtered_22k.jsonl")
514
+ g.add_argument("--lgbm-out", default="lgbm_top15k.jsonl")
515
+ g.add_argument("--reasoned-out",default="top100_reasoned.jsonl")
516
+ g.add_argument("--submission", default="submission.csv")
517
+
518
+ g = parser.add_argument_group("Top-K params")
519
+ g.add_argument("--filter-top-k", type=int, default=22000)
520
+ g.add_argument("--lgbm-top-k", type=int, default=15000)
521
+ g.add_argument("--top-final", type=int, default=100)
522
+
523
+ g = parser.add_argument_group("Misc")
524
+ g.add_argument("--report", action="store_true", help="Write Stage 1 filter report")
525
+ g.add_argument("--status", action="store_true", help="Show file status and exit")
526
+
527
+ args = parser.parse_args()
528
+
529
+ # Auto-resolve ranker features
530
+ if not args.ranker_features:
531
+ rd = os.path.dirname(os.path.abspath(args.ranker))
532
+ for n in ["ranker_features.json", "ranker_features (1).json"]:
533
+ p = os.path.join(rd, n)
534
+ if os.path.exists(p):
535
+ args.ranker_features = p
536
+ break
537
+
538
+ paths = {
539
+ "candidates": args.candidates,
540
+ "filtered": args.filtered,
541
+ "lgbm_out": args.lgbm_out,
542
+ "reasoned_out": args.reasoned_out,
543
+ "submission": args.submission,
544
+ }
545
+
546
+ print("""
547
+ +====================================================================+
548
+ | REDROB INTELLIGENT CANDIDATE RANKING PIPELINE |
549
+ | Stage 1: Hard filter + prescore (100k -> ~22k) |
550
+ | Stage 2: LightGBM LambdaRank (22k -> 15k) |
551
+ | Stage 3: Deterministic reasoning (top 100) |
552
+ | Stage 3.5: JD-aligned composite score (0-100 with spread) |
553
+ | Stage 4: SLM rewrite - Qwen2.5-1.5B (JD-aware, offline) |
554
+ | Stage 5: submission.csv |
555
+ +====================================================================+
556
+ """)
557
+
558
+ if args.status:
559
+ status_check(paths)
560
+ model_exists = os.path.exists(args.model)
561
+ print(f" SLM model: {'EXISTS' if model_exists else 'MISSING - run: py setup_model.py'}")
562
+ print(f" Model path: {args.model}")
563
+ return
564
+
565
+ # Validate inputs
566
+ if not os.path.exists(args.candidates):
567
+ log(f"candidates file not found: {args.candidates}", "ERR"); sys.exit(1)
568
+ if args.start_stage <= 2 and not os.path.exists(args.ranker):
569
+ log(f"ranker not found: {args.ranker}", "ERR"); sys.exit(1)
570
+
571
+ print(f" Config:")
572
+ print(f" candidates: {args.candidates} ({fmtmb(args.candidates):.0f} MB)")
573
+ print(f" ranker: {args.ranker}")
574
+ print(f" filter_top_k: {args.filter_top_k:,}")
575
+ print(f" lgbm_top_k: {args.lgbm_top_k:,}")
576
+ print(f" top_final: {args.top_final}")
577
+
578
+ print()
579
+
580
+ monitor = ResourceMonitor()
581
+ monitor.start()
582
+ stage_stats = []
583
+ t_total = time.time()
584
+
585
+ try:
586
+ # STAGE 1
587
+ if args.start_stage <= 1:
588
+ stage_stats.append(stage1_filter(args.candidates, args.filtered, args.filter_top_k, args.report, monitor))
589
+ else:
590
+ log(f"Skipping Stage 1 -- using: {args.filtered}")
591
+
592
+ # STAGE 2
593
+ if args.start_stage <= 2:
594
+ stage_stats.append(stage2_lgbm(args.filtered, args.ranker, args.lgbm_out, args.lgbm_top_k, monitor))
595
+ else:
596
+ log(f"Skipping Stage 2 -- using: {args.lgbm_out}")
597
+
598
+ # STAGE 3
599
+ if args.start_stage <= 3:
600
+ stage_stats.append(stage3_fix_reasoning(args.lgbm_out, args.reasoned_out, args.top_final, monitor))
601
+ else:
602
+ log(f"Skipping Stage 3 -- using: {args.reasoned_out}")
603
+
604
+ # STAGE 3.5 β€” JD-aligned composite score (0-100)
605
+ stage_stats.append(stage35_composite_score(
606
+ args.lgbm_out, args.reasoned_out, args.submission, args.top_final, monitor
607
+ ))
608
+
609
+ # STAGE 4 - write final submission CSV from reasoned JSONL (previously Stage 5)
610
+ stage_stats.append(stage5_submission(args.reasoned_out, args.submission, args.top_final, monitor))
611
+
612
+ finally:
613
+ monitor.stop()
614
+
615
+ print_report([s for s in stage_stats if s], t_total, paths)
616
+ print(f" Submission: {args.submission}")
617
+ print(f" Reasoning: {args.reasoned_out}\n")
618
+
619
+
620
+ if __name__ == "__main__":
621
+ main()
622
+
setup_model.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ setup_model.py - One-time model download for offline SLM reasoning rewriter
4
+ ============================================================================
5
+ Run this ONCE to download the model weights locally.
6
+ After this, rewrite_reasoning.py works fully offline forever.
7
+
8
+ Usage:
9
+ py setup_model.py
10
+
11
+ What this downloads:
12
+ Model : Qwen2.5-1.5B-Instruct-Q4_K_M.gguf
13
+ Size : ~986 MB (~1 GB)
14
+ Source: Hugging Face (bartowski/Qwen2.5-1.5B-Instruct-GGUF)
15
+ Dest : ./models/Qwen2.5-1.5B-Instruct-Q4_K_M.gguf
16
+
17
+ Why Qwen2.5-1.5B:
18
+ - Best 1 GB model for instruction-following + paraphrasing
19
+ - Handles Indian company/tech jargon well
20
+ - Apache 2.0 license (commercial use OK)
21
+ - Runs on CPU, no GPU required
22
+
23
+ After download, install the runtime if not done:
24
+ pip install llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
25
+
26
+ Then run the rewriter:
27
+ py rewrite_reasoning.py --input top100_reasoned.jsonl --out submission.csv
28
+ """
29
+
30
+ import os
31
+ import sys
32
+ import urllib.request
33
+ import urllib.error
34
+ import shutil
35
+ from pathlib import Path
36
+
37
+ # -- Model config -------------------------------------------------------------
38
+
39
+ MODEL_REPO = "bartowski/Qwen2.5-1.5B-Instruct-GGUF"
40
+ MODEL_FILE = "Qwen2.5-1.5B-Instruct-Q4_K_M.gguf"
41
+ MODEL_URL = (
42
+ "https://huggingface.co/bartowski/Qwen2.5-1.5B-Instruct-GGUF"
43
+ "/resolve/main/Qwen2.5-1.5B-Instruct-Q4_K_M.gguf"
44
+ )
45
+ EXPECTED_MB = 986
46
+ MODELS_DIR = Path(__file__).parent / "models"
47
+ MODEL_PATH = MODELS_DIR / MODEL_FILE
48
+
49
+
50
+ # -- Progress bar -------------------------------------------------------------
51
+
52
+ class ProgressBar:
53
+ def __init__(self, total_mb):
54
+ self.total = total_mb * 1024 * 1024
55
+ self.done = 0
56
+ self.last_pct = -1
57
+
58
+ def __call__(self, block_num, block_size, total_size):
59
+ if total_size > 0:
60
+ self.total = total_size
61
+ self.done = min(self.done + block_size, self.total)
62
+ pct = int(self.done * 100 / self.total)
63
+ if pct != self.last_pct and pct % 2 == 0:
64
+ filled = pct // 5
65
+ bar = "#" * filled + "." * (20 - filled)
66
+ mb_done = self.done / 1024 / 1024
67
+ mb_total = self.total / 1024 / 1024
68
+ print(f"\r [{bar}] {pct:3d}% {mb_done:.0f}/{mb_total:.0f} MB",
69
+ end="", flush=True)
70
+ self.last_pct = pct
71
+
72
+
73
+ # -- Download helpers ---------------------------------------------------------
74
+
75
+ def try_huggingface_hub():
76
+ """Try huggingface_hub if installed (resumable downloads)."""
77
+ try:
78
+ from huggingface_hub import hf_hub_download
79
+ print(" Using huggingface_hub (resumable)...")
80
+ hf_hub_download(
81
+ repo_id=MODEL_REPO,
82
+ filename=MODEL_FILE,
83
+ local_dir=str(MODELS_DIR),
84
+ local_dir_use_symlinks=False,
85
+ )
86
+ return True
87
+ except ImportError:
88
+ return False
89
+ except Exception as e:
90
+ print(f" huggingface_hub failed: {e}. Falling back to urllib...")
91
+ return False
92
+
93
+
94
+ def download_urllib():
95
+ print(f" From: {MODEL_URL}")
96
+ print(f" To: {MODEL_PATH}")
97
+ print(f" Size: ~{EXPECTED_MB} MB\n")
98
+ bar = ProgressBar(EXPECTED_MB)
99
+ try:
100
+ urllib.request.urlretrieve(MODEL_URL, MODEL_PATH, reporthook=bar)
101
+ print()
102
+ except urllib.error.URLError as e:
103
+ print(f"\n ERROR: Download failed: {e}")
104
+ if MODEL_PATH.exists():
105
+ MODEL_PATH.unlink()
106
+ sys.exit(1)
107
+
108
+
109
+ # -- Main ---------------------------------------------------------------------
110
+
111
+ def main():
112
+ print("""
113
+ +===================================================+
114
+ | REDROB SLM SETUP - One-time model download |
115
+ | Qwen2.5-1.5B-Instruct-Q4_K_M.gguf (~1 GB) |
116
+ +===================================================+
117
+ """)
118
+
119
+ MODELS_DIR.mkdir(exist_ok=True)
120
+
121
+ # Already downloaded?
122
+ if MODEL_PATH.exists():
123
+ size_mb = MODEL_PATH.stat().st_size / 1024 / 1024
124
+ if size_mb > 900:
125
+ print(f" [OK] Model already at: {MODEL_PATH}")
126
+ print(f" Size: {size_mb:.0f} MB")
127
+ _check_llama_cpp()
128
+ return
129
+ else:
130
+ print(f" Partial download ({size_mb:.0f} MB). Re-downloading...")
131
+ MODEL_PATH.unlink()
132
+
133
+ # Disk space check
134
+ free_gb = shutil.disk_usage(MODELS_DIR).free / 1024 ** 3
135
+ if free_gb < 1.2:
136
+ print(f" ERROR: Need 1.2 GB free, only {free_gb:.1f} GB available.")
137
+ sys.exit(1)
138
+ print(f" Free disk: {free_gb:.1f} GB [OK]")
139
+ print(f" Downloading {MODEL_FILE} ...")
140
+ print()
141
+
142
+ downloaded = try_huggingface_hub()
143
+ if not downloaded:
144
+ download_urllib()
145
+
146
+ # Verify size
147
+ size_mb = MODEL_PATH.stat().st_size / 1024 / 1024
148
+ if size_mb < 900:
149
+ print(f" ERROR: File too small ({size_mb:.0f} MB) β€” may be corrupted.")
150
+ sys.exit(1)
151
+
152
+ print(f"\n [OK] Model ready: {MODEL_PATH} ({size_mb:.0f} MB)")
153
+ _check_llama_cpp()
154
+
155
+ print("\n NEXT STEP:")
156
+ print(" py rewrite_reasoning.py --input top100_reasoned.jsonl --out submission.csv\n")
157
+
158
+
159
+ def _check_llama_cpp():
160
+ print()
161
+ try:
162
+ import llama_cpp
163
+ print(f" [OK] llama-cpp-python: {llama_cpp.__version__}")
164
+ except ImportError:
165
+ print(" [!] llama-cpp-python not installed. Run ONE of:")
166
+ print()
167
+ print(" # CPU only (recommended if no NVIDIA GPU):")
168
+ print(" pip install llama-cpp-python \\")
169
+ print(" --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu")
170
+ print()
171
+ print(" # CUDA 12.1 (if you have an NVIDIA GPU):")
172
+ print(" pip install llama-cpp-python \\")
173
+ print(" --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu121")
174
+
175
+
176
+ if __name__ == "__main__":
177
+ main()
submission.csv ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ candidate_id,rank,score,reasoning
2
+ CAND_0039754,1,89.35,"Senior Applied Scientist at Meta with 16.2 years of experience and 95mo of production Python; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Available within 30 days; active GitHub (score 78/100), highly responsive (81% reply rate) β€” recommend prioritising outreach."
3
+ CAND_0071974,2,89.18,"Senior AI Engineer at Netflix with 7.8 years of experience and 96mo of production Elasticsearch; strong alignment with this retrieval-focused role including 8 of the JD's must-have technical requirements. Notice period is 45 days (manageable); strong GitHub presence (score 83/100), actively job-hunting β€” recommend prioritising outreach."
4
+ CAND_0011687,3,87.76,"Senior NLP Engineer at Niramai with 7.8 years of experience and 92mo of production OpenSearch; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Available within 15 days; active GitHub (score 76/100), highly responsive (89% reply rate) β€” recommend prioritising outreach."
5
+ CAND_0046064,4,87.47,"Senior NLP Engineer at Salesforce with 8.9 years of experience and 85mo of production Pinecone; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Available within 30 days; active GitHub (score 67/100), actively job-hunting β€” recommend prioritising outreach."
6
+ CAND_0088025,5,87.07,"Staff Machine Learning Engineer at Yellow.ai with 8.6 years of experience and 90mo of production Learning to Rank; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Caution: 90-day notice raises timeline risk; active GitHub (score 75/100), highly responsive (83% reply rate) β€” recommend prioritising outreach."
7
+ CAND_0077337,6,86.51,"Staff Machine Learning Engineer at Paytm with 7 years of experience and 82mo of production Python; strong alignment with this retrieval-focused role including 7 of the JD's must-have technical requirements. Notice period is 60 days (manageable); active GitHub (score 68/100), highly responsive (95% reply rate) β€” recommend prioritising outreach."
8
+ CAND_0079387,7,86.34,"AI Engineer at Microsoft with 6.9 years of experience and 87mo of production OpenSearch; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Available within 30 days; active GitHub (score 64/100), highly responsive (81% reply rate) β€” recommend prioritising outreach."
9
+ CAND_0018499,8,86.3,"Senior Machine Learning Engineer at Zomato with 7.2 years of experience and 94mo of production RAG; strong alignment with this retrieval-focused role including 6 of the JD's must-have technical requirements. Available within 15 days; strong GitHub presence (score 95/100), actively job-hunting β€” recommend prioritising outreach."
10
+ CAND_0081846,9,86.28,"Lead AI Engineer at Razorpay with 6.7 years of experience and 95mo of production Embeddings; strong alignment with this retrieval-focused role including 8 of the JD's must-have technical requirements. Available within 30 days; some GitHub activity (score 34/100), actively job-hunting β€” recommend prioritising outreach."
11
+ CAND_0086022,10,85.84,"Senior Applied Scientist at Sarvam AI with 5.3 years of experience and 80mo of production Sentence Transformers; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Caution: 90-day notice raises timeline risk; active GitHub (score 75/100), actively job-hunting β€” recommend prioritising outreach."
12
+ CAND_0052328,11,85.76,"Recommendation Systems Engineer at Amazon with 6.5 years of experience and 71mo of production Sentence Transformers; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Available within 30 days; active GitHub (score 78/100), actively job-hunting β€” recommend prioritising outreach."
13
+ CAND_0061257,12,85.62,"Staff Machine Learning Engineer at LinkedIn with 8 years of experience and 72mo of production Python; covers 1 JD must-haves (Python, Haystack, PEFT) making them a solid candidate for this role. Available within 30 days; some GitHub activity (score 45/100), highly responsive (87% reply rate) β€” recommend prioritising outreach."
14
+ CAND_0041669,13,85.03,"Recommendation Systems Engineer at CRED with 8 years of experience and 76mo of production Milvus; strong alignment with this retrieval-focused role including 6 of the JD's must-have technical requirements. Notice period is 60 days (manageable); active GitHub (score 71/100), actively job-hunting β€” recommend prioritising outreach."
15
+ CAND_0052682,14,85.0,"NLP Engineer at Aganitha with 6.6 years of experience and 81mo of production Python; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Available within 30 days; active GitHub (score 72/100), highly responsive (88% reply rate) β€” recommend prioritising outreach."
16
+ CAND_0064326,15,84.86,"Search Engineer at Sarvam AI with 7.6 years of experience and 60mo of production Python; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Notice period is 45 days (manageable); active GitHub (score 61/100), highly responsive (94% reply rate) β€” recommend prioritising outreach."
17
+ CAND_0093193,16,84.53,"Senior Machine Learning Engineer at Niramai with 7.9 years of experience and 88mo of production OpenSearch; covers 2 JD must-haves (OpenSearch, Qdrant, Haystack) making them a solid candidate for this role. Notice period is 45 days (manageable); some GitHub activity (score 34/100), actively job-hunting β€” recommend prioritising outreach."
18
+ CAND_0007009,17,84.48,"Recommendation Systems Engineer at Wysa with 7.9 years of experience and 96mo of production OpenSearch; strong alignment with this retrieval-focused role including 6 of the JD's must-have technical requirements. Available within 30 days; active GitHub (score 69/100), actively job-hunting β€” recommend prioritising outreach."
19
+ CAND_0046525,18,84.43,"Senior Machine Learning Engineer at Genpact AI with 6.1 years of experience and 74mo of production Sentence Transformers; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Notice period is 60 days (manageable); some GitHub activity (score 37/100), highly responsive (88% reply rate) β€” recommend prioritising outreach."
20
+ CAND_0030468,19,84.34,"Senior Applied Scientist at Swiggy with 5.4 years of experience and 95mo of production OpenSearch; strong alignment with this retrieval-focused role including 2 of the JD's must-have technical requirements. Notice period is 45 days (manageable); some GitHub activity (score 45/100), actively job-hunting β€” recommend prioritising outreach."
21
+ CAND_0055992,20,84.34,"AI Engineer at CRED with 16.9 years of experience and 96mo of production FAISS; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Notice period is 60 days (manageable); active GitHub (score 65/100), actively job-hunting β€” recommend prioritising outreach."
22
+ CAND_0017960,21,83.97,"Recommendation Systems Engineer at Nykaa with 7.7 years of experience and 67mo of production Information Retrieval; strong alignment with this retrieval-focused role including 3 of the JD's must-have technical requirements. Notice period is 60 days (manageable); strong GitHub presence (score 92/100), actively job-hunting β€” recommend prioritising outreach."
23
+ CAND_0006418,22,83.95,"Machine Learning Engineer at Verloop.io with 5.7 years of experience and 93mo of production Weaviate; strong alignment with this retrieval-focused role including 7 of the JD's must-have technical requirements. Notice period is 60 days (manageable); active GitHub (score 60/100), highly responsive (92% reply rate) β€” recommend prioritising outreach."
24
+ CAND_0098846,23,83.84,"AI Engineer at upGrad with 7.6 years of experience and 95mo of production Qdrant; covers 1 JD must-haves (Qdrant, Recommendation Systems, Hugging Face Transformers) making them a solid candidate for this role. Notice period is 45 days (manageable); strong GitHub presence (score 86/100), actively job-hunting β€” recommend prioritising outreach."
25
+ CAND_0062247,24,83.82,"AI Engineer at Google with 7.3 years of experience and 90mo of production Qdrant; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Available within 30 days; active GitHub (score 53/100), actively job-hunting β€” recommend prioritising outreach."
26
+ CAND_0002025,25,83.8,"Senior AI Engineer at Apple with 5.9 years of experience and 86mo of production FAISS; strong alignment with this retrieval-focused role including 6 of the JD's must-have technical requirements. Available within 30 days; strong GitHub presence (score 97/100), highly responsive (80% reply rate) β€” recommend prioritising outreach."
27
+ CAND_0037944,26,83.76,"Senior Data Scientist at Vedantu with 4.9 years of experience and 96mo of production Python; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Available within 30 days; strong GitHub presence (score 83/100), actively job-hunting β€” recommend prioritising outreach."
28
+ CAND_0018549,27,83.55,"Recommendation Systems Engineer at Uber with 6.8 years of experience and 79mo of production Milvus; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Notice period is 60 days (manageable); active GitHub (score 56/100), actively job-hunting β€” recommend prioritising outreach."
29
+ CAND_0037566,28,83.48,"Machine Learning Engineer at LinkedIn with 6.9 years of experience and 74mo of production Pinecone; strong alignment with this retrieval-focused role including 2 of the JD's must-have technical requirements. Available within 15 days; active GitHub (score 73/100), actively job-hunting β€” recommend prioritising outreach."
30
+ CAND_0076163,29,83.46,"NLP Engineer at Ola with 6.9 years of experience and 57mo of production BM25; strong alignment with this retrieval-focused role including 6 of the JD's must-have technical requirements. Notice period is 60 days (manageable); strong GitHub presence (score 85/100), highly responsive (84% reply rate) β€” recommend prioritising outreach."
31
+ CAND_0037980,30,83.44,"Senior Applied Scientist at Niramai (9yr) with skills in BM25, pgvector, LoRA; partial match β€” covers 0 of the JD's must-have retrieval/ranking requirements, with some gaps remaining. Availability: 90-day notice (high risk); primary concern β€” 90-day notice is a risk."
32
+ CAND_0008425,31,83.42,"Senior NLP Engineer at Ola with 7.8 years of experience and 96mo of production Sentence Transformers; strong alignment with this retrieval-focused role including 6 of the JD's must-have technical requirements. Caution: 90-day notice raises timeline risk; active GitHub (score 53/100), actively job-hunting β€” recommend prioritising outreach."
33
+ CAND_0058688,32,83.35,"AI Engineer at Vedantu with 6.7 years of experience and 86mo of production Milvus; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Available within 15 days; some GitHub activity (score 39/100), actively job-hunting β€” recommend prioritising outreach."
34
+ CAND_0006567,33,83.32,"Senior AI Engineer at Meta with 7.9 years of experience and 80mo of production BM25; covers 1 JD must-haves (BM25, Python, Haystack) making them a solid candidate for this role. Notice period is 60 days (manageable); some GitHub activity (score 35/100), actively job-hunting β€” recommend prioritising outreach."
35
+ CAND_0040887,34,83.25,"Machine Learning Engineer at Netflix with 4.7 years of experience and 84mo of production Python; strong alignment with this retrieval-focused role including 3 of the JD's must-have technical requirements. Available within 15 days; some GitHub activity (score 34/100), highly responsive (84% reply rate) β€” recommend prioritising outreach."
36
+ CAND_0065195,35,83.22,"Search Engineer at CRED with 5.1 years of experience and 94mo of production Qdrant; strong alignment with this retrieval-focused role including 3 of the JD's must-have technical requirements. Notice period is 45 days (manageable); strong GitHub presence (score 87/100), highly responsive (80% reply rate) β€” recommend prioritising outreach."
37
+ CAND_0099806,36,83.12,"AI Engineer at Mad Street Den with 4.6 years of experience and 89mo of production Embeddings; strong alignment with this retrieval-focused role including 6 of the JD's must-have technical requirements. Available within 30 days; strong GitHub presence (score 87/100), actively job-hunting β€” recommend prioritising outreach."
38
+ CAND_0042506,37,83.09,"Search Engineer at Verloop.io with 4.2 years of experience and 88mo of production Information Retrieval; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Available within 15 days; active GitHub (score 64/100), actively job-hunting β€” recommend prioritising outreach."
39
+ CAND_0093912,38,83.06,"Senior Data Scientist at Razorpay with 5.3 years of experience and 80mo of production Vector Search; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Available within 30 days; strong GitHub presence (score 82/100), actively job-hunting β€” recommend prioritising outreach."
40
+ CAND_0041610,39,82.62,"Recommendation Systems Engineer at Zoho with 6.7 years of experience and 94mo of production Elasticsearch; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Available within 30 days; active GitHub (score 52/100), actively job-hunting β€” recommend prioritising outreach."
41
+ CAND_0081053,40,82.48,"NLP Engineer at Glance with 5.4 years of experience and 77mo of production Weaviate; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Caution: 90-day notice raises timeline risk; active GitHub (score 78/100), highly responsive (83% reply rate) β€” recommend prioritising outreach."
42
+ CAND_0005649,41,82.4,"Senior Data Scientist at Sarvam AI with 7.4 years of experience and 74mo of production Weaviate; strong alignment with this retrieval-focused role including 3 of the JD's must-have technical requirements. Caution: 90-day notice raises timeline risk; strong GitHub presence (score 81/100), actively job-hunting β€” recommend prioritising outreach."
43
+ CAND_0051004,42,82.4,"Senior Data Scientist at CRED with 4.7 years of experience and 95mo of production Vector Search; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Available within 15 days; some GitHub activity (score 31/100), actively job-hunting β€” recommend prioritising outreach."
44
+ CAND_0027691,43,82.38,"NLP Engineer at Haptik with 6.5 years of experience and 73mo of production Weaviate; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Available within 15 days; active GitHub (score 58/100), actively job-hunting β€” recommend prioritising outreach."
45
+ CAND_0014440,44,82.36,"Recommendation Systems Engineer at CRED with 6.4 years of experience and 79mo of production Milvus; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Notice period is 60 days (manageable); strong GitHub presence (score 90/100), actively job-hunting β€” recommend prioritising outreach."
46
+ CAND_0039383,45,82.3,"Applied ML Engineer at Meesho with 7.1 years of experience and 94mo of production pgvector; strong alignment with this retrieval-focused role including 3 of the JD's must-have technical requirements. Caution: 90-day notice raises timeline risk; strong GitHub presence (score 86/100), actively job-hunting β€” recommend prioritising outreach."
47
+ CAND_0074735,46,82.23,"Applied ML Engineer at Rephrase.ai with 5.5 years of experience and 82mo of production Learning to Rank; strong alignment with this retrieval-focused role including 6 of the JD's must-have technical requirements. Caution: 90-day notice raises timeline risk; strong GitHub presence (score 88/100), actively job-hunting β€” recommend prioritising outreach."
48
+ CAND_0075574,47,82.23,"Machine Learning Engineer at Haptik with 5.7 years of experience and 94mo of production OpenSearch; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Notice period is 60 days (manageable); some GitHub activity (score 38/100), actively job-hunting β€” recommend prioritising outreach."
49
+ CAND_0009024,48,82.22,"Search Engineer at Google with 5.2 years of experience and 90mo of production OpenSearch; strong alignment with this retrieval-focused role including 3 of the JD's must-have technical requirements. Available within 30 days; some GitHub activity (score 33/100), actively job-hunting β€” recommend prioritising outreach."
50
+ CAND_0081852,49,82.17,"Senior Data Scientist at Mad Street Den with 5.9 years of experience and 61mo of production Vector Search; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Notice period is 45 days (manageable); strong GitHub presence (score 88/100), actively job-hunting β€” recommend prioritising outreach."
51
+ CAND_0066999,50,82.13,"Recommendation Systems Engineer at Microsoft with 5.9 years of experience and 54mo of production FAISS; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Available within 15 days; strong GitHub presence (score 94/100), actively job-hunting β€” recommend prioritising outreach."
52
+ CAND_0042100,51,82.02,"Machine Learning Engineer at Freshworks with 7.3 years of experience and 69mo of production Pinecone; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Caution: 90-day notice raises timeline risk; some GitHub activity (score 43/100), highly responsive (87% reply rate) β€” recommend prioritising outreach."
53
+ CAND_0078002,52,82.0,"Machine Learning Engineer at Meta with 6.3 years of experience and 60mo of production pgvector; covers 1 JD must-haves (pgvector, Weaviate, QLoRA) making them a solid candidate for this role. Notice period is 60 days (manageable); active GitHub (score 51/100), highly responsive (86% reply rate) β€” recommend prioritising outreach."
54
+ CAND_0091909,53,81.94,"Machine Learning Engineer at Rephrase.ai with 6.9 years of experience and 84mo of production Pinecone; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Notice period is 45 days (manageable); some GitHub activity (score 50/100), actively job-hunting β€” recommend prioritising outreach."
55
+ CAND_0094759,54,81.94,"Lead AI Engineer at Meta with 8.6 years of experience and 55mo of production Qdrant; strong alignment with this retrieval-focused role including 6 of the JD's must-have technical requirements. Available within 30 days; active GitHub (score 76/100) β€” recommend prioritising outreach."
56
+ CAND_0036184,55,81.9,"Recommendation Systems Engineer at CRED with 6 years of experience and 94mo of production Sentence Transformers; strong alignment with this retrieval-focused role including 6 of the JD's must-have technical requirements. Available within 30 days; some GitHub activity (score 43/100), highly responsive (90% reply rate) β€” recommend prioritising outreach."
57
+ CAND_0055905,56,81.83,"Senior Machine Learning Engineer at Flipkart with 8.1 years of experience and 93mo of production Vector Search; strong alignment with this retrieval-focused role including 6 of the JD's must-have technical requirements. Available within 30 days; highly responsive (87% reply rate), actively job-hunting β€” recommend prioritising outreach."
58
+ CAND_0069905,57,81.78,"Applied ML Engineer at Sarvam AI with 6.6 years of experience and 78mo of production Weaviate; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Caution: 90-day notice raises timeline risk; some GitHub activity (score 45/100), actively job-hunting β€” recommend prioritising outreach."
59
+ CAND_0080766,58,81.65,"Staff Machine Learning Engineer at Salesforce with 8.8 years of experience and 53mo of production Python; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Caution: 90-day notice raises timeline risk; some GitHub activity (score 33/100) β€” recommend prioritising outreach."
60
+ CAND_0018722,59,81.61,"Recommendation Systems Engineer at Saarthi.ai with 6.6 years of experience and 86mo of production Weaviate; strong alignment with this retrieval-focused role including 2 of the JD's must-have technical requirements. Caution: 90-day notice raises timeline risk; strong GitHub presence (score 94/100), actively job-hunting β€” recommend prioritising outreach."
61
+ CAND_0028793,60,81.54,"Search Engineer at Google with 7.2 years of experience and 78mo of production Embeddings; strong alignment with this retrieval-focused role including 3 of the JD's must-have technical requirements. Caution: 120-day notice raises timeline risk; strong GitHub presence (score 83/100), actively job-hunting β€” recommend prioritising outreach."
62
+ CAND_0051615,61,81.52,"Search Engineer at Meta with 4.6 years of experience and 84mo of production RAG; strong alignment with this retrieval-focused role including 3 of the JD's must-have technical requirements. Notice period is 60 days (manageable); strong GitHub presence (score 95/100), highly responsive (88% reply rate) β€” recommend prioritising outreach."
63
+ CAND_0075249,62,81.47,"Applied ML Engineer at Zomato with 6.2 years of experience and 80mo of production Pinecone; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Notice period is 45 days (manageable); some GitHub activity (score 37/100), highly responsive (82% reply rate) β€” recommend prioritising outreach."
64
+ CAND_0068811,63,81.41,"Applied ML Engineer at Freshworks with 8 years of experience and 86mo of production Pinecone; strong alignment with this retrieval-focused role including 6 of the JD's must-have technical requirements. Available within 30 days; some GitHub activity (score 23/100), actively job-hunting β€” recommend prioritising outreach."
65
+ CAND_0044222,64,81.34,"AI Engineer at PolicyBazaar with 7.7 years of experience and 75mo of production OpenSearch; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Notice period is 60 days (manageable); some GitHub activity (score 29/100), actively job-hunting β€” recommend prioritising outreach."
66
+ CAND_0077285,65,81.05,"Recommendation Systems Engineer at Nykaa with 5.5 years of experience and 66mo of production Sentence Transformers; strong alignment with this retrieval-focused role including 6 of the JD's must-have technical requirements. Notice period is 60 days (manageable); active GitHub (score 76/100), actively job-hunting β€” recommend prioritising outreach."
67
+ CAND_0005538,66,81.01,"Senior AI Engineer at Adobe with 5.9 years of experience and 91mo of production pgvector; covers 1 JD must-haves (pgvector, Python, PyTorch) making them a solid candidate for this role. Caution: 90-day notice raises timeline risk; active GitHub (score 58/100), highly responsive (81% reply rate) β€” recommend prioritising outreach."
68
+ CAND_0010257,67,80.95,"Senior Data Scientist at Google with 6.5 years of experience and 86mo of production Python; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Caution: 120-day notice raises timeline risk; some GitHub activity (score 46/100), actively job-hunting β€” recommend prioritising outreach."
69
+ CAND_0020708,68,80.79,"Search Engineer at PolicyBazaar with 4.2 years of experience and 75mo of production Learning to Rank; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Available within 30 days; strong GitHub presence (score 84/100), highly responsive (84% reply rate) β€” recommend prioritising outreach."
70
+ CAND_0054394,69,80.78,"Recommendation Systems Engineer at PharmEasy with 4.1 years of experience and 71mo of production Embeddings; covers 2 JD must-haves (Embeddings, Pinecone, Haystack) making them a solid candidate for this role. Available within 30 days; some GitHub activity (score 32/100), actively job-hunting β€” recommend prioritising outreach."
71
+ CAND_0078492,70,80.75,"Recommendation Systems Engineer at Verloop.io with 5.1 years of experience and 66mo of production FAISS; strong alignment with this retrieval-focused role including 3 of the JD's must-have technical requirements. Available within 30 days; some GitHub activity (score 35/100), actively job-hunting β€” recommend prioritising outreach."
72
+ CAND_0016163,71,80.71,"Applied ML Engineer at Dream11 with 6.7 years of experience and 64mo of production pgvector; strong alignment with this retrieval-focused role including 3 of the JD's must-have technical requirements. Caution: 120-day notice raises timeline risk; strong GitHub presence (score 95/100), actively job-hunting β€” recommend prioritising outreach."
73
+ CAND_0033861,72,80.39,"Senior NLP Engineer at Mad Street Den with 8 years of experience and 81mo of production Learning to Rank; strong alignment with this retrieval-focused role including 8 of the JD's must-have technical requirements. Available within 30 days; some GitHub activity (score 33/100) β€” recommend prioritising outreach."
74
+ CAND_0053591,73,80.32,"AI Engineer at Ola with 5.3 years of experience and 63mo of production Sentence Transformers; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Notice period is 60 days (manageable); active GitHub (score 76/100), highly responsive (81% reply rate) β€” recommend prioritising outreach."
75
+ CAND_0007411,74,80.28,"Senior Machine Learning Engineer at Amazon with 8 years of experience and 86mo of production OpenSearch; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Available within 15 days; strong GitHub presence (score 90/100) β€” recommend prioritising outreach."
76
+ CAND_0027801,75,80.24,"NLP Engineer at InMobi with 7.4 years of experience and 80mo of production FAISS; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Caution: 120-day notice raises timeline risk; active GitHub (score 63/100), actively job-hunting β€” recommend prioritising outreach."
77
+ CAND_0040117,76,80.17,"Recommendation Systems Engineer at PhonePe with 6.5 years of experience and 95mo of production RAG; covers 2 JD must-haves (RAG, FAISS, Embeddings) making them a solid candidate for this role. Available within 15 days; actively job-hunting β€” recommend prioritising outreach."
78
+ CAND_0011432,77,80.16,"Senior Data Scientist at Amazon with 7.6 years of experience and 65mo of production pgvector; strong alignment with this retrieval-focused role including 2 of the JD's must-have technical requirements. Notice period is 60 days (manageable); some GitHub activity (score 20/100), actively job-hunting β€” recommend prioritising outreach."
79
+ CAND_0011162,78,80.15,"Recommendation Systems Engineer at upGrad with 5.8 years of experience and 94mo of production FAISS; strong alignment with this retrieval-focused role including 9 of the JD's must-have technical requirements. Caution: 90-day notice raises timeline risk; some GitHub activity (score 34/100), actively job-hunting β€” recommend prioritising outreach."
80
+ CAND_0042029,79,80.15,"Senior Data Scientist at Flipkart with 6.5 years of experience and 96mo of production OpenSearch; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Notice period is 45 days (manageable); active GitHub (score 79/100) β€” recommend prioritising outreach."
81
+ CAND_0095619,80,80.13,"NLP Engineer at Nykaa with 15.6 years of experience and 74mo of production Pinecone; strong alignment with this retrieval-focused role including 6 of the JD's must-have technical requirements. Available within 30 days; some GitHub activity (score 30/100), highly responsive (90% reply rate) β€” recommend prioritising outreach."
82
+ CAND_0036437,81,80.12,"Search Engineer at Rephrase.ai with 4.8 years of experience and 93mo of production Sentence Transformers; strong alignment with this retrieval-focused role including 7 of the JD's must-have technical requirements. Available within 30 days; some GitHub activity (score 35/100), highly responsive (87% reply rate) β€” recommend prioritising outreach."
83
+ CAND_0053695,82,80.12,"Recommendation Systems Engineer at Meesho with 5.8 years of experience and 90mo of production Pinecone; strong alignment with this retrieval-focused role including 3 of the JD's must-have technical requirements. Available within 15 days; active GitHub (score 68/100), actively job-hunting β€” recommend prioritising outreach."
84
+ CAND_0050454,83,80.08,"AI Engineer at Rephrase.ai with 6.8 years of experience and 62mo of production FAISS; strong alignment with this retrieval-focused role including 2 of the JD's must-have technical requirements. Available within 30 days; actively job-hunting β€” recommend prioritising outreach."
85
+ CAND_0019480,84,80.04,"NLP Engineer at Meesho with 2.8 years of experience and 58mo of production Milvus; covers 2 JD must-haves (Milvus, OpenSearch, BM25) making them a solid candidate for this role. Caution: 90-day notice raises timeline risk; active GitHub (score 70/100), highly responsive (87% reply rate) β€” recommend prioritising outreach."
86
+ CAND_0024620,85,79.95,"AI Engineer at PharmEasy with 5.9 years of experience and 90mo of production BM25; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Notice period is 45 days (manageable); some GitHub activity (score 33/100), actively job-hunting β€” recommend prioritising outreach."
87
+ CAND_0029367,86,79.95,"Senior Data Scientist at Rephrase.ai with 5.7 years of experience and 95mo of production Sentence Transformers; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Caution: 90-day notice raises timeline risk; some GitHub activity (score 40/100), actively job-hunting β€” recommend prioritising outreach."
88
+ CAND_0083879,87,79.91,"Machine Learning Engineer at Ola with 7.1 years of experience and 51mo of production Sentence Transformers; strong alignment with this retrieval-focused role including 2 of the JD's must-have technical requirements. Available within 30 days; actively job-hunting β€” recommend prioritising outreach."
89
+ CAND_0050876,88,79.86,"Applied ML Engineer at Freshworks with 6 years of experience and 70mo of production Sentence Transformers; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Caution: 90-day notice raises timeline risk; strong GitHub presence (score 86/100), actively job-hunting β€” recommend prioritising outreach."
90
+ CAND_0000031,89,79.84,"Recommendation Systems Engineer at Swiggy with 6 years of experience and 88mo of production Pinecone; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Notice period is 60 days (manageable); some GitHub activity (score 33/100), highly responsive (91% reply rate) β€” recommend prioritising outreach."
91
+ CAND_0051292,90,79.76,"Applied ML Engineer at Freshworks with 5.2 years of experience and 86mo of production RAG; strong alignment with this retrieval-focused role including 3 of the JD's must-have technical requirements. Available within 30 days; active GitHub (score 62/100), actively job-hunting β€” recommend prioritising outreach."
92
+ CAND_0061265,91,79.73,"Recommendation Systems Engineer at Zoho with 6.6 years of experience and 84mo of production Learning to Rank; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Caution: 120-day notice raises timeline risk; active GitHub (score 80/100), highly responsive (94% reply rate) β€” recommend prioritising outreach."
93
+ CAND_0009691,92,79.69,"Applied ML Engineer at LinkedIn with 6.2 years of experience and 63mo of production Qdrant; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Caution: 120-day notice raises timeline risk; active GitHub (score 68/100), actively job-hunting β€” recommend prioritising outreach."
94
+ CAND_0030348,93,79.54,"Machine Learning Engineer at BYJU'S with 4.5 years of experience and 94mo of production pgvector; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Notice period is 45 days (manageable); actively job-hunting β€” recommend prioritising outreach."
95
+ CAND_0091534,94,79.43,"AI Engineer at Flipkart with 16.6 years of experience and 73mo of production RAG; strong alignment with this retrieval-focused role including 2 of the JD's must-have technical requirements. Available within 30 days; active GitHub (score 66/100), highly responsive (84% reply rate) β€” recommend prioritising outreach."
96
+ CAND_0013536,95,79.37,"Applied ML Engineer at Haptik with 14.1 years of experience and 75mo of production Vector Search; strong alignment with this retrieval-focused role including 6 of the JD's must-have technical requirements. Caution: 90-day notice raises timeline risk; active GitHub (score 52/100), highly responsive (87% reply rate) β€” recommend prioritising outreach."
97
+ CAND_0010685,96,79.35,"NLP Engineer at Rephrase.ai with 6.7 years of experience and 78mo of production Elasticsearch; strong alignment with this retrieval-focused role including 2 of the JD's must-have technical requirements. Available within 30 days; highly responsive (83% reply rate), actively job-hunting β€” recommend prioritising outreach."
98
+ CAND_0064904,97,79.29,"AI Engineer at LinkedIn with 4.9 years of experience and 74mo of production Embeddings; strong alignment with this retrieval-focused role including 6 of the JD's must-have technical requirements. Caution: 90-day notice raises timeline risk; active GitHub (score 59/100), actively job-hunting β€” recommend prioritising outreach."
99
+ CAND_0061339,98,79.22,"Search Engineer at Rephrase.ai with 4.2 years of experience and 95mo of production Information Retrieval; strong alignment with this retrieval-focused role including 4 of the JD's must-have technical requirements. Caution: 90-day notice raises timeline risk; strong GitHub presence (score 86/100), highly responsive (90% reply rate) β€” recommend prioritising outreach."
100
+ CAND_0096142,99,79.14,"Applied ML Engineer at upGrad with 5 years of experience and 72mo of production Python; strong alignment with this retrieval-focused role including 3 of the JD's must-have technical requirements. Caution: 120-day notice raises timeline risk; strong GitHub presence (score 80/100), highly responsive (84% reply rate) β€” recommend prioritising outreach."
101
+ CAND_0001610,100,79.1,"Machine Learning Engineer at Dream11 with 3 years of experience and 85mo of production FAISS; strong alignment with this retrieval-focused role including 5 of the JD's must-have technical requirements. Caution: 90-day notice raises timeline risk; some GitHub activity (score 40/100), actively job-hunting β€” recommend prioritising outreach."
validate_submission.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Validate submission CSV per challenge rules (sections 2–3).
4
+ Row 1 = header. Rows 2–101 = exactly 100 data rows. CSV only.
5
+ """
6
+
7
+ import csv
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ REQUIRED_HEADER = ["candidate_id", "rank", "score", "reasoning"]
13
+ CANDIDATE_ID_PATTERN = re.compile(r"^CAND_[0-9]{7}$")
14
+ DATA_ROW_START = 2
15
+ EXPECTED_DATA_ROWS = 100
16
+
17
+
18
+ def validate_submission(csv_path):
19
+ errors = []
20
+ path = Path(csv_path)
21
+
22
+ if path.suffix.lower() != ".csv":
23
+ errors.append("Filename must use a .csv extension.")
24
+ elif not path.stem:
25
+ errors.append("Filename must be your registered participant ID (e.g. team_xxx.csv).")
26
+
27
+ try:
28
+ with open(path, "r", encoding="utf-8", newline="") as f:
29
+ reader = csv.reader(f)
30
+
31
+ try:
32
+ header = next(reader)
33
+ except StopIteration:
34
+ errors.append("Row 1 must be the header row; file is empty.")
35
+ return errors
36
+
37
+ # Row 1: column names and their order come from this line only
38
+ if header != REQUIRED_HEADER:
39
+ errors.append(
40
+ "Row 1 (header) must be exactly:\n"
41
+ f" {','.join(REQUIRED_HEADER)}\n"
42
+ f"Found:\n"
43
+ f" {','.join(header)}"
44
+ )
45
+
46
+ data_rows = []
47
+ for row in reader:
48
+ if any(cell.strip() for cell in row):
49
+ data_rows.append(row)
50
+
51
+ except UnicodeDecodeError:
52
+ errors.append("File must be UTF-8 encoded.")
53
+ return errors
54
+ except OSError as e:
55
+ errors.append(f"Cannot read file: {e}")
56
+ return errors
57
+
58
+ n = len(data_rows)
59
+ if n != EXPECTED_DATA_ROWS:
60
+ errors.append(
61
+ f"After the header (row 1), there must be exactly {EXPECTED_DATA_ROWS} "
62
+ f"data rows (rows {DATA_ROW_START}–{DATA_ROW_START + EXPECTED_DATA_ROWS - 1}); "
63
+ f"found {n}."
64
+ )
65
+
66
+ seen_ids = set()
67
+ seen_ranks = set()
68
+ by_rank = []
69
+
70
+ for i, cells in enumerate(data_rows):
71
+ row_num = DATA_ROW_START + i
72
+
73
+ if len(cells) != len(REQUIRED_HEADER):
74
+ errors.append(
75
+ f"Row {row_num}: expected {len(REQUIRED_HEADER)} columns "
76
+ f"({','.join(REQUIRED_HEADER)}), got {len(cells)}."
77
+ )
78
+ continue
79
+
80
+ row = dict(zip(REQUIRED_HEADER, cells))
81
+ cid = row["candidate_id"].strip()
82
+ rank_s = row["rank"].strip()
83
+ score_s = row["score"].strip()
84
+
85
+ if not cid:
86
+ errors.append(f"Row {row_num}: candidate_id is required.")
87
+ elif not CANDIDATE_ID_PATTERN.match(cid):
88
+ errors.append(
89
+ f"Row {row_num}: candidate_id must be CAND_XXXXXXX (7 digits)."
90
+ )
91
+ elif cid in seen_ids:
92
+ errors.append(f"Row {row_num}: duplicate candidate_id '{cid}'.")
93
+ else:
94
+ seen_ids.add(cid)
95
+
96
+ try:
97
+ rank = int(rank_s)
98
+ if str(rank) != rank_s:
99
+ raise ValueError
100
+ if not 1 <= rank <= 100:
101
+ errors.append(f"Row {row_num}: rank must be between 1 and 100.")
102
+ elif rank in seen_ranks:
103
+ errors.append(f"Row {row_num}: duplicate rank {rank}.")
104
+ else:
105
+ seen_ranks.add(rank)
106
+ except ValueError:
107
+ errors.append(f"Row {row_num}: rank must be an integer (1–100).")
108
+ rank = None
109
+
110
+ try:
111
+ score = float(score_s)
112
+ except ValueError:
113
+ errors.append(f"Row {row_num}: score must be a float.")
114
+ score = None
115
+
116
+ if rank is not None and score is not None and cid:
117
+ by_rank.append((rank, score, cid))
118
+
119
+ missing = set(range(1, 101)) - seen_ranks
120
+ if missing:
121
+ errors.append(
122
+ f"Each rank 1–100 must appear exactly once; missing: {sorted(missing)}"
123
+ )
124
+
125
+ by_rank.sort(key=lambda x: x[0])
126
+
127
+ for i in range(len(by_rank) - 1):
128
+ r1, s1, _ = by_rank[i]
129
+ r2, s2, _ = by_rank[i + 1]
130
+ if s1 < s2:
131
+ errors.append(
132
+ f"score must be non-increasing by rank: "
133
+ f"rank {r1} ({s1}) < rank {r2} ({s2})."
134
+ )
135
+
136
+ for i in range(len(by_rank) - 1):
137
+ r1, s1, c1 = by_rank[i]
138
+ r2, s2, c2 = by_rank[i + 1]
139
+ if s1 == s2 and c1 > c2:
140
+ errors.append(
141
+ f"Equal scores at ranks {r1} and {r2}: "
142
+ f"tie-break requires candidate_id ascending "
143
+ f"({c1!r} > {c2!r})."
144
+ )
145
+
146
+ return errors
147
+
148
+
149
+ def main():
150
+ if len(sys.argv) != 2:
151
+ print("Usage: python validate_submission.py <participant_id>.csv")
152
+ sys.exit(1)
153
+
154
+ errors = validate_submission(sys.argv[1])
155
+ if errors:
156
+ print(f"Validation failed ({len(errors)} issue(s)):\n")
157
+ for e in errors:
158
+ print(f"- {e}")
159
+ sys.exit(1)
160
+
161
+ print("Submission is valid.")
162
+
163
+
164
+ if __name__ == "__main__":
165
+ main()