Upload 13 files
Browse files- Dockerfile +26 -0
- LICENSE +201 -0
- README.md +341 -7
- filter_candidates.py +2096 -0
- fix_reasoning.py +513 -0
- regressor_no_prescore.pkl +3 -0
- regressor_no_prescore_features.json +51 -0
- requirements.txt +44 -0
- rewrite_reasoning.py +397 -0
- run_pipeline.py +622 -0
- setup_model.py +177 -0
- submission.csv +101 -0
- validate_submission.py +165 -0
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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
| 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()
|