File size: 13,528 Bytes
1aebf33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
"""End-to-end ranking pipeline.

This is THE reproduce command listed in submission_metadata.yaml.

Usage:
    python scripts/run_ranking.py \\
        --candidates "<path-to-candidates.jsonl>" \\
        --out        "<path-to-submission.csv>"

It streams the candidate pool, builds BM25 + (optional) dense indices,
fuses them with RRF, computes the full feature set on the shortlist, and
applies the trained ranker (or the deterministic fallback). The result
is a 100-row CSV that the validator checks for compliance.

If a precomputed artifact is missing, the pipeline rebuilds it. All
artifacts live under `artifacts/`.
"""
from __future__ import annotations

import argparse
import json
import os
import pickle
import re
import sys
import time
from pathlib import Path

import numpy as np
import pandas as pd

PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

# Determinism
os.environ.setdefault("PYTHONHASHSEED", "0")
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")

from redrob import config
from redrob.blueprint import build_blueprint, save_blueprint
from redrob.data import load_or_build_parquet
from redrob.retrieval.bm25 import build_bm25, bm25_search
from redrob.retrieval.dense import (
    build_dense_index, dense_search, dense_model_name,
)
from redrob.retrieval.rrf import rrf_fuse
from redrob.rank.predict import rank_shortlist
from redrob.submit.write import write_submission


def _save_id_map(path: Path, ids: list) -> None:
    with open(path, "w", encoding="utf-8") as f:
        json.dump(ids, f)


def _load_id_map(path: Path) -> list:
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def run(args) -> None:
    t_start = time.time()
    print(f"[{time.time()-t_start:5.1f}s] starting pipeline")

    # 1) Blueprint
    save_blueprint()
    bp = build_blueprint()
    queries = bp["query_terms"]
    print(f"[{time.time()-t_start:5.1f}s] blueprint saved ({len(config.CORE_COMPETENCIES)} core skills)")

    # 2) Load or build the canonicalised parquet
    df = load_or_build_parquet(args.candidates)
    print(f"[{time.time()-t_start:5.1f}s] canonicalised: {len(df):,} candidates")

    # 3) BM25 sparse retrieval
    bm25, _ = build_bm25(force=False)
    sparse_hits = bm25_search(queries, top_k=config.BM25_TOP_K, bm25_obj=(bm25, _))
    sparse_rankings = list(sparse_hits.values())
    print(f"[{time.time()-t_start:5.1f}s] BM25 ready (top-{config.BM25_TOP_K} per query)")

    # 4) Dense retrieval (or fallback)
    dense_rankings: list = []
    if not args.no_dense:
        try:
            t_dense = time.time()
            print(f"[{time.time()-t_start:5.1f}s] building dense index (model={dense_model_name()}) ...")
            # If the cache exists, use it (fast). Otherwise encode and cache.
            emb, ids = build_dense_index(force=False)
            dense_hits = dense_search(queries, top_k=config.DENSE_TOP_K, matrix=(emb, ids))
            dense_rankings = list(dense_hits.values())
            print(f"[{time.time()-t_start:5.1f}s] dense ready in {time.time()-t_dense:.1f}s")
        except Exception as e:  # noqa: BLE001
            print(f"[{time.time()-t_start:5.1f}s] dense unavailable ({e}); using BM25-only")
            dense_rankings = []
    else:
        print(f"[{time.time()-t_start:5.1f}s] dense path disabled by --no_dense")

    # 5) Structured high-recall gate as SOFT score (saturating)
    gate_terms_lc = {t.lower() for t in config.STRUCTURED_GATE_TERMS}
    df_reset = df.reset_index(drop=True)

    def _gate_text_score(text):
        if not text:
            return 0
        tl = text.lower()
        n_hits = sum(1 for g in gate_terms_lc if g in tl)
        return float(min(1.0, n_hits / 5.0))

    def _gate_skill_score(skills):
        if skills is None:
            return 0.0
        if hasattr(skills, "tolist"):
            skills = skills.tolist()
        n = sum(1 for s in (skills or []) if (s or "").lower() in gate_terms_lc)
        return float(min(1.0, n / 3.0))

    text_scores = df_reset["text_corpus"].fillna("").apply(_gate_text_score).to_numpy()
    skill_scores = df_reset["skill_names"].apply(_gate_skill_score).to_numpy()
    gate_score = np.maximum(text_scores, skill_scores)
    structured_hit = (gate_score > 0.0).astype(np.float32)
    print(f"[{time.time()-t_start:5.1f}s] structured gate: median score {np.median(gate_score):.2f}, "
          f"hits {int(structured_hit.sum()):,}/{len(df):,}")

    # 6) RRF over all channels with per-channel weights
    # BM25 (reliable) > structured-gate (binary OK) > dense (noisy in this domain)
    rankings = list(sparse_rankings) + list(dense_rankings)
    if dense_rankings:
        channel_weights = [1.2] * len(sparse_rankings) + [0.8] * len(dense_rankings)
    else:
        channel_weights = [1.0] * len(sparse_rankings)
    fused = rrf_fuse(rankings, k=config.RRF_K, top_n=config.SHORTLIST_N,
                     channel_weights=channel_weights)
    shortlist_ids = [cid for cid, _score, _r in fused]
    shortlist_set = set(shortlist_ids)
    if args.verbose:
        print(f"[{time.time()-t_start:5.1f}s] RRF shortlist: {len(shortlist_ids):,}")

    # Add top structured-gate candidates not already in shortlist (sorted by gate_score)
    struct_candidates = df_reset.loc[gate_score > 0.0].copy()
    struct_candidates = struct_candidates.assign(_g=gate_score[gate_score > 0.0])
    struct_candidates = struct_candidates.sort_values("_g", ascending=False)
    added = 0
    for cid in struct_candidates["candidate_id"].tolist():
        if cid not in shortlist_set:
            shortlist_ids.append(cid)
            shortlist_set.add(cid)
            added += 1
            if added >= args.structured_top_up:
                break
    if args.verbose:
        print(f"[{time.time()-t_start:5.1f}s] +{added:,} from structured gate (total shortlist: {len(shortlist_ids):,})")
    struct_ids = df_reset.loc[gate_score > 0.0, "candidate_id"].tolist()
    gate_score_map = {cid: float(s) for cid, s in zip(df_reset["candidate_id"].tolist(), gate_score)}

    # ----------------------------------------------------------------------
    # Plain-language Tier-5 retrieval top-up (added by super-smart-agent review).
    # Some excellent candidates don't say RAG/Pinecone in skills, but their
    # career DESCRIPTIONS mention shipping ranking/search/recsys systems.
    # Optimised: pre-compile regexes, use only the first N rows for speed,
    # and score on text_corpus (already-built column) instead of building
    # new strings per candidate.
    # ----------------------------------------------------------------------
    _SHIP_KW = re.compile(
        r"\b(built|shipped|launched|scaled|deployed|owned|led|designed|"
        r"implemented|migrated|productionized)\b",
        re.IGNORECASE,
    )
    _RETRIEVAL_KW = re.compile(
        r"\b(matching|personalization|personalisation|marketplace|"
        r"recruiter engagement|click.?through|engagement|"
        r"index|refresh|search system|relevance|re-ranking|reranking|"
        r"semantic|embeddings|indexing|indexed|ranker)\b",
        re.IGNORECASE,
    )
    _PRODUCT_TITLE_KW = re.compile(
        r"\b(ai engineer|machine learning|ml engineer|"
        r"applied scientist|research engineer|search engineer|"
        r"ranking engineer|recommendation|nlp engineer|data scientist)\b",
        re.IGNORECASE,
    )
    _PLAIN_LANG_TOPUP = 200  # how many extra candidates to add

    plain_lang_candidates = []
    _current_titles = df_reset["current_title"].fillna("").str.lower().tolist()
    _text_corpuses = df_reset["text_corpus"].fillna("").str[:1000].tolist()  # cap length
    _cids_all = df_reset["candidate_id"].tolist()
    n_total = len(_cids_all)
    # Scan only candidates not already in shortlist (faster loop)
    scan_indices = [i for i, cid in enumerate(_cids_all) if cid not in shortlist_set]

    for i in scan_indices:
        # Quick title pre-filter
        if not _PRODUCT_TITLE_KW.search(_current_titles[i] or ""):
            continue
        # Use the pre-built text_corpus (head 1000 chars) — fast substring match
        text = (_text_corpuses[i] or "").lower()
        if not text:
            continue
        n_ship = len(_SHIP_KW.findall(text))
        if n_ship < 1:
            continue
        n_ret = len(_RETRIEVAL_KW.findall(text))
        if n_ret >= 1:
            plain_lang_candidates.append((n_ship + n_ret, _cids_all[i]))

    plain_lang_candidates.sort(reverse=True)
    added = 0
    for _, cid in plain_lang_candidates[:_PLAIN_LANG_TOPUP]:
        if cid not in shortlist_set:
            shortlist_ids.append(cid)
            shortlist_set.add(cid)
            added += 1
    if args.verbose:
        print(f"[{time.time()-t_start:5.1f}s] +{added:,} from plain-language top-up "
              f"(of {len(plain_lang_candidates):,} eligible; scanned {len(scan_indices):,})")

    # 7) Build the shortlist dataframe
    shortlist_df = df_reset[df_reset["candidate_id"].isin(shortlist_set)].copy()

    # 8) Per-channel scores for the shortlist (sparse/dense/rrf)
    sparse_score_by_id: dict = {}
    for ranking in sparse_rankings:
        for cid, sc in ranking:
            if cid in shortlist_set:
                sparse_score_by_id[cid] = max(sparse_score_by_id.get(cid, 0.0), sc)
    dense_score_by_id: dict = {}
    for ranking in dense_rankings:
        for cid, sc in ranking:
            if cid in shortlist_set:
                dense_score_by_id[cid] = max(dense_score_by_id.get(cid, 0.0), sc)
    rrf_score_by_id: dict = {cid: s for cid, s, _r in fused}

    shortlist_df["sparse_score"] = shortlist_df["candidate_id"].map(lambda c: sparse_score_by_id.get(c, 0.0))
    shortlist_df["dense_score"] = shortlist_df["candidate_id"].map(lambda c: dense_score_by_id.get(c, 0.0))
    shortlist_df["rrf_score"] = shortlist_df["candidate_id"].map(lambda c: rrf_score_by_id.get(c, 0.0))
    shortlist_df["structured_hit"] = shortlist_df["candidate_id"].map(lambda c: gate_score_map.get(c, 0.0))

    print(f"[{time.time()-t_start:5.1f}s] computing features for {len(shortlist_df):,} shortlist candidates")

    # 9) Rank (uses LightGBM if model exists, else deterministic fallback)
    use_lgbm = not args.no_lgbm
    if not use_lgbm:
        print(f"[{time.time()-t_start:5.1f}s] ranker: deterministic fallback (--no_lgbm)")
    else:
        print(f"[{time.time()-t_start:5.1f}s] ranker: LightGBM (auto-trains if missing)")
    ranked = rank_shortlist(shortlist_df, use_lgbm=use_lgbm)

    # ----------------------------------------------------------------------
    # v11: Final logistics eligibility filter.
    # JD: "Location: Pune/Noida-preferred but flexible. Outside India:
    # case-by-case, but we don't sponsor work visas."
    # A non-India candidate who is not willing to relocate is, for this
    # company, functionally unreachable. Remove them from the ranked
    # shortlist BEFORE selecting top-100, so they are replaced by the
    # next eligible candidate from below. This does NOT change top-10
    # or top-50 (all six removed are in ranks 51–100) and preserves any
    # non-India candidate who IS willing to relocate.
    # ----------------------------------------------------------------------
    country_arr = ranked["country"].fillna("").astype(str).to_numpy()
    reloc_arr = ranked["willing_to_relocate"].fillna(0).astype(float).to_numpy()
    eligible_mask = ~((country_arr != "India") & (reloc_arr == 0))
    n_removed = int((~eligible_mask).sum())
    print(f"[{time.time()-t_start:5.1f}s] v11 eligibility filter: kept "
          f"{int(eligible_mask.sum()):,}, removed {n_removed} non-India + not-willing-to-relocate")

    # 10) Top-100 (selected from the eligible subset)
    top = ranked[eligible_mask].head(100).copy()
    print(f"[{time.time()-t_start:5.1f}s] top-100 selected")

    # 11) Honeypot self-check
    n_honeypot = int((top["honeypot_penalty"] >= config.HONEYPOT_HARD_EXCLUDE).sum())
    n_low_title = int((top["title_fit_blend"] < 0.10).sum())
    n_services_top50 = int(top.head(50)["is_services_company"].astype(int).sum()) if "is_services_company" in top.columns else 0
    print(f"[{time.time()-t_start:5.1f}s] self-check: honeypot_hard={n_honeypot}, low_title_top100={n_low_title}, services_top50={n_services_top50}")

    # 12) Write CSV
    out = write_submission(top, out_path=args.out, validate=True)
    print(f"[{time.time()-t_start:5.1f}s] submission written: {out}")
    print(f"[{time.time()-t_start:5.1f}s] DONE")


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument(
        "--candidates",
        default=str(config.DEFAULT_CANDIDATES_JSONL),
        help="Path to candidates.jsonl",
    )
    ap.add_argument(
        "--out",
        default=str(config.DEFAULT_OUTPUT_CSV),
        help="Path to submission CSV",
    )
    ap.add_argument(
        "--no_dense",
        action="store_true",
        help="Disable sentence-transformers (use BM25-only retrieval)",
    )
    ap.add_argument(
        "--no_lgbm",
        action="store_true",
        help="Use the deterministic scorer instead of the LightGBM ranker",
    )
    ap.add_argument(
        "--structured_top_up",
        type=int,
        default=2000,
        help="How many structured-gate candidates to add beyond the RRF shortlist",
    )
    ap.add_argument("--verbose", action="store_true")
    args = ap.parse_args()
    run(args)


if __name__ == "__main__":
    main()