Deploy Lighthouse Streamlit app (docker space)

#1
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Lighthouse — self-contained sandbox image (also used by the HuggingFace Docker Space).
2
+ # Runs the Streamlit demo on CPU. For the full 100K rank, use rank.py (see README).
3
+ FROM python:3.10-slim
4
+
5
+ WORKDIR /app
6
+
7
+ # CPU-only torch first (kept out of the CUDA default to keep the image small)
8
+ RUN pip install --no-cache-dir torch==2.5.1 --index-url https://download.pytorch.org/whl/cpu
9
+
10
+ COPY requirements.txt .
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
+
13
+ COPY . .
14
+
15
+ # HuggingFace Spaces expects the app on 7860
16
+ ENV PORT=7860
17
+ EXPOSE 7860
18
+ CMD ["streamlit", "run", "app/app.py", "--server.port=7860", "--server.address=0.0.0.0"]
README.md CHANGED
@@ -1,10 +1,20 @@
1
- ---
2
- title: Lighthouse
3
- emoji: 📉
4
- colorFrom: red
5
- colorTo: purple
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Lighthouse Candidate Ranker
3
+ emoji: 🔦
4
+ colorFrom: indigo
5
+ colorTo: yellow
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ ---
11
+
12
+ # 🔦 Lighthouse - candidate ranker sandbox
13
+
14
+ Live demo of the Lighthouse recruiter-grade candidate ranker (Redrob India Runs challenge).
15
+ Upload up to 100 candidates (or use the preloaded 100-candidate sample) and Lighthouse ranks
16
+ them end-to-end on CPU - five-component fit score, JD hard-negative gates, behavioral modifier,
17
+ honeypot zeroing, grounded reasoning - then download the ranked CSV.
18
+
19
+ Uploaded candidates are encoded on the fly with BAAI/bge-small-en-v1.5. Full code:
20
+ https://github.com/jacklachan/lighthouse-indiaruns
app/app.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lighthouse — HuggingFace Spaces / Streamlit sandbox.
2
+
3
+ Accepts up to 100 candidates (preloaded sample or uploaded JSONL), runs the full
4
+ Lighthouse ranker end-to-end on CPU, and shows the ranked table with scores +
5
+ grounded reasoning. Candidates uploaded here are not precomputed, so the small
6
+ bge-small encoder runs at request time (a few seconds for <=100 rows) — this is
7
+ the demo path; the official 100K run uses fully precomputed embeddings.
8
+
9
+ Run locally: streamlit run app/app.py
10
+ """
11
+ import json
12
+ import os
13
+ import sys
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+ import streamlit as st
18
+
19
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
20
+
21
+ from lighthouse import loader, ranker, reasoning # noqa: E402
22
+
23
+ HERE = os.path.dirname(os.path.abspath(__file__))
24
+ ROOT = os.path.dirname(HERE)
25
+ ART = os.path.join(ROOT, "artifacts")
26
+ SAMPLE = os.path.join(HERE, "sample_candidates.jsonl")
27
+
28
+ st.set_page_config(page_title="Lighthouse — Candidate Ranker", page_icon="🔦", layout="wide")
29
+
30
+
31
+ @st.cache_resource
32
+ def _facets_and_rubric():
33
+ rubric = json.load(open(os.path.join(ART, "jd_rubric.json"), encoding="utf-8"))
34
+ facet_emb = np.load(os.path.join(ART, "jd_facet_emb.npy"))
35
+ return rubric, facet_emb
36
+
37
+
38
+ def _build_art(rubric, facet_emb):
39
+ """Empty precomputed set -> every candidate is encoded on the fly."""
40
+ dim = facet_emb.shape[1]
41
+ return {"rubric": rubric, "ids": [], "id_to_row": {},
42
+ "cand_emb": np.zeros((0, dim), dtype=np.float32), "facet_emb": facet_emb}
43
+
44
+
45
+ def _parse_jsonl(text: str):
46
+ raws = []
47
+ for line in text.splitlines():
48
+ line = line.strip()
49
+ if not line:
50
+ continue
51
+ try:
52
+ raws.append(json.loads(line))
53
+ except json.JSONDecodeError:
54
+ pass
55
+ return raws
56
+
57
+
58
+ st.title("🔦 Lighthouse — recruiter-grade candidate ranker")
59
+ st.caption("Keyword filters surface the loudest profiles. Lighthouse surfaces the right ones — "
60
+ "and ignores the fakes that fool keyword filters.")
61
+
62
+ with st.sidebar:
63
+ st.header("Input")
64
+ mode = st.radio("Candidate source", ["Preloaded sample (100)", "Upload JSONL (≤100)"])
65
+ st.markdown("---")
66
+ st.markdown("**JD:** Senior AI Engineer @ Redrob AI — production embeddings/retrieval, "
67
+ "ranking-eval, product (not services), 6–8 yrs ideal, Noida/Pune/India.")
68
+ st.markdown("Ranking runs on **CPU, no hosted LLM**. The five-component score is gated by "
69
+ "JD hard-negatives and a behavioral modifier; honeypots are zeroed.")
70
+
71
+ rubric, facet_emb = _facets_and_rubric()
72
+
73
+ raws = []
74
+ if mode.startswith("Preloaded"):
75
+ if os.path.exists(SAMPLE):
76
+ raws = _parse_jsonl(open(SAMPLE, encoding="utf-8").read())
77
+ st.info(f"Loaded {len(raws)} preloaded sample candidates.")
78
+ else:
79
+ st.error("sample_candidates.jsonl not found.")
80
+ else:
81
+ up = st.file_uploader("Upload candidates JSONL (one JSON candidate per line)", type=["jsonl", "json"])
82
+ if up is not None:
83
+ raws = _parse_jsonl(up.read().decode("utf-8"))
84
+ st.info(f"Parsed {len(raws)} candidates.")
85
+
86
+ raws = raws[:100]
87
+
88
+ if raws and st.button("🔦 Rank candidates", type="primary"):
89
+ with st.spinner(f"Encoding + scoring {len(raws)} candidates on CPU ..."):
90
+ art = _build_art(rubric, facet_emb)
91
+ records = ranker.score_all(raws, art)
92
+ mx = max((r["final_score"] for r in records), default=0.0)
93
+ if mx > 0:
94
+ for r in records:
95
+ r["final_score"] = round(r["final_score"] / mx, 6)
96
+ top = ranker.rank_records(records, top=len(records))
97
+ raw_by_id = {loader.candidate_id(r): r for r in raws}
98
+ rows = []
99
+ for rec in top:
100
+ raw = raw_by_id[rec["candidate_id"]]
101
+ p = loader.get_profile(raw)
102
+ rows.append({
103
+ "rank": rec["rank"],
104
+ "candidate_id": rec["candidate_id"],
105
+ "score": rec["final_score"],
106
+ "title": loader._s(p, "current_title"),
107
+ "country": loader._s(p, "country"),
108
+ "yrs": loader._f(p, "years_of_experience"),
109
+ "honeypot": "⚠️" if rec["honeypot"] else "",
110
+ "reasoning": reasoning.generate(raw, rubric, rec),
111
+ })
112
+ df = pd.DataFrame(rows)
113
+ n_hp = sum(1 for r in top if r["honeypot"])
114
+ c1, c2, c3 = st.columns(3)
115
+ c1.metric("Candidates ranked", len(rows))
116
+ c2.metric("Honeypots flagged", n_hp)
117
+ c3.metric("Top score", f"{rows[0]['score']:.3f}" if rows else "—")
118
+ st.dataframe(df, use_container_width=True, hide_index=True,
119
+ column_config={"score": st.column_config.NumberColumn(format="%.4f")})
120
+
121
+ with st.expander("Inspect the top candidate's component breakdown"):
122
+ best = top[0]
123
+ st.json({"candidate_id": best["candidate_id"], "components": best["components"],
124
+ "base": best["base"], "gate_mult": best["gate_mult"],
125
+ "gate_reasons": best["gate_reasons"], "behavior_mult": best["behavior_mult"],
126
+ "honeypot": best["honeypot"]})
127
+
128
+ st.download_button("⬇️ Download ranking CSV",
129
+ df[["candidate_id", "rank", "score", "reasoning"]].to_csv(index=False),
130
+ file_name="submission.csv", mime="text/csv")
131
+ elif not raws:
132
+ st.warning("Choose the preloaded sample or upload a JSONL to begin.")
app/sample_candidates.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
artifacts/jd_facet_emb.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a6b3d7b669928bc60e95057d092ba8bdac5c462b11f42d02a085cb314ed58e42
3
+ size 15488
artifacts/jd_rubric.json ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_meta": {
3
+ "title": "Senior AI Engineer — Founding Team @ Redrob AI",
4
+ "authored_by": "Claude (offline, reading job_description.docx)",
5
+ "authored_at": "2026-06-06",
6
+ "reference_date": "2026-06-06",
7
+ "note": "This rubric is a STATIC, committed artifact. rank.py reads it at rank-time; no LLM/API call is made during ranking. It encodes the gap between what the JD literally says and what it means, per the JD's own 'how to read between the lines' and 'final note for hackathon participants' sections."
8
+ },
9
+
10
+ "experience": {
11
+ "band_min": 5.0,
12
+ "band_max": 9.0,
13
+ "ideal_min": 6.0,
14
+ "ideal_max": 8.0,
15
+ "rationale": "JD: '5-9 years ... a range, not a requirement', ideal '6-8 years total, of which 4-5 in applied ML/AI at product companies'. Soft curve: peak inside ideal, gentle taper across the band, steeper outside. Strong signals can rescue out-of-band candidates, so this is a score component, not a hard gate.",
16
+ "soft": true
17
+ },
18
+
19
+ "facets": [
20
+ "Production experience with embeddings-based retrieval systems (sentence-transformers, OpenAI embeddings, BGE, E5) deployed to real users, handling embedding drift and retrieval-quality regression.",
21
+ "Production experience with vector databases or hybrid search infrastructure: Pinecone, Weaviate, Qdrant, Milvus, FAISS, OpenSearch, Elasticsearch.",
22
+ "Designed and ran evaluation frameworks for ranking systems: NDCG, MRR, MAP, offline-to-online correlation, A/B test interpretation.",
23
+ "Built and shipped an end-to-end ranking, search, or recommendation system to real users at meaningful scale.",
24
+ "Hybrid retrieval and re-ranking: dense vs sparse retrieval, learning-to-rank, LLM-based re-ranking, relevance tuning.",
25
+ "Applied machine learning at a product company: feature engineering through deployment, owning a model in production.",
26
+ "Information retrieval and NLP: semantic search, query understanding, text embeddings, retrieval relevance.",
27
+ "Strong Python engineering with attention to code quality.",
28
+ "Learning-to-rank models with XGBoost, LightGBM, or neural rankers over click-through and human-judgement labels.",
29
+ "Scrappy product-engineering: shipping a working ranker fast and iterating from real user feedback, comfortable owning the intelligence/matching layer of a product."
30
+ ],
31
+
32
+ "must_haves": [
33
+ {"key": "embeddings_retrieval", "label": "Production embeddings-based retrieval", "keywords": ["embedding", "embeddings", "sentence transformer", "sentence-transformers", "bge", "e5", "dense retrieval", "vector search", "semantic search", "openai embeddings"]},
34
+ {"key": "vector_search_infra", "label": "Vector DB / hybrid search infra", "keywords": ["pinecone", "weaviate", "qdrant", "milvus", "faiss", "opensearch", "elasticsearch", "vector database", "hybrid search", "ann index", "hnsw"]},
35
+ {"key": "ranking_eval", "label": "Ranking evaluation frameworks", "keywords": ["ndcg", "mrr", "map", "mean average precision", "offline-online", "offline to online", "a/b test", "ab test", "relevance label", "click-through", "ctr", "evaluation framework", "learning to rank", "learning-to-rank"]},
36
+ {"key": "shipped_ranking_system", "label": "Shipped a ranking/search/recsys system", "keywords": ["ranking", "search", "recommendation", "recommender", "recsys", "retrieval", "relevance", "personalization", "discovery feed", "matching"]},
37
+ {"key": "strong_python", "label": "Strong Python", "keywords": ["python", "pytorch", "tensorflow", "scikit-learn", "numpy", "pandas"]}
38
+ ],
39
+
40
+ "nice_to_haves": [
41
+ {"key": "llm_finetuning", "label": "LLM fine-tuning (LoRA/QLoRA/PEFT)", "keywords": ["lora", "qlora", "peft", "fine-tun", "fine tun", "instruction tuning", "rlhf"]},
42
+ {"key": "ltr_models", "label": "Learning-to-rank models", "keywords": ["xgboost", "lightgbm", "learning to rank", "learning-to-rank", "lambdamart", "ranknet"]},
43
+ {"key": "hrtech", "label": "HR-tech / recruiting / marketplace", "keywords": ["recruit", "hr-tech", "hrtech", "talent", "marketplace", "two-sided"]},
44
+ {"key": "distributed_inference", "label": "Distributed systems / large-scale inference", "keywords": ["distributed", "large-scale inference", "low latency", "serving", "throughput", "scale"]},
45
+ {"key": "open_source", "label": "Open-source contributions", "keywords": ["open source", "open-source", "github", "maintainer", "contributor", "published", "paper", "talk"]}
46
+ ],
47
+
48
+ "role_taxonomy": {
49
+ "positive_title_terms": [
50
+ "machine learning", "ml engineer", "ml scientist", "ai engineer", "applied scientist",
51
+ "applied ml", "data scientist", "research engineer", "nlp", "search engineer",
52
+ "ranking", "recommendation", "recsys", "deep learning", "software engineer",
53
+ "backend engineer", "data engineer", "mlops", "platform engineer", "staff engineer",
54
+ "research scientist", "information retrieval", "relevance engineer", "full stack"
55
+ ],
56
+ "strong_positive_title_terms": [
57
+ "machine learning", "ml engineer", "ai engineer", "applied scientist", "applied ml",
58
+ "nlp", "search engineer", "ranking", "recommendation", "recsys", "research engineer",
59
+ "data scientist", "relevance engineer", "information retrieval"
60
+ ],
61
+ "negative_title_terms": [
62
+ "marketing", "hr ", "human resources", "accountant", "accounting", "sales",
63
+ "operations manager", "graphic", "designer", "civil engineer", "mechanical engineer",
64
+ "content writer", "customer support", "recruiter", "business analyst",
65
+ "project manager", "program manager", "qa engineer", "quality assurance",
66
+ "frontend engineer", "mobile developer", "ui ", "ux "
67
+ ],
68
+ "rationale": "role_coherence is the decisive signal against keyword-stuffers. The JD's own example: 'A candidate who has all the AI keywords listed as skills but whose title is Marketing Manager is not a fit, no matter how perfect their skill list looks.' Title alone is noisy, so role_coherence blends taxonomy match of current+historical titles with semantic_fit of the career text."
69
+ },
70
+
71
+ "career_evidence_terms": [
72
+ "ranking", "learning to rank", "learning-to-rank", "search", "semantic search",
73
+ "recommendation", "recommender", "recsys", "retrieval", "information retrieval",
74
+ "relevance", "embedding", "embeddings", "vector", "personalization",
75
+ "discovery feed", "click-through", "offline-online", "a/b test", "ndcg",
76
+ "feature engineering", "production", "shipped", "deployed", "at scale", "real users"
77
+ ],
78
+
79
+ "hard_negatives": [
80
+ {
81
+ "key": "services_only",
82
+ "label": "Services/consulting-only career (no product company)",
83
+ "companies": ["tcs", "tata consultancy", "infosys", "wipro", "accenture", "cognizant", "capgemini", "tech mahindra", "hcl", "mindtree", "ltimindtree", "mphasis", "igate", "syntel", "l&t infotech", "larsen", "dxc", "ibm global services"],
84
+ "penalty": 0.45,
85
+ "rule": "Apply when EVERY role's company is a services/consulting firm. JD: 'People who have only worked at consulting firms ... in their entire career.' If currently at one but has prior product-company experience, do NOT penalize (JD says that's fine).",
86
+ "rationale": "Multiplicative penalty, not a zero — a services-only candidate is a weak fit but not impossible/fraudulent."
87
+ },
88
+ {
89
+ "key": "location_visa",
90
+ "label": "Outside India and unwilling to relocate (no visa sponsorship)",
91
+ "penalty": 0.30,
92
+ "soft_penalty": 0.80,
93
+ "rule": "country != India AND willing_to_relocate == false -> penalty (0.30). country != India AND willing_to_relocate == true -> soft_penalty (0.80, visa/logistics risk). country == India -> no penalty (JD welcomes Hyderabad/Pune/Mumbai/Delhi-NCR and is flexible within India).",
94
+ "rationale": "JD: 'Outside India: case-by-case, but we don't sponsor work visas.' A Toronto-based candidate who won't relocate (e.g. CAND_0000001) is effectively unhireable here."
95
+ },
96
+ {
97
+ "key": "research_only",
98
+ "label": "Pure research, no production deployment",
99
+ "positive_terms": ["research", "phd", "postdoc", "thesis", "publication", "academic", "professor", "lab", "paper"],
100
+ "production_terms": ["production", "shipped", "deployed", "real users", "at scale", "serving", "product", "launched", "a/b"],
101
+ "penalty": 0.55,
102
+ "rule": "Apply when research signals are strong across the career AND production signals are essentially absent. JD: 'pure research environments ... without any production deployment — we will not move forward.'"
103
+ },
104
+ {
105
+ "key": "cv_speech_only",
106
+ "label": "CV/speech/robotics primary, no NLP/IR",
107
+ "domain_terms": ["computer vision", "image classification", "object detection", "speech recognition", "tts", "asr", "robotics", "gans", "image segmentation", "ocr", "pose estimation"],
108
+ "nlp_ir_terms": ["nlp", "natural language", "information retrieval", "retrieval", "search", "ranking", "embedding", "recommendation", "text", "language model"],
109
+ "penalty": 0.60,
110
+ "rule": "Apply when CV/speech/robotics dominate skills/career AND NLP/IR signals are weak/absent. JD: 'primary expertise is computer vision, speech, or robotics without significant NLP/IR exposure ... you'd be re-learning fundamentals here.'"
111
+ },
112
+ {
113
+ "key": "langchain_only_recent",
114
+ "label": "AI experience only recent LangChain+OpenAI wrappers",
115
+ "wrapper_terms": ["langchain", "llamaindex", "llama-index", "openai api", "prompt engineering", "chatgpt", "gpt wrapper", "autogen"],
116
+ "depth_terms": ["embedding", "retrieval", "ranking", "recommendation", "feature engineering", "xgboost", "lightgbm", "model training", "mlops", "information retrieval", "pre-llm", "classical ml"],
117
+ "penalty": 0.65,
118
+ "rule": "Apply when AI signals are recent (<= ~18 months) and consist of LangChain/OpenAI-wrapper terms WITHOUT substantial pre-LLM-era ML depth. JD: '\"AI experience\" consists primarily of recent (under 12 months) projects using LangChain to call OpenAI ... unless you can demonstrate substantial pre-LLM-era ML production experience.'"
119
+ },
120
+ {
121
+ "key": "title_chaser",
122
+ "label": "Title-chaser (job-hops ~every 1.5 yrs for title bumps)",
123
+ "max_avg_tenure_months": 20,
124
+ "min_roles": 4,
125
+ "penalty": 0.80,
126
+ "rule": "Apply when 4+ roles with average completed tenure < 20 months AND titles escalate (Senior->Staff->Principal pattern). JD: 'optimizing for Senior -> Staff -> Principal titles by switching companies every 1.5 years ... we need someone who plans to be here for 3+ years.' Mild multiplicative penalty."
127
+ },
128
+ {
129
+ "key": "non_technical_role",
130
+ "label": "Non-engineering current role (keyword-stuffer bait)",
131
+ "penalty": 0.25,
132
+ "rule": "Apply when current_title and the whole career trajectory are clearly non-engineering (marketing/HR/accounting/sales/ops/design/civil/mechanical/content/support) regardless of how many AI skills are listed. This is the primary keyword-stuffer trap. Largely overlaps role_coherence; kept as an explicit gate so the reasoning can name it.",
133
+ "rationale": "Strong penalty (not zero): an Accountant listing 9 AI skills is the canonical trap candidate the sample_submission wrongly ranks #1."
134
+ }
135
+ ],
136
+
137
+ "behavioral": {
138
+ "reference_date": "2026-06-06",
139
+ "active_recent_days": 60,
140
+ "active_stale_days": 180,
141
+ "good_response_rate": 0.5,
142
+ "weak_response_rate": 0.15,
143
+ "good_interview_completion": 0.6,
144
+ "notice_preferred_days": 30,
145
+ "notice_acceptable_days": 90,
146
+ "modifier_floor": 0.80,
147
+ "modifier_ceiling": 1.12,
148
+ "rationale": "Behavioral signals MODIFY (multiplicatively) a fit score; they never drive it. JD: 'a perfect-on-paper candidate who hasn't logged in for 6 months and has a 5% recruiter response rate is, for hiring purposes, not actually available. Down-weight them appropriately.' Reward recent activity, high recruiter_response_rate, open_to_work, verified contact, healthy interview_completion_rate; penalize stale/unreachable. Capped so behavior can lift a weak base only modestly (ceiling 1.12) but can meaningfully sink an unreachable one (floor 0.80). Sentinels (-1 github, -1 offer, empty assessments) are COMMON (>60% of pool) and are treated as neutral, never penalized."
149
+ },
150
+
151
+ "ai_core_skills": [
152
+ "nlp", "embeddings", "information retrieval", "sentence transformers", "hugging face transformers",
153
+ "faiss", "pinecone", "milvus", "weaviate", "qdrant", "machine learning", "deep learning",
154
+ "fine-tuning llms", "lora", "rag", "vector search", "recommendation systems", "learning to rank",
155
+ "xgboost", "lightgbm", "pytorch", "tensorflow", "scikit-learn", "mlops", "mlflow",
156
+ "feature engineering", "llms", "transformers", "semantic search", "retrieval", "speech recognition",
157
+ "image classification", "gans", "tts", "statistical modeling"
158
+ ],
159
+ "jd_relevant_skills": [
160
+ "nlp", "embeddings", "information retrieval", "sentence transformers", "hugging face transformers",
161
+ "faiss", "pinecone", "milvus", "weaviate", "qdrant", "machine learning", "vector search",
162
+ "recommendation systems", "learning to rank", "xgboost", "lightgbm", "mlops", "mlflow",
163
+ "feature engineering", "semantic search", "retrieval", "fine-tuning llms", "rag", "transformers"
164
+ ],
165
+ "off_target_skills": [
166
+ "speech recognition", "tts", "asr", "image classification", "object detection", "gans",
167
+ "ocr", "robotics", "photoshop", "tailwind", "react", "angular", "javascript", "typescript",
168
+ "marketing", "seo", "project management"
169
+ ],
170
+
171
+ "component_weights": {
172
+ "_comment": "Base weighted sum of the five components, tuned on the Claude-authored eval set to maximise NDCG@10. role_coherence + career_evidence dominate because they are what separate true fits from keyword-stuffers.",
173
+ "semantic_fit": 0.22,
174
+ "role_coherence": 0.26,
175
+ "career_evidence": 0.24,
176
+ "experience_fit": 0.10,
177
+ "trust_skills": 0.18
178
+ }
179
+ }
lighthouse/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lighthouse — a recruiter-grade, reasoning-first candidate ranker.
2
+
3
+ Keyword filters surface the loudest profiles. Lighthouse surfaces the right
4
+ ones — and ignores the fakes that fool keyword filters.
5
+ """
6
+
7
+ __version__ = "1.0.0"
8
+
9
+ # Single source of truth for determinism across the whole pipeline.
10
+ SEED = 1729
lighthouse/features.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic per-candidate feature extraction.
2
+
3
+ These are the building blocks of the five scoring components that do NOT require
4
+ embeddings (role-taxonomy coherence, career evidence, experience fit, skill
5
+ trust). `semantic_fit` is computed in `scoring.py` from precomputed embeddings.
6
+
7
+ Every function is pure and deterministic. All text matching is lowercase
8
+ substring matching against term lists in the JD rubric, so the logic is fully
9
+ explainable and traceable back to the JD.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from datetime import date
15
+ from typing import Dict, List, Optional
16
+
17
+ from . import loader
18
+
19
+ PROFICIENCY_WEIGHT = {"beginner": 0.25, "intermediate": 0.5, "advanced": 0.8, "expert": 1.0}
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # text helpers
24
+ # ---------------------------------------------------------------------------
25
+
26
+ def _norm(s: str) -> str:
27
+ return (s or "").lower()
28
+
29
+
30
+ def count_hits(text: str, terms: List[str]) -> int:
31
+ t = _norm(text)
32
+ return sum(1 for term in terms if term in t)
33
+
34
+
35
+ def any_hit(text: str, terms: List[str]) -> bool:
36
+ t = _norm(text)
37
+ return any(term in t for term in terms)
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # title taxonomy
42
+ # ---------------------------------------------------------------------------
43
+
44
+ def classify_title(title: str, rubric: dict) -> str:
45
+ """Return 'strong' | 'positive' | 'negative' | 'neutral' for a job title."""
46
+ t = _norm(title)
47
+ if not t:
48
+ return "neutral"
49
+ tax = rubric["role_taxonomy"]
50
+ if any(term in t for term in tax["strong_positive_title_terms"]):
51
+ return "strong"
52
+ if any(term in t for term in tax["negative_title_terms"]):
53
+ return "negative"
54
+ if any(term in t for term in tax["positive_title_terms"]):
55
+ return "positive"
56
+ return "neutral"
57
+
58
+
59
+ _TITLE_SCORE = {"strong": 1.0, "positive": 0.7, "neutral": 0.4, "negative": 0.05}
60
+
61
+
62
+ def role_coherence_taxonomy(raw: dict, rubric: dict) -> float:
63
+ """Coherence of titles with an AI/ML/IR/SWE-ranking trajectory, in [0,1].
64
+
65
+ Blends the current title (weighted heavily) with the fraction of historical
66
+ roles that are positive. This is the taxonomy half of `role_coherence`;
67
+ `scoring.py` blends it with semantic fit.
68
+ """
69
+ p = loader.get_profile(raw)
70
+ cur = classify_title(loader._s(p, "current_title"), rubric)
71
+ cur_score = _TITLE_SCORE[cur]
72
+
73
+ career = loader.get_career(raw)
74
+ if career:
75
+ hist_scores = [_TITLE_SCORE[classify_title(h["title"], rubric)] for h in career]
76
+ hist = sum(hist_scores) / len(hist_scores)
77
+ else:
78
+ hist = cur_score
79
+ return round(0.6 * cur_score + 0.4 * hist, 4)
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # services / product company detection
84
+ # ---------------------------------------------------------------------------
85
+
86
+ def is_services_company(name: str, rubric: dict) -> bool:
87
+ n = _norm(name)
88
+ return any(c in n for c in rubric["hard_negatives"][0]["companies"])
89
+
90
+
91
+ def services_fraction(raw: dict, rubric: dict) -> float:
92
+ career = loader.get_career(raw)
93
+ if not career:
94
+ return 0.0
95
+ s = sum(1 for h in career if is_services_company(h["company"], rubric))
96
+ return s / len(career)
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # career evidence
101
+ # ---------------------------------------------------------------------------
102
+
103
+ def career_evidence(raw: dict, rubric: dict) -> float:
104
+ """Did they actually BUILD ranking/search/recsys/retrieval at product cos? [0,1]
105
+
106
+ Per role: term density over the description x a product-vs-services weight.
107
+ Aggregate emphasises the single best role (JD: 'shipped at least one
108
+ end-to-end ranking/search/recommendation system'), with a mean term so a
109
+ consistently-strong career also benefits.
110
+ """
111
+ career = loader.get_career(raw)
112
+ if not career:
113
+ return 0.0
114
+ terms = rubric["career_evidence_terms"]
115
+ role_ev = []
116
+ for h in career:
117
+ hits = count_hits(h["description"] + " " + h["title"], terms)
118
+ density = min(hits / 4.0, 1.0)
119
+ if is_services_company(h["company"], rubric):
120
+ product_w = 0.35
121
+ elif h["company"]:
122
+ product_w = 1.0
123
+ else:
124
+ product_w = 0.85
125
+ role_ev.append(density * product_w)
126
+ return round(0.65 * max(role_ev) + 0.35 * (sum(role_ev) / len(role_ev)), 4)
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # experience fit (soft curve)
131
+ # ---------------------------------------------------------------------------
132
+
133
+ def experience_fit(raw: dict, rubric: dict) -> float:
134
+ """Soft curve peaking inside ideal [6,8], in-band [5,9], gentle taper. [0,1]"""
135
+ yoe = loader._f(loader.get_profile(raw), "years_of_experience")
136
+ e = rubric["experience"]
137
+ lo, hi = e["ideal_min"], e["ideal_max"]
138
+ blo, bhi = e["band_min"], e["band_max"]
139
+ if lo <= yoe <= hi:
140
+ return 1.0
141
+ if blo <= yoe < lo:
142
+ return round(0.85 + 0.15 * (yoe - blo) / (lo - blo), 4)
143
+ if hi < yoe <= bhi:
144
+ return round(0.85 + 0.15 * (bhi - yoe) / (bhi - hi), 4)
145
+ # outside band: decay (don't zero — strong signals can rescue)
146
+ dist = (blo - yoe) if yoe < blo else (yoe - bhi)
147
+ return round(max(0.15, 0.7 * math.exp(-dist / 3.0)), 4)
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # skill trust
152
+ # ---------------------------------------------------------------------------
153
+
154
+ def trust_skills(raw: dict, rubric: dict) -> float:
155
+ """Skills weighted by proficiency x duration x endorsements x assessment,
156
+ counting only JD-relevant skills. Saturating. [0,1]
157
+
158
+ This is what makes keyword-stuffing structurally worthless: an 'expert'
159
+ skill with 0 months, no endorsements and a low Redrob assessment contributes
160
+ almost nothing. Missing assessment is NEUTRAL (76% of pool has none).
161
+ """
162
+ skills = loader.get_skills(raw)
163
+ if not skills:
164
+ return 0.0
165
+ sig = loader.get_signals(raw)
166
+ assess = sig.get("skill_assessment_scores") or {}
167
+ # build a case-insensitive assessment lookup
168
+ assess_lc = {str(k).lower(): v for k, v in assess.items() if isinstance(v, (int, float))}
169
+
170
+ relevant = set(rubric["jd_relevant_skills"])
171
+ off = set(rubric["off_target_skills"])
172
+
173
+ trust_sum = 0.0
174
+ for sk in skills:
175
+ name = _norm(sk["name"])
176
+ if name in relevant:
177
+ rel_w = 1.0
178
+ elif name in off:
179
+ rel_w = 0.15
180
+ else:
181
+ rel_w = 0.4
182
+ if rel_w < 0.5:
183
+ continue # only JD-relevant skills build trust
184
+
185
+ prof = PROFICIENCY_WEIGHT.get(sk["proficiency"], 0.4)
186
+ dur_f = min(sk["duration_months"] / 24.0, 1.0)
187
+ dur_w = 0.3 + 0.7 * dur_f
188
+ end_f = min(sk["endorsements"] / 20.0, 1.0)
189
+ end_w = 0.7 + 0.3 * end_f
190
+ if name in assess_lc:
191
+ assess_w = assess_lc[name] / 100.0
192
+ else:
193
+ assess_w = 0.65 # neutral when no assessment taken
194
+
195
+ trust_sum += prof * dur_w * end_w * assess_w * rel_w
196
+
197
+ # saturate: ~3 well-backed relevant skills -> ~0.78
198
+ return round(1.0 - math.exp(-trust_sum / 2.0), 4)
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # tenure stats (for title-chaser gate + reasoning)
203
+ # ---------------------------------------------------------------------------
204
+
205
+ def tenure_stats(raw: dict) -> Dict[str, float]:
206
+ career = loader.get_career(raw)
207
+ completed = [h["duration_months"] for h in career if not h["is_current"] and h["duration_months"] > 0]
208
+ n_roles = len(career)
209
+ avg_tenure = (sum(completed) / len(completed)) if completed else 0.0
210
+ return {"n_roles": n_roles, "avg_tenure_months": round(avg_tenure, 1)}
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # bundle: all non-embedding features for one candidate
215
+ # ---------------------------------------------------------------------------
216
+
217
+ def extract_features(raw: dict, rubric: dict) -> dict:
218
+ """All deterministic (non-embedding) features for one candidate."""
219
+ p = loader.get_profile(raw)
220
+ feats = {
221
+ "candidate_id": loader.candidate_id(raw),
222
+ "yoe": loader._f(p, "years_of_experience"),
223
+ "current_title": loader._s(p, "current_title"),
224
+ "country": loader._s(p, "country"),
225
+ "role_coherence_tax": role_coherence_taxonomy(raw, rubric),
226
+ "career_evidence": career_evidence(raw, rubric),
227
+ "experience_fit": experience_fit(raw, rubric),
228
+ "trust_skills": trust_skills(raw, rubric),
229
+ "services_fraction": services_fraction(raw, rubric),
230
+ }
231
+ feats.update(tenure_stats(raw))
232
+ return feats
lighthouse/gates.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hard-negative gates — multiplicative penalties derived from the JD rubric.
2
+
3
+ Each gate encodes one disqualifier the JD *explicitly* names. Gates are
4
+ multiplicative (not hard zeros — that is reserved for honeypots), so a strong
5
+ candidate with one soft concern is dampened, not annihilated, and the reasoning
6
+ generator can name the concern honestly.
7
+
8
+ Every gate returns at most one (key, multiplier, reason). `apply_gates` returns
9
+ the product of multipliers and the list of fired reasons.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from datetime import date
14
+ from typing import List, Tuple
15
+
16
+ from . import features, loader
17
+
18
+ _MIN_DATE = date(1900, 1, 1)
19
+
20
+
21
+ def _career_text(raw: dict) -> str:
22
+ parts = [loader._s(loader.get_profile(raw), "summary")]
23
+ for h in loader.get_career(raw):
24
+ parts.append(h["title"])
25
+ parts.append(h["description"])
26
+ parts += [s["name"] for s in loader.get_skills(raw)]
27
+ return " ".join(parts).lower()
28
+
29
+
30
+ def _gate(rubric: dict, key: str) -> dict:
31
+ return next(g for g in rubric["hard_negatives"] if g["key"] == key)
32
+
33
+
34
+ def gate_services_only(raw: dict, rubric: dict, text: str) -> Tuple[float, str]:
35
+ g = _gate(rubric, "services_only")
36
+ if features.services_fraction(raw, rubric) >= 0.999 and loader.get_career(raw):
37
+ cur = loader._s(loader.get_profile(raw), "current_company")
38
+ return g["penalty"], f"entire career at services/consulting firms (e.g. {cur})"
39
+ return 1.0, ""
40
+
41
+
42
+ def gate_location_visa(raw: dict, rubric: dict, text: str) -> Tuple[float, str]:
43
+ g = _gate(rubric, "location_visa")
44
+ p = loader.get_profile(raw)
45
+ sig = loader.get_signals(raw)
46
+ country = loader._s(p, "country").strip().lower()
47
+ if country and country != "india":
48
+ relocate = bool(sig.get("willing_to_relocate"))
49
+ loc = loader._s(p, "location") or country.title()
50
+ if not relocate:
51
+ return g["penalty"], f"based in {loc} ({country.title()}) and not willing to relocate; no visa sponsorship"
52
+ return g["soft_penalty"], f"based outside India ({loc}); relocation willing but visa/logistics risk"
53
+ return 1.0, ""
54
+
55
+
56
+ def gate_research_only(raw: dict, rubric: dict, text: str) -> Tuple[float, str]:
57
+ g = _gate(rubric, "research_only")
58
+ research = features.count_hits(text, g["positive_terms"])
59
+ production = features.count_hits(text, g["production_terms"])
60
+ if research >= 2 and production == 0:
61
+ return g["penalty"], "research-heavy profile with no evident production deployment"
62
+ return 1.0, ""
63
+
64
+
65
+ def gate_cv_speech_only(raw: dict, rubric: dict, text: str) -> Tuple[float, str]:
66
+ g = _gate(rubric, "cv_speech_only")
67
+ domain = features.count_hits(text, g["domain_terms"])
68
+ nlp_ir = features.count_hits(text, g["nlp_ir_terms"])
69
+ if domain >= 2 and nlp_ir == 0:
70
+ return g["penalty"], "primary expertise in computer vision/speech/robotics with no NLP/IR signal"
71
+ return 1.0, ""
72
+
73
+
74
+ def gate_langchain_only_recent(raw: dict, rubric: dict, text: str) -> Tuple[float, str]:
75
+ g = _gate(rubric, "langchain_only_recent")
76
+ wrapper = features.count_hits(text, g["wrapper_terms"])
77
+ depth = features.count_hits(text, g["depth_terms"])
78
+ if wrapper >= 2 and depth == 0:
79
+ return g["penalty"], "AI experience appears limited to recent LLM-wrapper tooling without classical ML/retrieval depth"
80
+ return 1.0, ""
81
+
82
+
83
+ def _seniority_level(title: str) -> int:
84
+ t = title.lower()
85
+ if any(k in t for k in ("principal", "staff", "director", "vp", "head of", "distinguished")):
86
+ return 3
87
+ if "lead" in t or "manager" in t:
88
+ return 2
89
+ if "senior" in t or "sr." in t or "sr " in t:
90
+ return 1
91
+ return 0
92
+
93
+
94
+ def gate_title_chaser(raw: dict, rubric: dict, text: str) -> Tuple[float, str]:
95
+ """Fire only on genuine title-chasing: short tenures AND an escalating
96
+ seniority ladder. Lateral moves at short tenure (common for early-career ML
97
+ engineers, e.g. RecSys -> Search -> NLP) are NOT title-chasing.
98
+ """
99
+ g = _gate(rubric, "title_chaser")
100
+ stats = features.tenure_stats(raw)
101
+ if not (stats["n_roles"] >= g["min_roles"] and 0 < stats["avg_tenure_months"] < g["max_avg_tenure_months"]):
102
+ return 1.0, ""
103
+ # titles in chronological (oldest-first) order
104
+ career = sorted(loader.get_career(raw), key=lambda h: (h["start_date"] or _MIN_DATE))
105
+ levels = [_seniority_level(h["title"]) for h in career]
106
+ escalated = levels and (max(levels) - min(levels) >= 2) and levels[-1] >= levels[0] and levels[-1] >= 2
107
+ if escalated:
108
+ return g["penalty"], f"escalating titles while job-hopping every ~{stats['avg_tenure_months']:.0f} months across {stats['n_roles']} roles (title-chaser pattern)"
109
+ return 1.0, ""
110
+
111
+
112
+ def gate_non_technical_role(raw: dict, rubric: dict, text: str) -> Tuple[float, str]:
113
+ g = _gate(rubric, "non_technical_role")
114
+ p = loader.get_profile(raw)
115
+ cur_class = features.classify_title(loader._s(p, "current_title"), rubric)
116
+ if cur_class == "negative":
117
+ # whole trajectory non-technical? (no strong AI role anywhere)
118
+ career = loader.get_career(raw)
119
+ has_strong = any(features.classify_title(h["title"], rubric) == "strong" for h in career)
120
+ if not has_strong:
121
+ return g["penalty"], f"current role '{loader._s(p,'current_title')}' is non-engineering with no AI/ML role in career history"
122
+ return 1.0, ""
123
+
124
+
125
+ GATES = [
126
+ gate_non_technical_role,
127
+ gate_services_only,
128
+ gate_location_visa,
129
+ gate_research_only,
130
+ gate_cv_speech_only,
131
+ gate_langchain_only_recent,
132
+ gate_title_chaser,
133
+ ]
134
+
135
+
136
+ def apply_gates(raw: dict, rubric: dict) -> Tuple[float, List[str]]:
137
+ """Return (combined_multiplier, [fired reasons])."""
138
+ text = _career_text(raw)
139
+ mult = 1.0
140
+ reasons: List[str] = []
141
+ for fn in GATES:
142
+ m, reason = fn(raw, rubric, text)
143
+ if m < 1.0:
144
+ mult *= m
145
+ reasons.append(reason)
146
+ return mult, reasons
lighthouse/honeypot.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Honeypot / anomaly detection — explainable, rule-based.
2
+
3
+ The dataset seeds ~80 honeypots: "subtly impossible" profiles (tenure longer
4
+ than the company has existed; 'expert' in many skills with 0 months used; total
5
+ skill-months far exceeding career length; dates that don't add up). The spec
6
+ forces them to relevance tier 0 and disqualifies submissions with >10% of them
7
+ in the top 100. `rank.py` zeroes any candidate flagged here.
8
+
9
+ Design notes grounded in EDA over the real 100K:
10
+ * The naive "sum(skill_months) > years_of_experience*12" check fires on 63% of
11
+ the pool (skills are used concurrently), so it is USELESS as written. We only
12
+ flag a *single* skill whose duration exceeds the whole career — that is
13
+ genuinely impossible, not just overlapping.
14
+ * Every rule returns a human-readable reason so flags are auditable and feed
15
+ the reasoning generator.
16
+
17
+ Reference "now" is the JD reference date (2026-06-06).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from datetime import date
22
+ from typing import List, Tuple
23
+
24
+ from . import loader
25
+
26
+ REFERENCE_DATE = date(2026, 6, 6)
27
+
28
+
29
+ def _months_between(d0: date, d1: date) -> int:
30
+ return (d1.year - d0.year) * 12 + (d1.month - d0.month)
31
+
32
+
33
+ def detect(raw: dict) -> Tuple[bool, List[str]]:
34
+ """Return (is_honeypot, reasons). Any single hard-impossible rule flags."""
35
+ reasons: List[str] = []
36
+ p = loader.get_profile(raw)
37
+ yoe = loader._f(p, "years_of_experience")
38
+ career = loader.get_career(raw)
39
+ skills = loader.get_skills(raw)
40
+ edu = loader.get_education(raw)
41
+
42
+ # --- 1. claimed expertise with zero usage ---
43
+ expert_zero = [s["name"] for s in skills
44
+ if s["proficiency"] in ("advanced", "expert") and s["duration_months"] == 0]
45
+ if len(expert_zero) >= 3:
46
+ reasons.append(
47
+ f"{len(expert_zero)} skills claimed advanced/expert with 0 months used "
48
+ f"(e.g. {', '.join(expert_zero[:3])})")
49
+
50
+ # NOTE: a tempting rule — "a skill used more months than the whole career" —
51
+ # was REMOVED after EDA: skill durations routinely exceed current-job YOE
52
+ # (skills are learned before/outside a role), so that rule flagged 9% of the
53
+ # pool. Genuine impossibility lives in the date math and expertise claims,
54
+ # not in skill-vs-YOE.
55
+
56
+ # --- 2 & 3. per-role date integrity ---
57
+ total_role_months = 0
58
+ for h in career:
59
+ sd, ed = h["start_date"], h["end_date"]
60
+ dm = h["duration_months"]
61
+ total_role_months += dm
62
+ if sd and sd > REFERENCE_DATE:
63
+ reasons.append(f"role at {h['company']} starts in the future ({sd.isoformat()})")
64
+ if sd and ed and ed < sd:
65
+ reasons.append(f"role at {h['company']} ends before it starts")
66
+ if sd and ed:
67
+ span = _months_between(sd, ed)
68
+ if span >= 0 and abs(span - dm) > 9:
69
+ reasons.append(
70
+ f"role at {h['company']}: stated {dm}mo vs {span}mo implied by dates")
71
+
72
+ # --- 5. total tenure overflows the career length ---
73
+ if yoe > 0 and total_role_months > yoe * 12 * 1.6 + 18:
74
+ reasons.append(
75
+ f"career roles sum to {total_role_months}mo, impossible for {yoe:.1f} yrs experience")
76
+
77
+ # --- 6. tenure longer than a young company could exist (proxy) ---
78
+ # A single role of >15 yrs is implausible in this pool's company ages.
79
+ for h in career:
80
+ if h["duration_months"] > 200:
81
+ reasons.append(f"implausible {h['duration_months']}mo tenure at {h['company']}")
82
+ break
83
+
84
+ # --- 7. education dates invalid ---
85
+ for e in edu:
86
+ if e["start_year"] and e["end_year"] and e["end_year"] < e["start_year"]:
87
+ reasons.append(
88
+ f"education '{e['degree']}' ends ({e['end_year']}) before it starts ({e['start_year']})")
89
+
90
+ return (len(reasons) > 0, reasons)
91
+
92
+
93
+ def is_honeypot(raw: dict) -> bool:
94
+ return detect(raw)[0]
lighthouse/loader.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Schema-tolerant loading of Redrob candidate records.
2
+
3
+ The challenge file is a 100K-line JSONL (~487 MB). We stream it line-by-line so
4
+ we never hold more than one raw record in memory at a time during parsing.
5
+
6
+ Every accessor here is defensive: optional fields may be missing, lists may be
7
+ empty, and the dataset uses sentinels (`github_activity_score == -1`,
8
+ `offer_acceptance_rate == -1`, empty `skill_assessment_scores`). Nothing in this
9
+ module should ever raise on a well-formed-but-sparse profile.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from datetime import date, datetime
15
+ from typing import Any, Dict, Iterator, List, Optional
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Low-level safe accessors
19
+ # ---------------------------------------------------------------------------
20
+
21
+ def _s(d: Optional[dict], key: str, default: str = "") -> str:
22
+ if not isinstance(d, dict):
23
+ return default
24
+ v = d.get(key, default)
25
+ return v if isinstance(v, str) else (default if v is None else str(v))
26
+
27
+
28
+ def _f(d: Optional[dict], key: str, default: float = 0.0) -> float:
29
+ if not isinstance(d, dict):
30
+ return default
31
+ v = d.get(key, default)
32
+ try:
33
+ return float(v)
34
+ except (TypeError, ValueError):
35
+ return default
36
+
37
+
38
+ def _i(d: Optional[dict], key: str, default: int = 0) -> int:
39
+ if not isinstance(d, dict):
40
+ return default
41
+ v = d.get(key, default)
42
+ try:
43
+ return int(v)
44
+ except (TypeError, ValueError):
45
+ return default
46
+
47
+
48
+ def _b(d: Optional[dict], key: str, default: bool = False) -> bool:
49
+ if not isinstance(d, dict):
50
+ return default
51
+ v = d.get(key, default)
52
+ return bool(v) if isinstance(v, bool) else default
53
+
54
+
55
+ def parse_date(s: Any) -> Optional[date]:
56
+ """Parse an ISO date string; return None for missing/invalid/null."""
57
+ if not s or not isinstance(s, str):
58
+ return None
59
+ try:
60
+ return datetime.strptime(s[:10], "%Y-%m-%d").date()
61
+ except (ValueError, TypeError):
62
+ return None
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Streaming reader
67
+ # ---------------------------------------------------------------------------
68
+
69
+ def iter_raw(path: str) -> Iterator[dict]:
70
+ """Yield raw candidate dicts from a JSONL file, one line at a time."""
71
+ with open(path, "r", encoding="utf-8") as f:
72
+ for line in f:
73
+ line = line.strip()
74
+ if not line:
75
+ continue
76
+ try:
77
+ yield json.loads(line)
78
+ except json.JSONDecodeError:
79
+ # Skip malformed lines rather than aborting the whole run.
80
+ continue
81
+
82
+
83
+ def load_all(path: str) -> List[dict]:
84
+ """Load every raw candidate record into a list."""
85
+ return list(iter_raw(path))
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Normalisation
90
+ # ---------------------------------------------------------------------------
91
+
92
+ def get_skills(raw: dict) -> List[dict]:
93
+ skills = raw.get("skills") or []
94
+ out = []
95
+ for sk in skills:
96
+ if not isinstance(sk, dict):
97
+ continue
98
+ out.append({
99
+ "name": _s(sk, "name"),
100
+ "proficiency": _s(sk, "proficiency", "beginner").lower(),
101
+ "endorsements": _i(sk, "endorsements"),
102
+ "duration_months": _i(sk, "duration_months"),
103
+ })
104
+ return out
105
+
106
+
107
+ def get_career(raw: dict) -> List[dict]:
108
+ hist = raw.get("career_history") or []
109
+ out = []
110
+ for h in hist:
111
+ if not isinstance(h, dict):
112
+ continue
113
+ out.append({
114
+ "company": _s(h, "company"),
115
+ "title": _s(h, "title"),
116
+ "start_date": parse_date(h.get("start_date")),
117
+ "end_date": parse_date(h.get("end_date")),
118
+ "duration_months": _i(h, "duration_months"),
119
+ "is_current": _b(h, "is_current"),
120
+ "industry": _s(h, "industry"),
121
+ "company_size": _s(h, "company_size"),
122
+ "description": _s(h, "description"),
123
+ })
124
+ return out
125
+
126
+
127
+ def get_education(raw: dict) -> List[dict]:
128
+ edu = raw.get("education") or []
129
+ out = []
130
+ for e in edu:
131
+ if not isinstance(e, dict):
132
+ continue
133
+ out.append({
134
+ "institution": _s(e, "institution"),
135
+ "degree": _s(e, "degree"),
136
+ "field_of_study": _s(e, "field_of_study"),
137
+ "start_year": _i(e, "start_year"),
138
+ "end_year": _i(e, "end_year"),
139
+ "tier": _s(e, "tier", "unknown"),
140
+ })
141
+ return out
142
+
143
+
144
+ def get_signals(raw: dict) -> dict:
145
+ """Return the redrob_signals object (raw dict, with safe fallback)."""
146
+ sig = raw.get("redrob_signals")
147
+ return sig if isinstance(sig, dict) else {}
148
+
149
+
150
+ def get_profile(raw: dict) -> dict:
151
+ p = raw.get("profile")
152
+ return p if isinstance(p, dict) else {}
153
+
154
+
155
+ def candidate_id(raw: dict) -> str:
156
+ return _s(raw, "candidate_id")
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Canonical text blob (used by both embeddings and BM25)
161
+ # ---------------------------------------------------------------------------
162
+
163
+ def build_text_blob(raw: dict) -> str:
164
+ """Build one clean text blob per candidate for semantic + lexical matching.
165
+
166
+ Order matters for readability but not for embeddings: headline + summary
167
+ first (densest signal), then each role's title/description, then skill names.
168
+ We deliberately repeat titles/descriptions verbatim — they are the strongest
169
+ evidence of what the person actually *did*.
170
+ """
171
+ p = get_profile(raw)
172
+ parts: List[str] = []
173
+
174
+ headline = _s(p, "headline")
175
+ if headline:
176
+ parts.append(headline)
177
+
178
+ summary = _s(p, "summary")
179
+ if summary:
180
+ parts.append(summary)
181
+
182
+ cur_title = _s(p, "current_title")
183
+ cur_co = _s(p, "current_company")
184
+ if cur_title:
185
+ parts.append(f"{cur_title} at {cur_co}".strip(" at"))
186
+
187
+ for h in get_career(raw):
188
+ seg = f"{h['title']} at {h['company']} ({h['industry']}). {h['description']}".strip()
189
+ if seg:
190
+ parts.append(seg)
191
+
192
+ skill_names = [sk["name"] for sk in get_skills(raw) if sk["name"]]
193
+ if skill_names:
194
+ parts.append("Skills: " + ", ".join(skill_names))
195
+
196
+ return "\n".join(parts).strip()
lighthouse/metrics.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ranking metrics: NDCG@k, MAP, P@k — matching the challenge spec.
2
+
3
+ The composite the organizers use:
4
+ composite = 0.50*NDCG@10 + 0.30*NDCG@50 + 0.15*MAP + 0.05*P@10
5
+
6
+ Relevance tiers are 0-5. Conventions used here (documented in eval/results.md):
7
+ * NDCG uses exponential gain (2^rel - 1) / log2(rank+1) — the common form.
8
+ * "relevant" for P@k and MAP means tier >= 3 (the spec: 'P@10 counts tier-3+
9
+ as relevant'); we apply the same threshold to MAP for consistency.
10
+ All functions take `rels`, the true relevance tiers in the system's ranked order.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ from typing import List
16
+
17
+ RELEVANT_TIER = 3
18
+
19
+
20
+ def dcg_at_k(rels: List[float], k: int) -> float:
21
+ return sum((2 ** r - 1) / math.log2(i + 2) for i, r in enumerate(rels[:k]))
22
+
23
+
24
+ def ndcg_at_k(rels: List[float], k: int) -> float:
25
+ ideal = sorted(rels, reverse=True)
26
+ idcg = dcg_at_k(ideal, k)
27
+ if idcg == 0:
28
+ return 0.0
29
+ return dcg_at_k(rels, k) / idcg
30
+
31
+
32
+ def precision_at_k(rels: List[float], k: int, threshold: int = RELEVANT_TIER) -> float:
33
+ if k == 0:
34
+ return 0.0
35
+ topk = rels[:k]
36
+ return sum(1 for r in topk if r >= threshold) / k
37
+
38
+
39
+ def average_precision(rels: List[float], threshold: int = RELEVANT_TIER) -> float:
40
+ """AP over the ranked list; R = total relevant in the list."""
41
+ n_rel = sum(1 for r in rels if r >= threshold)
42
+ if n_rel == 0:
43
+ return 0.0
44
+ hits = 0
45
+ score = 0.0
46
+ for i, r in enumerate(rels, start=1):
47
+ if r >= threshold:
48
+ hits += 1
49
+ score += hits / i
50
+ return score / n_rel
51
+
52
+
53
+ def evaluate_ranking(rels: List[float]) -> dict:
54
+ """All metrics + the spec composite for one ranked list of true tiers."""
55
+ ndcg10 = ndcg_at_k(rels, 10)
56
+ ndcg50 = ndcg_at_k(rels, 50)
57
+ mapv = average_precision(rels)
58
+ p10 = precision_at_k(rels, 10)
59
+ composite = 0.50 * ndcg10 + 0.30 * ndcg50 + 0.15 * mapv + 0.05 * p10
60
+ return {
61
+ "NDCG@10": round(ndcg10, 4), "NDCG@50": round(ndcg50, 4),
62
+ "MAP": round(mapv, 4), "P@10": round(p10, 4),
63
+ "P@5": round(precision_at_k(rels, 5), 4),
64
+ "composite": round(composite, 4),
65
+ }
lighthouse/ranker.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rank-time engine: load artifacts, score every candidate, sort, take top-N.
2
+
3
+ Pure numpy/pandas/Python. No network, no model inference over the full pool
4
+ (embeddings are precomputed). The only place a model may load is the small-sample
5
+ fallback (`_embeddings_for`) used by the HuggingFace app when it is handed
6
+ candidates that were never precomputed — never on the 100K official path.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from typing import Dict, List, Optional, Tuple
13
+
14
+ import numpy as np
15
+
16
+ from . import loader, scoring
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # artifact loading
21
+ # ---------------------------------------------------------------------------
22
+
23
+ def load_artifacts(art_dir: str) -> dict:
24
+ rubric = json.load(open(os.path.join(art_dir, "jd_rubric.json"), encoding="utf-8"))
25
+ ids = json.load(open(os.path.join(art_dir, "candidate_ids.json"), encoding="utf-8"))
26
+ cand_emb = np.load(os.path.join(art_dir, "cand_emb.npy"))
27
+ facet_emb = np.load(os.path.join(art_dir, "jd_facet_emb.npy"))
28
+ id_to_row = {cid: i for i, cid in enumerate(ids)}
29
+ return {"rubric": rubric, "ids": ids, "cand_emb": cand_emb,
30
+ "facet_emb": facet_emb, "id_to_row": id_to_row}
31
+
32
+
33
+ def _embeddings_for(raws: List[dict], art: dict, model_name: Optional[str]) -> np.ndarray:
34
+ """Return an embedding matrix aligned to `raws`, using precomputed vectors
35
+ where available and encoding any missing ones on the fly (small-sample only).
36
+ """
37
+ cand_emb = art["cand_emb"]
38
+ dim = cand_emb.shape[1]
39
+ out = np.zeros((len(raws), dim), dtype=np.float32)
40
+ missing_idx, missing_blobs = [], []
41
+ for i, raw in enumerate(raws):
42
+ row = art["id_to_row"].get(loader.candidate_id(raw))
43
+ if row is not None:
44
+ out[i] = cand_emb[row].astype(np.float32)
45
+ else:
46
+ missing_idx.append(i)
47
+ missing_blobs.append(loader.build_text_blob(raw))
48
+ if missing_blobs:
49
+ # small-sample fallback (e.g. HF app) — load the small encoder lazily
50
+ from sentence_transformers import SentenceTransformer
51
+ meta_path = os.path.join("artifacts", "precompute_meta.json")
52
+ mn = model_name or (json.load(open(meta_path))["model"]
53
+ if os.path.exists(meta_path) else "BAAI/bge-small-en-v1.5")
54
+ model = SentenceTransformer(mn, device="cpu")
55
+ vecs = model.encode(missing_blobs, normalize_embeddings=True, convert_to_numpy=True)
56
+ for j, i in enumerate(missing_idx):
57
+ out[i] = vecs[j]
58
+ return out
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # scoring all candidates
63
+ # ---------------------------------------------------------------------------
64
+
65
+ def score_all(raws: List[dict], art: dict, drop: str = None,
66
+ model_name: Optional[str] = None, use_gates: bool = True,
67
+ use_honeypot: bool = True, use_behavior: bool = True) -> List[dict]:
68
+ rubric = art["rubric"]
69
+ emb = _embeddings_for(raws, art, model_name)
70
+ sem_raw = scoring.raw_semantic_fit(emb, art["facet_emb"])
71
+ sem_norm = scoring.normalize_semantic(sem_raw)
72
+ records = []
73
+ for raw, sf in zip(raws, sem_norm):
74
+ records.append(scoring.score_candidate(
75
+ raw, rubric, float(sf), drop=drop, use_gates=use_gates,
76
+ use_honeypot=use_honeypot, use_behavior=use_behavior))
77
+ return records
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # ranking + ordering (spec-compliant)
82
+ # ---------------------------------------------------------------------------
83
+
84
+ def rank_records(records: List[dict], top: int = 100) -> List[dict]:
85
+ """Sort by final_score desc, tie-break candidate_id ascending; take top-N.
86
+
87
+ Guarantees the validator's constraints: scores non-increasing by rank and
88
+ equal scores ordered by candidate_id ascending.
89
+ """
90
+ ordered = sorted(records, key=lambda r: (-r["final_score"], r["candidate_id"]))
91
+ top_recs = ordered[:top]
92
+ for i, r in enumerate(top_recs):
93
+ r["rank"] = i + 1
94
+ return top_recs
lighthouse/reasoning.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic, fact-grounded reasoning generator (NO LLM at rank-time).
2
+
3
+ For each top-100 candidate we compose a 1-2 sentence justification that:
4
+ * cites only REAL field values (years, title, named skills, signal numbers),
5
+ * connects them to specific JD requirements,
6
+ * honestly names gaps (gate concerns, behavioral risks),
7
+ * varies by which factors dominate THIS candidate, and
8
+ * matches its rank band in tone (confident high, "filler" honesty low).
9
+
10
+ Grounding guarantee: every skill or employer surfaced is pulled directly from
11
+ the candidate's own `skills` / `career_history` / `profile`, never invented.
12
+ `grounded_terms()` exposes exactly what was used so tests can verify it.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from datetime import date
17
+ from typing import Dict, List, Tuple
18
+
19
+ from . import features, loader
20
+
21
+ REFERENCE_DATE = date(2026, 6, 6)
22
+
23
+ # representative "build" words; only surfaced if present in the candidate's text
24
+ _EVIDENCE_WORDS = [
25
+ ("ranking", "ranking"), ("learning to rank", "learning-to-rank"),
26
+ ("recommendation", "recommendation"), ("recommender", "recommendation"),
27
+ ("retrieval", "retrieval"), ("search", "search"), ("embedding", "embeddings"),
28
+ ("semantic search", "semantic search"), ("personalization", "personalization"),
29
+ ("information retrieval", "information retrieval"),
30
+ ]
31
+
32
+
33
+ def _fmt_yoe(y: float) -> str:
34
+ return f"{y:.1f}".rstrip("0").rstrip(".") + " yrs"
35
+
36
+
37
+ def extract_facts(raw: dict, rubric: dict, record: dict) -> dict:
38
+ """Pull grounded facts used to compose reasoning."""
39
+ p = loader.get_profile(raw)
40
+ sig = loader.get_signals(raw)
41
+ skills = loader.get_skills(raw)
42
+ career = loader.get_career(raw)
43
+
44
+ # JD-relevant skills the candidate actually has, strongest first
45
+ relevant = set(rubric["jd_relevant_skills"])
46
+ rel_present = [s for s in skills if s["name"].lower() in relevant]
47
+ rel_present.sort(key=lambda s: (features.PROFICIENCY_WEIGHT.get(s["proficiency"], 0.4)
48
+ * min(s["duration_months"] / 24.0, 1.0)), reverse=True)
49
+ top_skills = [s["name"] for s in rel_present[:3]]
50
+
51
+ # product (non-services) employers actually in the career history
52
+ product_cos = []
53
+ for h in career:
54
+ if h["company"] and not features.is_services_company(h["company"], rubric):
55
+ if h["company"] not in product_cos:
56
+ product_cos.append(h["company"])
57
+
58
+ # which "build" word is genuinely present in their text
59
+ text = " ".join([loader._s(p, "summary")] + [h["description"] + " " + h["title"] for h in career]).lower()
60
+ evidence_word = None
61
+ for needle, label in _EVIDENCE_WORDS:
62
+ if needle in text:
63
+ evidence_word = label
64
+ break
65
+
66
+ la = loader.parse_date(sig.get("last_active_date"))
67
+ days_active = (REFERENCE_DATE - la).days if la else None
68
+
69
+ return {
70
+ "yoe": loader._f(p, "years_of_experience"),
71
+ "title": loader._s(p, "current_title"),
72
+ "company": loader._s(p, "current_company"),
73
+ "country": loader._s(p, "country"),
74
+ "location": loader._s(p, "location"),
75
+ "top_skills": top_skills,
76
+ "product_cos": product_cos[:2],
77
+ "evidence_word": evidence_word,
78
+ "resp_rate": sig.get("recruiter_response_rate"),
79
+ "days_active": days_active,
80
+ "notice": sig.get("notice_period_days"),
81
+ "relocate": sig.get("willing_to_relocate"),
82
+ "n_relevant_skills": len(rel_present),
83
+ }
84
+
85
+
86
+ def grounded_terms(raw: dict, rubric: dict, record: dict) -> Dict[str, List[str]]:
87
+ """The skills/companies the generator is allowed to mention (for tests)."""
88
+ f = extract_facts(raw, rubric, record)
89
+ return {"skills": f["top_skills"], "companies": f["product_cos"]}
90
+
91
+
92
+ def _positive_clause(f: dict, comps: dict, band: str) -> str:
93
+ """Lead clause built from the candidate's strongest grounded evidence."""
94
+ title = f["title"] or "Engineer"
95
+ yoe = _fmt_yoe(f["yoe"])
96
+ lead = f"{title} with {yoe}"
97
+
98
+ # choose the dominant evidence to feature
99
+ bits = []
100
+ if f["evidence_word"] and comps["career_evidence"] >= 0.45:
101
+ cos = f" at {', '.join(f['product_cos'])}" if f["product_cos"] else " at product companies"
102
+ bits.append(f"built {f['evidence_word']} systems{cos}")
103
+ elif f["product_cos"] and comps["role_coherence"] >= 0.6:
104
+ bits.append(f"applied-ML track record at {', '.join(f['product_cos'])}")
105
+
106
+ if f["top_skills"]:
107
+ if len(f["top_skills"]) == 1:
108
+ bits.append(f"hands-on with {f['top_skills'][0]}")
109
+ else:
110
+ bits.append(f"hands-on with {', '.join(f['top_skills'][:2])}")
111
+
112
+ if not bits:
113
+ # nothing strong to feature — keep it honest
114
+ if comps["semantic_fit"] >= 0.5:
115
+ bits.append("profile semantically adjacent to the retrieval/ranking mandate")
116
+ else:
117
+ bits.append("limited direct evidence of production retrieval/ranking work")
118
+
119
+ body = "; ".join(bits)
120
+ if band == "top":
121
+ return f"{lead} — {body}."
122
+ if band == "mid":
123
+ return f"{lead}; {body}."
124
+ # capitalize only the first letter (preserve proper-noun casing in company/skill names)
125
+ body_cap = body[0].upper() + body[1:] if body else body
126
+ return f"{lead}. {body_cap}."
127
+
128
+
129
+ def _concern_clause(f: dict, record: dict, band: str) -> str:
130
+ """Honest concern/gap clause. Prefers the most material concern."""
131
+ concerns = list(record.get("gate_reasons", []))
132
+ concerns += [c for c in record.get("behavior_facts", [])
133
+ if any(k in c for k in ("low", "inactive", "notice", "last active"))]
134
+
135
+ if record.get("honeypot"):
136
+ return f" Flagged as anomalous: {record['honeypot_reasons'][0]}."
137
+
138
+ if concerns:
139
+ return " Concern: " + concerns[0] + "."
140
+
141
+ # no concern -> reinforce with a real positive behavioral signal
142
+ pos = []
143
+ if isinstance(f["resp_rate"], (int, float)) and f["resp_rate"] >= 0.5:
144
+ pos.append(f"{f['resp_rate']:.0%} recruiter response")
145
+ if f["days_active"] is not None and f["days_active"] <= 60:
146
+ pos.append(f"active {f['days_active']}d ago")
147
+ if f["country"].lower() == "india":
148
+ pos.append("India-based")
149
+ elif f["relocate"]:
150
+ pos.append("willing to relocate")
151
+ if pos:
152
+ s = ", ".join(pos)
153
+ return " " + s[0].upper() + s[1:] + "."
154
+ return ""
155
+
156
+
157
+ def generate(raw: dict, rubric: dict, record: dict) -> str:
158
+ """Compose the 1-2 sentence grounded reasoning for one ranked candidate."""
159
+ rank = record.get("rank", 999)
160
+ band = "top" if rank <= 10 else ("mid" if rank <= 50 else "low")
161
+ f = extract_facts(raw, rubric, record)
162
+ comps = record["components"]
163
+
164
+ text = _positive_clause(f, comps, band) + _concern_clause(f, record, band)
165
+
166
+ if band == "low" and not record.get("gate_reasons") and not record.get("honeypot"):
167
+ text += " Adjacent fit included near the cutoff."
168
+
169
+ return " ".join(text.split()).strip()
lighthouse/scoring.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The Lighthouse scoring model — the core IP.
2
+
3
+ Five explainable components in [0,1] are combined into a base score, then
4
+ modified multiplicatively:
5
+
6
+ final = base_weighted_sum × Π(hard-negative gates) × behavioral_modifier
7
+ final = 0 if honeypot/anomaly detected
8
+
9
+ | component | what it measures | anti-trap role |
10
+ |-----------------|-------------------------------------------------------------|---------------------------|
11
+ | semantic_fit | cosine of candidate embedding vs JD facets | plain-language Tier-5s |
12
+ | role_coherence | is the title/trajectory actually AI/ML/IR/SWE-ranking? | decisive vs keyword-stuff |
13
+ | career_evidence | did they BUILD ranking/search/recsys at product companies? | rewards real builders |
14
+ | experience_fit | soft curve peaking 6-8 yrs, in-band 5-9 | matches the JD band |
15
+ | trust_skills | skills weighted by proficiency×duration×endorse×assessment | kills keyword stuffing |
16
+
17
+ `semantic_fit` is supplied pre-normalized (population percentile-scaled) by the
18
+ caller; everything else is computed here from the rubric + raw fields.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from datetime import date
23
+ from typing import Dict, List, Tuple
24
+
25
+ import numpy as np
26
+
27
+ from . import features, gates, honeypot, loader
28
+
29
+ REFERENCE_DATE = date(2026, 6, 6)
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # semantic fit (embedding side)
34
+ # ---------------------------------------------------------------------------
35
+
36
+ def raw_semantic_fit(cand_emb: np.ndarray, facet_emb: np.ndarray) -> np.ndarray:
37
+ """Per-candidate raw semantic fit = 0.6*max + 0.4*mean facet cosine.
38
+
39
+ Both matrices are L2-normalized, so the dot product is cosine. Returns a
40
+ 1-D array aligned to cand_emb rows. (max rewards a strong single-facet
41
+ match; mean rewards broad alignment.)
42
+ """
43
+ cos = cand_emb.astype(np.float32) @ facet_emb.T # (N, F)
44
+ return 0.6 * cos.max(axis=1) + 0.4 * cos.mean(axis=1)
45
+
46
+
47
+ def normalize_semantic(raw: np.ndarray) -> np.ndarray:
48
+ """Percentile-clip raw semantic fit into [0,1] across the population."""
49
+ if len(raw) == 0:
50
+ return raw
51
+ lo = np.percentile(raw, 5)
52
+ hi = np.percentile(raw, 95)
53
+ if hi - lo < 1e-6:
54
+ return np.clip((raw - raw.min()) / (raw.ptp() + 1e-6), 0, 1)
55
+ return np.clip((raw - lo) / (hi - lo), 0.0, 1.0)
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # behavioral modifier (multiplicative, clamped)
60
+ # ---------------------------------------------------------------------------
61
+
62
+ def behavioral_modifier(raw: dict, rubric: dict) -> Tuple[float, List[str]]:
63
+ """Reward reachable/active candidates, penalize stale/unreachable. [floor,ceiling].
64
+
65
+ Sentinels (-1 github, -1 offer, empty assessments) are NEUTRAL — only
66
+ present signals move the modifier. Behavior modifies; it never drives.
67
+ """
68
+ b = rubric["behavioral"]
69
+ sig = loader.get_signals(raw)
70
+ delta = 0.0
71
+ facts: List[str] = []
72
+
73
+ la = loader.parse_date(sig.get("last_active_date"))
74
+ if la:
75
+ days = (REFERENCE_DATE - la).days
76
+ if days <= b["active_recent_days"]:
77
+ delta += 0.05; facts.append(f"active {days}d ago")
78
+ elif days >= b["active_stale_days"]:
79
+ delta -= 0.15; facts.append(f"inactive {days}d")
80
+ elif days >= 120:
81
+ delta -= 0.07; facts.append(f"last active {days}d ago")
82
+
83
+ rr = sig.get("recruiter_response_rate")
84
+ if isinstance(rr, (int, float)):
85
+ if rr >= b["good_response_rate"]:
86
+ delta += 0.04; facts.append(f"{rr:.0%} recruiter response")
87
+ elif rr <= b["weak_response_rate"]:
88
+ delta -= 0.12; facts.append(f"low {rr:.0%} recruiter response")
89
+ elif rr <= 0.3:
90
+ delta -= 0.05
91
+
92
+ if sig.get("open_to_work_flag"):
93
+ delta += 0.02
94
+ if sig.get("verified_email") and sig.get("verified_phone"):
95
+ delta += 0.02
96
+
97
+ ic = sig.get("interview_completion_rate")
98
+ if isinstance(ic, (int, float)) and ic > 0:
99
+ if ic >= b["good_interview_completion"]:
100
+ delta += 0.02
101
+ elif ic < 0.3:
102
+ delta -= 0.06; facts.append(f"low {ic:.0%} interview completion")
103
+
104
+ notice = sig.get("notice_period_days")
105
+ if isinstance(notice, (int, float)):
106
+ if notice <= b["notice_preferred_days"]:
107
+ delta += 0.02
108
+ elif notice > b["notice_acceptable_days"]:
109
+ delta -= 0.05; facts.append(f"{int(notice)}-day notice")
110
+
111
+ mult = float(np.clip(1.0 + delta, b["modifier_floor"], b["modifier_ceiling"]))
112
+ return round(mult, 4), facts
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # component assembly + final score
117
+ # ---------------------------------------------------------------------------
118
+
119
+ def components(raw: dict, rubric: dict, semantic_fit: float) -> Dict[str, float]:
120
+ tax = features.role_coherence_taxonomy(raw, rubric)
121
+ # role_coherence blends taxonomy (dominant) with semantic fit
122
+ role_coherence = round(0.7 * tax + 0.3 * semantic_fit, 4)
123
+ return {
124
+ "semantic_fit": round(float(semantic_fit), 4),
125
+ "role_coherence": role_coherence,
126
+ "career_evidence": features.career_evidence(raw, rubric),
127
+ "experience_fit": features.experience_fit(raw, rubric),
128
+ "trust_skills": features.trust_skills(raw, rubric),
129
+ }
130
+
131
+
132
+ def base_score(comps: Dict[str, float], rubric: dict, drop: str = None) -> float:
133
+ """Weighted sum of components. `drop` ablates one component (re-normalising)."""
134
+ w = dict(rubric["component_weights"])
135
+ w.pop("_comment", None)
136
+ if drop and drop in w:
137
+ w.pop(drop)
138
+ total_w = sum(w.values())
139
+ s = sum(comps[k] * wv for k, wv in w.items())
140
+ return s / total_w if total_w else 0.0
141
+
142
+
143
+ def score_candidate(raw: dict, rubric: dict, semantic_fit: float, drop: str = None,
144
+ use_gates: bool = True, use_honeypot: bool = True,
145
+ use_behavior: bool = True) -> dict:
146
+ """Full scoring record for one candidate (feeds ranking + reasoning).
147
+
148
+ The use_* flags exist for the ablation study in eval/ (e.g. measure NDCG
149
+ with the gates or honeypot filter switched off)."""
150
+ comps = components(raw, rubric, semantic_fit)
151
+ base = base_score(comps, rubric, drop=drop)
152
+
153
+ hp, hp_reasons = honeypot.detect(raw)
154
+ gate_mult, gate_reasons = gates.apply_gates(raw, rubric)
155
+ beh_mult, beh_facts = behavioral_modifier(raw, rubric)
156
+
157
+ eff_gate = gate_mult if use_gates else 1.0
158
+ eff_beh = beh_mult if use_behavior else 1.0
159
+ if hp and use_honeypot:
160
+ final = 0.0
161
+ else:
162
+ final = base * eff_gate * eff_beh
163
+
164
+ return {
165
+ "candidate_id": loader.candidate_id(raw),
166
+ "components": comps,
167
+ "base": round(base, 4),
168
+ "gate_mult": gate_mult,
169
+ "gate_reasons": gate_reasons,
170
+ "behavior_mult": beh_mult,
171
+ "behavior_facts": beh_facts,
172
+ "honeypot": hp,
173
+ "honeypot_reasons": hp_reasons,
174
+ "final_score": round(float(final), 6),
175
+ }
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # HuggingFace Space (Streamlit) dependencies for the Lighthouse sandbox.
2
+ streamlit==1.40.2
3
+ sentence-transformers==3.3.1
4
+ torch==2.5.1
5
+ numpy==2.2.6
6
+ pandas==2.3.3
7
+ PyYAML==6.0.2