SignalHire
Technical Documentation

Version 1.0 · June 2026 · AI-Powered Candidate Ranking for the Redrob AI Challenge

Live Demo: huggingface.co/spaces/DevanshSrajput/SignalHire — Interactive dashboard on 50 sample candidates, all tabs live.  ·  GitHub Repo  ·  Author Portfolio

Overview

SignalHire is an AI-powered candidate ranking system that processes 100,000 candidate profiles and surfaces the top 100 best matches for a Senior AI Engineer role. Built for the Redrob AI Challenge at the India Runs Hackathon.

The Problem

Given a 487 MB JSONL file containing 100K candidate profiles — including keyword stuffers, ~80 honeypot/adversarial profiles, behavioral twins, and strong engineers who describe themselves in plain language — identify and rank the top 100 candidates for a Senior AI Engineer position.

The Solution

A two-phase offline hybrid scoring pipeline:

DecisionRationale
Offline precomputeSeparates expensive GPU work from CPU-only ranking sandbox
Multi-signal scoringDefeats keyword stuffers — no single signal can game the system
Semantic embedding backupCatches strong engineers whose plain language misses keyword checks
Evidence-cited reasoningEvery score claim traces to a specific skill or sentence
Vectorized re-rankingEnables live 50 ms re-ranking of all 100K candidates in the dashboard

Quick Start

# Clone and setup
git clone https://github.com/DevanshSrajput/SignalHire.git
cd SignalHire
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt

# Step 1: Precompute (CPU default; GPU ~10× faster)
python precompute.py
# or:
EMBEDDING_DEVICE=cuda python precompute.py

# Step 2: Rank → output/submission.csv
python rank.py

# Step 3: Dashboard
streamlit run app.py  # http://localhost:8501

Prerequisites

RequirementNotes
Python ≥ 3.10Required
NVIDIA GPU + CUDAOptional — speeds precompute ~10×. CPU is the default; set EMBEDDING_DEVICE=cuda to use GPU
Disk~500 MB for data + ~160 MB for generated artifacts
RAM16 GB recommended

Docker

Artifacts are baked into the image — only candidates.jsonl needs to be mounted at runtime.

# Build (bakes precomputed artifacts, pre-downloads embedding model)
docker build -t signalhire .

# Rank (mount candidates.jsonl in, get submission.csv out)
docker run --rm \
  -v $(pwd)/data:/app/data \
  -v $(pwd)/output:/app/output \
  signalhire

# Dashboard
docker run --rm -p 8501:8501 \
  -v $(pwd)/data:/app/data \
  signalhire streamlit run app.py --server.port=8501 --server.address=0.0.0.0
Precomputed artifacts are baked into the image — no GPU needed at runtime. Add --gpus all only for GPU-accelerated precompute runs.

Two-Phase Pipeline

                    candidates.jsonl (100K, 487 MB)
                            │
                            ▼
          ┌──────────────────────────────────────┐
          │  PHASE A: Precompute (GPU, ~4 min)   │
          │                                      │
          │  Stream JSONL ─► Disqualify fakes    │
          │         └──► Embed (MiniLM, 384-dim) │
          │         └──► Compute 4 sub-scores    │
          │         └──► Serialize artifacts     │
          └──────────────┬───────────────────────┘
                         │  embeddings.npy + subscores.pkl (~155 MB)
                         ▼
          ┌──────────────────────────────────────┐
          │  PHASE B: Ranking (CPU, <10 s)       │
          │                                      │
          │  Load artifacts ─► Cosine similarity │
          │         └──► Weighted composite      │
          │         └──► argsort → top 100       │
          │         └──► Evidence-based reasoning│
          │         └──► Validate & write CSV    │
          └──────────────┬───────────────────────┘
                         │
                         ▼
          ┌──────────────────────────────────────┐
          │  DASHBOARD (CPU, ~50 ms re-rank)     │
          │  Weight UI → Live matrix re-score    │
          │  6 interactive tabs                  │
          └──────────────────────────────────────┘

Dependency Graph

Layer 0 — leaf modules (no project imports):
    textmatch.py ─── re, functools
    config.py    ─── os, re, pathlib

Layer 1:
    disqualify.py ──► config, textmatch

Layer 2:
    signals.py   ──► config, disqualify, textmatch

Layer 3:
    evidence.py  ──► config, signals, textmatch
    engine.py    ──► config

Layer 4:
    precompute.py ──► config, disqualify, signals + sentence_transformers

Layer 5:
    rank.py      ──► config, engine, evidence

Layer 6:
    app.py       ──► config, engine, evidence, rank + streamlit, plotly

Data Flow

candidates.jsonl ──► precompute.py ──► artifacts/
                          │                 ├── embeddings.npy     (99965×384, float32)
                          │                 ├── candidate_ids.npy  (99965 strings)
                          │                 ├── jd_embedding.npy   (384, float32)
                          │                 ├── subscores.pkl      (99965 dicts)
                          │                 ├── disqualified.json  (~35 records)
                          │                 └── demographics.csv
                          │
                          └── rank.py ──► output/submission.csv
                              app.py  ──► Streamlit dashboard (http://localhost:8501)

Module Reference

config.py — Configuration Hub

Single source of truth for all paths, weights, keyword lists, penalties, and thresholds. Every other module imports from here — no magic numbers elsewhere.

Path Constants

ConstantValueDescription
ROOTProject rootBase directory (resolved from config.py location)
DATA_DIRROOT / "data"Raw input data
ARTIFACTS_DIRROOT / "artifacts"Precomputed output
OUTPUT_DIRROOT / "output"Final CSV output
CANDIDATES_PATHDATA_DIR / "candidates.jsonl"100K profiles (~487 MB)
VALIDATE_SCRIPTDATA_DIR / "validate_submission.py"Challenge validator script
SAMPLE_PATHDATA_DIR / "sample_candidates.json"Sample data for demo mode

Scoring Weights

WEIGHTS = {
    "technical_fit":       0.35,
    "career_quality":      0.25,
    "availability_signal": 0.20,
    "seniority_fit":       0.12,
    "semantic_similarity": 0.08,
}
# Sum is asserted to equal 1.0

Embedding Configuration

ConstantValueDescription
EMBEDDING_MODEL"sentence-transformers/all-MiniLM-L6-v2"384-dim sentence embeddings
EMBEDDING_DIM384Vector dimensionality
EMBEDDING_DEVICEos.getenv("EMBEDDING_DEVICE", "cpu")Compute device — cpu default, override with env var
EMBEDDING_BATCH_SIZE2048Batch size for encoding
PROFILE_BLOB_MAX_CHARS1024Max chars for embedding input

Penalty Constants

ConstantValueDescription
CONSULTING_PENALTY0.15Multiplier for all-consulting careers
NO_CODE_PENALTY0.80Multiplier for 18+ months of inactivity
CV_SPEECH_ROBOTICS_PENALTY0.85Multiplier for off-domain specialization
HONEYPOT_YEAR_BUFFER5Allowed YoE vs career timeline discrepancy
GHOST_COMPLETENESS_THRESHOLD5Profile completeness % below which → ghost

textmatch.py — Keyword Matching Engine

Word-boundary-aware keyword matching. Prevents false positives like "rag" matching inside "storage" or "map" inside "bitmap".

Design: Keywords ≥4 characters use stem matching (\b{kw}\w*), allowing "eval""evaluation". Keywords <4 characters require exact word match (\b{kw}\b).

keyword_pattern(kw: str) → re.Pattern

Returns a compiled regex. Cached via functools.lru_cache(maxsize=1024). kw ≥ 4 chars → stem match; kw < 4 → exact word.

matches_any(text: str, keywords: List[str]) → bool

Returns True if any keyword matches. Short-circuits on first match.

matched_keywords(text: str, keywords: List[str]) → List[str]

Returns all keywords that match within the text.


disqualify.py — Adversarial Profile Detection

Identifies and removes adversarial profiles before scoring. Three hard-disqualify checks, plus soft penalties for borderline profiles.

is_honeypot(candidate: dict) → Tuple[bool, str]

Checks three conditions in order: YoE inflation (claimed YoE exceeds career timeline + buffer), Ghost profile (completeness <5%, no verified contact), Pure research (all roles researcher, zero production signals). Returns (True, reason) or (False, "").

should_hard_penalize(candidate: dict) → Tuple[float, str]

Returns cumulative penalty multiplier and reasons string. All-consulting → ×0.15, No-code-18mo → ×0.80, CV/speech/robotics-only → ×0.85. Multipliers stack multiplicatively.

HelperPurpose
parse_year(date_str)Extracts 4-digit year; handles "present"/"now" → CURRENT_YEAR
_all_roles_at_consulting(candidate)True if every role's company matches a known consulting firm
_is_pure_research(candidate)All roles titled "researcher" + no production signal keywords
_is_no_code_18mo(candidate)Last activity >548 days ago + no current role
_is_cv_speech_robotics_only(candidate)Has CV/speech/robotics skills but zero retrieval signals

signals.py — Sub-Score Computation

Computes the four primary sub-scores. Each returns a float in [0, 1].

compute_technical_fit(candidate: dict) → float

Must-have match (75% weight) across declared skills (proficiency-weighted) and career descriptions + nice-to-have match (25%) + production bonus (capped 0.10) + retrieval bonus (capped 0.10).

compute_career_quality(candidate: dict) → float

Non-consulting role (+0.30), ML/AI title at ≥50-person company (+0.15), median tenure ≥36mo (+0.25) / ≥24mo (+0.20) / ≥18mo (+0.05), upward title progression (+0.20).

compute_availability_signal(candidate: dict) → float

Open-to-work flag (+0.25), recency (≤30d +0.20, ≤90d +0.12, ≤180d +0.06), recruiter response rate (×0.15), interview completion rate (×0.15), notice period (≤30d +0.05, ≤60d +0.035).

compute_seniority_fit(candidate: dict) → float

YoE bands: 6–9yr → 1.0 · 4–5/10–12yr → 0.7 · 3/13–15yr → 0.4 · else → 0.1. Tier-1 education +0.05, Tier-2 +0.02.


evidence.py — Provenance & Reasoning

Traces every JD requirement match back to the specific skill or career sentence that triggered it. Generates honest, evidence-cited reasoning strings for the output CSV.

collect_evidence(candidate: dict) → dict

Returns structured evidence dict with matched (criterion, match_type, skill_name, snippet), missing_must_haves, production keywords, and retrieval keywords.

generate_reasoning(candidate: dict, evidence: dict = None, max_len: int = 300) → str

Produces one-liner reasoning string (120–300 chars). Format: "6.7yr Lead AI Engineer at Razorpay; strong match on embeddings, vector search, python; production evidence (serving, ndcg); actively looking, 73% response rate, 30d notice." Only claims what evidence actually contains.


engine.py — Vectorized Ranking Engine

All ranking math as vectorized NumPy operations. Every operation works on precomputed matrices — no per-candidate loops.

build_matrices(candidate_ids, subscores_dict) → Tuple[np.ndarray, np.ndarray]

Packs sub-score dicts into dense float32 arrays: subscore_matrix (N×4) and penalties (N,).

compute_scores(subscore_matrix, penalties, semantic_sim, weights) → np.ndarray

Core scoring operation: scores = penalties × (subscore_matrix @ weight_vector + w_semantic × semantic_sim)

top_k_indices(scores: np.ndarray, k: int) → np.ndarray

Selects top-k candidates via np.argsort with deterministic tie-breaking by candidate index.

mmr_rerank(candidate_idx, scores, embeddings, lambda_relevance, k) → List[int]

Maximal Marginal Relevance. MMR(d) = λ × score(d) − (1−λ) × max_similarity(d, already_selected). At λ=1.0 → pure score ranking.

stability_analysis(subscore_matrix, penalties, semantic_sim, weights, k, n_trials=200, jitter=0.20, seed=42) → Dict[int, float]

200 Monte Carlo trials with ±20% weight perturbation. Returns per-candidate frequency of appearing in top-k. Candidates with >95% frequency are stable regardless of exact weight choice.


precompute.py — Phase A Pipeline

The expensive one-time computation phase. Streams 487 MB JSONL, removes adversarial profiles, embeds all valid profiles with all-MiniLM-L6-v2, computes all sub-scores, serializes to disk.

main() → None

Full pipeline: create ARTIFACTS_DIR → load SentenceTransformer on configured device → embed JD → stream JSONL in 1K-line chunks (disqualify, blob, sub-scores) → batch-encode (batch=2048) → save all artifacts.

Runtime: ~4 min on NVIDIA RTX 3050 (6 GB GPU). ~30 min CPU-only. Set EMBEDDING_DEVICE=cuda to use GPU.

rank.py — Phase B Pipeline

Loads precomputed artifacts, calculates composite scores, selects top 100, generates evidence-based reasoning, writes validated CSV.

build_offset_index() → dict

Builds candidate_id → byte_offset by scanning the JSONL once (~1.5 s). Enables O(1) random access to any candidate's full record without loading the 487 MB file.

main() → None

Load artifacts → build matrices → cosine similarity → composite scores → top-100 → byte-offset index → load full records → evidence + reasoning → enforce monotonic ordering → write CSV → validate.


app.py — Interactive Dashboard

Six-tab Streamlit workbench. All scoring is a single float32 matrix multiply over precomputed artifacts — ~50 ms for 100K candidates regardless of which tab or slider is active.

TabFeatures
🏆 ShortlistPaginated candidate cards · stability badges · score bars · evidence chips · missing must-haves
⚖️ CompareRadar chart side-by-side for 2–4 candidates across all 5 score dimensions
📊 InsightsScore histogram + fairness audit (education tier, country, YoE distributions vs pool)
🛡️ IntegrityDisqualification log by category with concrete detection examples
📤 ExportSubmission CSV · personalized outreach pack (top-10) · ranking config JSON
📖 MethodologyLive formula display + integrity rule documentation
Sidebar ControlEffect
Custom JDPaste any job description → embedded on the fly → instant re-rank all 100K
Weight slidersDrag any of 5 signals → 100K re-score in ~50 ms
Diversity (MMR)λ slider → penalize near-duplicate profiles in shortlist
Blind screeningHides names, companies, institutions to reduce reviewer bias

Scoring Methodology

Composite Formula

S = penalty_multiplier × (
    0.35 × technical_fit
  + 0.25 × career_quality
  + 0.20 × availability_signal
  + 0.12 × seniority_fit
  + 0.08 × semantic_similarity
)

All sub-scores ∈ [0, 1]
penalty_multiplier ∈ [0.12, 1.0]
Final score scaled to [0, 10] in output CSV

Technical Fit (0.35)

CriterionWeightKeywords (sample)
Embeddings & Retrieval0.25embedding, retrieval, vector search, dense retrieval, RAG, faiss...
Vector Databases0.20vector database, faiss, pinecone, weaviate, milvus, qdrant, chroma...
Python0.15python, pytorch, tensorflow, jax, numpy...
Eval Frameworks0.15evaluation, ndcg, mrr, map, recall, precision...
LLM Fine-tuning nice-to-have0.10fine-tuning, lora, qlora, rlhf, peft, instruction tuning...
Learning to Rank nice-to-have0.10learning to rank, lambdamart, listwise, pairwise...
HR-Tech nice-to-have0.05recruiter, talent, hiring, ats, applicant...

Proficiency weights: expert=1.0, advanced=0.75, intermediate=0.4, beginner=0.1. Platform assessment scores override self-reported proficiency.

Career Quality (0.25)

Availability Signal (0.20)

Seniority Fit (0.12)

YoE RangeScore
6–9 years (ideal for Senior AI Engineer)1.0
4–5 or 10–12 years0.7
3 or 13–15 years0.4
<3 or >15 years0.1

Education bonus: Tier-1 (IIT, IISc, NIT, BITS...) → +0.05. Tier-2 (DTU, VIT, SRM...) → +0.02.

Semantic Similarity (0.08)

Cosine similarity between the candidate's 384-dim all-MiniLM-L6-v2 embedding and the JD embedding. This is the "safety net" — catches strong engineers whose plain-language descriptions miss keyword checks.

Penalty Multipliers

ConditionMultiplierRationale
All roles at consulting firms (TCS, Infosys, Wipro, Capgemini, Accenture, HCL...)× 0.15Pure-consulting careers rarely involve the depth of AI/ML work required
No coding activity in 18+ months× 0.80Skills may be stale in a fast-moving field
CV/speech/robotics only, no retrieval signals× 0.85Domain misalignment with this JD
Penalties stack multiplicatively: all-consulting + stale = 0.15 × 0.80 = 0.12.

Data Schemas

Input: candidates.jsonl

{
  "candidate_id": "CAND_0000001",
  "profile": {
    "name": "...",
    "headline": "Senior ML Engineer | NLP & Search",
    "years_of_experience": 7,
    "current_title": "Senior ML Engineer",
    "current_company": "Razorpay",
    "company_size": "1001-5000"
  },
  "career_history": [{
    "company": "Razorpay", "title": "Senior ML Engineer",
    "start_date": "2022-03", "end_date": "present",
    "is_current": true,
    "description": "Built embedding-based retrieval pipeline..."
  }],
  "skills": [{ "name": "Python", "proficiency": "expert" }],
  "redrob_signals": {
    "profile_completeness_score": 92,
    "open_to_work_flag": true,
    "recruiter_response_rate": 0.73,
    "notice_period_days": 30,
    "last_active_date": "2026-05-28",
    "verified_email": true
  }
}

Internal: Artifacts

FileFormatShapeDescription
embeddings.npyfloat32(99965, 384)Unit-normalized profile embeddings
candidate_ids.npystr array(99965,)Index-aligned with embeddings
jd_embedding.npyfloat32(384,)JD text embedding
subscores.pklPython dict99965 entriescandidate_id → {technical_fit, career_quality, availability_signal, seniority_fit, penalty_multiplier, top_skills, ...}
disqualified.jsonJSON list~35 entries[{candidate_id, disqualify_type, reason}, ...]
demographics.csvCSV~100K rowsCached pool demographics for fairness audit

Output: submission.csv

candidate_id,rank,score,reasoning
CAND_0081846,1,8.70,"6.7yr Lead AI Engineer at Razorpay; strong match on embeddings, vector search,
python, information retrieval; production evidence (serving, ndcg);
actively looking, 73% response rate, 30d notice."
RuleStatus
Exactly 100 rows (ranks 1–100)✅ enforced
Scores non-increasing by rank✅ monotonic enforcement in rank.py
Tie-breaking by candidate_id ascending✅ lexsort in engine.top_k_indices
candidate_id format CAND_XXXXXXX✅ validated
Reasoning ≤300 chars, no newlines✅ enforced in evidence.generate_reasoning

Deployment

Local Development

EMBEDDING_DEVICE=cuda python precompute.py  # Phase A: ~4 min GPU
python rank.py                               # Phase B: ~3 s
streamlit run app.py                         # Dashboard: http://localhost:8501

HuggingFace Space

Live demo at huggingface.co/spaces/DevanshSrajput/SignalHire — Docker SDK with precomputed artifacts for the 50-candidate sample dataset. All dashboard tabs work identically to the full 100K version.

Sandbox Constraints

ConstraintHow It's Met
CPU-only rankingPhase B uses only NumPy — zero GPU dependency
16 GB RAM limitStreaming JSONL ingestion, batched embedding, vectorized ops. Peak ~14 GB
No network accessEmbedding model pre-downloaded at Docker build time
<5 minute rankingnp.argsort O(N) + byte-offset seek index. Total ~3 s
Deterministic resultsSEED=42 enforced. Reproducible on re-run

Performance Benchmarks

Measured on NVIDIA RTX 3050 (6 GB) + 12-core CPU

PhaseDeviceTimeThroughput
Precompute (100K → 99,965)GPU (CUDA)~4.2 min~400 candidates/s
Offset index buildCPU~1.5 s~67K lines/s
Ranking + validationCPU~3.1 s~32K candidates/s
Dashboard re-rankCPU~50 ms2M candidates/s

Initial Documentation Archive

The original planning documents that guided SignalHire's development are preserved in the Initial-Documentation/ directory. These were the blueprints — the code is the building.

DocumentDescription
RecruiterIQ_PRD.mdProduct Requirements Document — problem statement, scoring formula, evaluation metrics
TRD.mdTechnical Requirements Document — functional and non-functional requirements
BACKEND_SCHEMA.mdData Models & Interfaces — complete schema definitions for all I/O
APP_FLOW.mdApplication Flow — system diagrams, state machines, error handling
IMPLEMENTATION_PLAN.mdBuild Execution Roadmap — 9-phase plan with code snippets and risk register
UIUX_DESIGN.mdUI/UX Design — dashboard layout, color palette, component specs

PRD — Product Requirements

The RecruiterIQ PRD defines the core problem: rank 100K candidates for a Senior AI Engineer role, defeat adversarial profiles, and surface the top 100 with honest evidence-based reasoning. It established the five-signal scoring formula and the evaluation criteria (recall of known good candidates, 0 honeypots in top 100).

TRD — Technical Requirements

The TRD translates the PRD into hard constraints: CPU-only inference, 16 GB RAM limit, 5-minute wall-clock budget, no network access, deterministic reproducibility. It specified the offline precompute architecture as the solution to the GPU/CPU split.

App Flow

The APP_FLOW document traces every user interaction through the system: from JSONL load to artifact production (Phase A), from artifact load to CSV output (Phase B), and from sidebar control to live matrix multiply (Dashboard). It includes the state machine for the Streamlit session and error handling for missing artifacts.

Implementation Plan

A 9-phase build roadmap: (1) config skeleton → (2) keyword matching → (3) disqualification → (4) sub-scores → (5) evidence → (6) vectorized engine → (7) precompute → (8) rank → (9) dashboard. Each phase has acceptance criteria, code snippets, and a risk register entry.

UI/UX Design

The UIUX document specifies the exact layout of each Streamlit tab, the color palette (dark theme with indigo/violet/green/amber accents), component hierarchy (cards → badges → bars → chips), and the sidebar control order. The stability badge design and MMR slider range were specified here.

FAQ

Why two phases instead of one?

The sandbox is CPU-only with a 5-minute time limit. Precomputing embeddings (GPU) separately keeps Phase B well under the budget.

How does it handle keyword stuffers?

Multi-signal scoring. A candidate who stuffs "Python" 50 times gets high technical_fit but still needs career_quality, availability_signal, and seniority_fit. No single signal can game the composite score.

Why all-MiniLM-L6-v2 over larger models?

Best accuracy-to-speed tradeoff for 100K profiles. 384 dimensions, ~60 MB model, <200 ms per batch of 2048. Larger models would exceed the RAM/time budget.

Why is the consulting penalty so severe (×0.15)?

For this specific JD, all-consulting careers at service firms rarely involve the depth of AI/ML work required. In a production system, it would be a configurable threshold, not a constant.

How is the stability badge calculated?

200 Monte Carlo trials with ±20% random weight perturbation. Frequency in top-100: >95% → "stable", 70–95% → "moderate", <70% → "fragile".

↑ Back to top