Spaces:
Running
Running
File size: 47,423 Bytes
7b833a7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 | """
lib/features.py β V5 JD-Driven Feature Extraction
~40 features organized in 9 groups. Every feature is JD-driven: the JD parser
and domain taxonomy determine what skills matter, not hardcoded lists.
Feature groups:
G1: JD Fit (7 features) β How well does the candidate match JD requirements
G2: Impact & Ownership (4) β Quantified outcomes and ownership level
G3: Production & Scale (3) β Real-world deployment evidence
G4: Experience & Career (8) β Career trajectory, depth, stability
G5: Retrieval & Eval (3) β Domain-specific retrieval/evaluation experience
G6: Behavioural (7) β Platform signals and availability
G7: Resume Quality (4) β Evidence density, truthiness, stuffing risk
G8: Safety (2) β Honeypot detection, disqualifiers
G9: Location (1) β Location match
G10: Embedding (1) β Semantic similarity (computed in precompute)
"""
from __future__ import annotations
import re
from lib import schema
from lib.jd_parser import get_jd, JDUnderstanding
from lib.domain import get_taxonomy, DomainTaxonomy
from lib.evidence import extract_all_evidence, get_evidence_summary, _OWNERSHIP_RE, _OWNERSHIP_TIERS
from lib.constants import REFERENCE_DATE
from lib import company_tier, title_scoring, honeypot
PROF_WEIGHT = {"beginner": 0.25, "intermediate": 0.5, "advanced": 0.75, "expert": 1.0}
# ML-relevant skill assessment keys
_ML_ASSESSMENT_KEYS = {
"python", "machine learning", "nlp", "natural language",
"deep learning", "statistics", "pytorch", "tensorflow",
"retrieval", "ranking", "recommendation",
}
# ===========================================================================
# Feature names (ordered, for documentation and parquet columns)
# ===========================================================================
FEATURE_NAMES = [
# G1: JD Fit
"skill_coverage", # 1 β Required skill coverage
"preferred_coverage", # 2 β Preferred skill coverage
"domain_specialization", # 3 β Depth in JD's primary domain
"skill_trust_avg", # 4 β Avg trusted skill signal
"title_relevance", # 5 β Title match to JD
"seniority", # 6 β Seniority level
"jd_skill_count", # 7 β Raw count of JD skills found
# G2: Impact & Ownership
"ownership_hierarchy", # 8 β Hierarchical ownership scoring
"impact_magnitude", # 9 β Best quantified impact
"impact_signals", # 10 β Non-quantified impact language
"evidence_strength", # 11 β Evidence density/quality
# G3: Production & Scale
"production_strength", # 12 β Production deployment evidence
"production_diversity", # 13 β Variety of production signals
"scale_evidence", # 14 β System scale metrics
# G4: Experience & Career
"yoe_band_score", # 15 β YoE match to JD range
"career_depth_ratio", # 16 β Fraction of career in domain
"pre_llm_months", # 17 β Pre-2022 IR experience (normalized)
"career_trajectory", # 18 β Career progression quality
"company_quality", # 19 β Current company tier
"company_quality_avg", # 20 β Average company quality
"career_stability", # 21 β Average tenure length
"promotion_velocity", # 22 β Speed of promotions
# G5: Retrieval & Evaluation
"retrieval_depth", # 23 β Retrieval system sophistication
"evaluation_experience", # 24 β Evaluation framework experience
"system_design_evidence", # 25 β System design work
# G6: Behavioural
"recency", # 26 β How recently active
"responsiveness", # 27 β Response rate + speed
"market_demand", # 28 β Recruiter interest
"github_activity", # 29 β Code activity
"availability_score", # 30 β Open to work + notice
"interview_completion", # 31 β Interview follow-through
"platform_trust", # 32 β Verification + completeness
# G7: Resume Quality
"quantified_outcomes", # 33 β Number of quantified achievements
"truthiness", # 34 β Cross-validation of claims
"keyword_stuffing_risk", # 35 β Probability of keyword stuffing
"profile_completeness", # 36 β Profile completeness score
# G8: Safety
"disqualifier_penalty", # 37 β Multi-factor penalty (multiplicative)
"is_honeypot", # 38 β Synthetic profile flag
# G9: Location
"location_score", # 39 β Location match
# G10: Embedding (computed in precompute)
# "embedding_sim", # 40 β Semantic similarity to ideal candidate
]
# ===========================================================================
# G1: JD FIT
# ===========================================================================
def skill_coverage(c: dict, text: str | None = None) -> tuple[float, dict]:
"""G1-1: Fraction of REQUIRED (Tier 1) JD skills found in context."""
if text is None:
text = schema.unified_text_blob(c)
jd = get_jd()
tax = get_taxonomy()
total_skills = 0
matched_skills = 0
evidence = {"matched": [], "missing": []}
for domain, skills in jd.required_skills.items():
for skill in skills:
total_skills += 1
if skill in text:
matched_skills += 1
evidence["matched"].append((skill, domain))
else:
evidence["missing"].append((skill, domain))
if total_skills == 0:
return 0.0, evidence
score = min(1.0, matched_skills / total_skills)
return score, evidence
def preferred_coverage(c: dict, text: str | None = None) -> tuple[float, dict]:
"""G1-2: Fraction of PREFERRED (Tier 2) JD skills found in context."""
if text is None:
text = schema.unified_text_blob(c)
jd = get_jd()
total_skills = 0
matched_skills = 0
evidence = {"matched": []}
for domain, skills in jd.preferred_skills.items():
for skill in skills:
total_skills += 1
if skill in text:
matched_skills += 1
evidence["matched"].append((skill, domain))
if total_skills == 0:
return 0.0, evidence
return min(1.0, matched_skills / total_skills), evidence
def domain_specialization(c: dict, text: str | None = None) -> tuple[float, dict]:
"""G1-3: How deeply the candidate is in the JD's primary domain."""
if text is None:
text = schema.unified_text_blob(c)
jd = get_jd()
tax = get_taxonomy()
# Get the JD's primary domain skills (all tiers)
primary_domain = jd.domain
all_domain_skills = set()
for tier_dict in [tax.tier1, tax.tier2, tax.tier3]:
if primary_domain in tier_dict:
all_domain_skills.update(tier_dict[primary_domain])
if not all_domain_skills:
return 0.0, {"domain": primary_domain}
# Count how many domain skills the candidate has in context
found = [s for s in all_domain_skills if s in text]
score = min(1.0, len(found) / max(len(all_domain_skills) * 0.4, 1))
# Bonus for Tier 1 skills
tier1_skills = tax.tier1.get(primary_domain, [])
tier1_found = [s for s in tier1_skills if s in text]
if tier1_found:
score = min(1.0, score + 0.15)
return score, {"domain": primary_domain, "found_count": len(found),
"total_domain_skills": len(all_domain_skills)}
def skill_trust_avg(c: dict) -> tuple[float, dict]:
"""G1-4: Weighted average of trusted skill signals for JD-relevant skills."""
jd = get_jd()
tax = get_taxonomy()
# Collect all JD skills (Tier 1 + 2)
jd_skills = set()
for d in [tax.tier1, tax.tier2]:
for skills in d.values():
jd_skills.update(skills)
if not jd_skills:
return 0.5, {"matched_count": 0}
candidate_skills = schema.skills(c)
scores = []
for s in candidate_skills:
name = (s.get("name") or "").lower()
if any(jd_sk in name for jd_sk in jd_skills):
prof_w = PROF_WEIGHT.get(s.get("proficiency"), 0.25)
dur_gate = 1.0 if (s.get("duration_months") or 0) > 0 else 0.4
endorsements = min((s.get("endorsements") or 0), 50) / 50.0
trust = 0.40 * prof_w + 0.30 * dur_gate + 0.30 * endorsements
scores.append(trust)
if not scores:
return 0.0, {"matched_count": 0}
return sum(scores) / len(scores), {"matched_count": len(scores)}
def jd_skill_count(c: dict, text: str | None = None) -> tuple[float, dict]:
"""G1-7: Raw count of JD skills found, normalized."""
if text is None:
text = schema.unified_text_blob(c)
jd = get_jd()
all_jd_skills = set()
for d in [jd.required_skills, jd.preferred_skills]:
for skills in d.values():
all_jd_skills.update(skills)
found = [s for s in all_jd_skills if s in text]
# Normalize: ~5 skills is "full coverage" for this JD
return min(1.0, len(found) / 5.0), {"count": len(found)}
# ===========================================================================
# G2: IMPACT & OWNERSHIP
# ===========================================================================
def ownership_hierarchy(c: dict, text: str | None = None) -> tuple[float, dict]:
"""G2-1: Hierarchical ownership scoring from career descriptions."""
if text is None:
text = schema.unified_text_blob(c)
matches = _OWNERSHIP_RE.findall(text)
if not matches:
return 0.0, {"best_verb": "none"}
# Weighted average of ownership verbs found
weights = []
best_verb = ""
best_weight = 0
for m in matches:
v = m.lower()
w = _OWNERSHIP_TIERS.get(v, 0.15)
weights.append(w)
if w > best_weight:
best_weight = w
best_verb = v
avg_weight = sum(weights) / len(weights)
# Bonus for top-tier ownership (architected/spearheaded/owned)
top_tier_bonus = 1.15 if best_weight >= 0.85 else 1.0
score = min(1.0, avg_weight * top_tier_bonus)
return score, {"best_verb": best_verb, "best_weight": best_weight,
"verb_count": len(weights)}
def impact_magnitude(c: dict, text: str | None = None) -> tuple[float, dict]:
"""G2-2: Strength of best quantified impact metric."""
if text is None:
text = schema.unified_text_blob(c)
patterns = [
(r'improved\s+\w+\s+by\s+(\d+(?:\.\d+)?)\s*%', 0.9),
(r'reduced\s+latency\s+from\s+(\d+)\s*ms\s+to\s+(\d+)\s*ms', 1.0),
(r'(?:p99|p95)\s*(?:latency\s*)?(?:of\s*)?(\d+)\s*ms', 0.85),
(r'ndcg.*?(0\.\d{2,3})', 0.95),
(r'recall@?\d+\s*(?:improved\s*)?(?:to\s*)?(\d+(?:\.\d+)?)\s*%', 0.90),
(r'(\d+(?:\.\d+)?)\s*million\s+(?:daily\s+)?(?:active\s+)?users', 0.80),
(r'(\d+(?:\.\d+)?)\s*k?\s*(?:qps|rps|requests?\s*per\s*sec)', 0.85),
]
best_strength = 0.0
best_metric = ""
for pattern, weight in patterns:
m = re.search(pattern, text, re.IGNORECASE)
if m:
strength = weight
groups = [g for g in m.groups() if g is not None]
metric = " / ".join(groups)
if strength > best_strength:
best_strength = strength
best_metric = metric
return best_strength, {"best_metric": best_metric}
def impact_signals(c: dict, text: str | None = None) -> tuple[float, dict]:
"""G2-3: Non-quantified impact language."""
if text is None:
text = schema.unified_text_blob(c)
impact_phrases = [
"significantly improved", "dramatically improved", "substantially improved",
"increased engagement", "boosted conversion", "reduced churn",
"improved ranking quality", "better relevance", "higher click-through",
"reduced false positives", "improved precision", "increased recall",
"faster inference", "lower latency", "higher throughput",
"scaled to", "grew to", "expanded to",
]
found = [p for p in impact_phrases if p in text]
score = min(1.0, len(found) / 3.0)
return score, {"count": len(found)}
def evidence_strength(c: dict) -> tuple[float, dict]:
"""G2-4: Evidence density and quality (from evidence engine)."""
summary = get_evidence_summary(c)
score = min(1.0, summary["top3_avg"] / 18.0) # 18 = very strong top-3
return score, {
"best_score": summary["best_score"],
"count": summary["count"],
"top3_avg": round(summary["top3_avg"], 1),
}
# ===========================================================================
# G3: PRODUCTION & SCALE
# ===========================================================================
def production_strength(c: dict, text: str | None = None) -> tuple[float, list[str]]:
"""G3-1: Production deployment evidence."""
if text is None:
text = schema.unified_text_blob(c)
jd = get_jd()
hits = [p for p in jd.production_evidence if p in text]
unique_hits = list(set(hits))
score = min(1.0, len(unique_hits) / 5.0)
return score, unique_hits[:3]
def production_diversity(c: dict, text: str | None = None) -> tuple[float, dict]:
"""G3-2: Variety of production signals (different categories)."""
if text is None:
text = schema.unified_text_blob(c)
categories = {
"deployment": ["deployed", "shipped", "launched", "rollout"],
"live_traffic": ["live traffic", "real users", "production"],
"scale": ["at scale", "throughput", "qps", "latency"],
"operations": ["on-call", "monitoring", "sla", "p99"],
"testing": ["a/b test", "ab test", "canary", "integration test"],
}
found_cats = []
for cat, phrases in categories.items():
if any(p in text for p in phrases):
found_cats.append(cat)
return min(1.0, len(found_cats) / 3.0), {"categories": found_cats}
def scale_evidence(c: dict, text: str | None = None) -> tuple[float, dict]:
"""G3-3: Evidence of system scale."""
if text is None:
text = schema.unified_text_blob(c)
scale_patterns = [
(r'(\d+(?:\.\d+)?)\s*million', 0.9, "user_scale"),
(r'(\d[\d,]*)\s*(?:requests?|queries?)\s*(?:per\s*(?:sec|second|min|minute)|\/\s*(?:sec|s))', 0.85, "qps"),
(r'(\d+)\s*(?:tb|gb|pb)\s+of\s+data', 0.70, "data_scale"),
(r'(\d[\d,]*)\s*concurrent', 0.60, "concurrency"),
]
best = 0.0
best_metric = ""
for pattern, weight, label in scale_patterns:
m = re.search(pattern, text, re.IGNORECASE)
if m and weight > best:
best = weight
best_metric = f"{m.group(1)} ({label})"
return best, {"best_metric": best_metric}
# ===========================================================================
# G4: EXPERIENCE & CAREER
# ===========================================================================
def yoe_band_score(c: dict, jd: JDUnderstanding | None = None) -> tuple[float, dict]:
"""G4-1: YoE match to JD's experience range."""
if jd is None:
jd = get_jd()
yoe = schema.years_of_experience(c)
low, high = jd.yoe_low, jd.yoe_high
soft_low, soft_high = low - 1, high + 2
if low <= yoe <= high:
score = 1.0
elif soft_low <= yoe < low:
score = 0.55 + 0.45 * (yoe - soft_low) / (low - soft_low)
elif high < yoe <= soft_high:
score = 1.0 - 0.45 * (yoe - high) / (soft_high - high)
else:
score = 0.2
return max(0.0, min(1.0, score)), {"yoe": yoe, "band": f"{low}-{high}"}
def career_depth_ratio(c: dict, jd: JDUnderstanding | None = None) -> tuple[float, dict]:
"""G4-2: Fraction of career time in JD-relevant domain."""
if jd is None:
jd = get_jd()
ch = schema.career_history(c)
total_months = sum((r.get("duration_months") or 0) for r in ch) or 1
relevant_kw = set()
for skills in jd.required_skills.values():
relevant_kw.update(skills)
for skills in jd.preferred_skills.values():
relevant_kw.update(skills)
relevant_kw.update(jd.pre_llm_keywords)
relevant_months = 0
for r in ch:
role_text = f"{r.get('title','')} {r.get('description','')}".lower()
if any(kw in role_text for kw in relevant_kw):
relevant_months += (r.get("duration_months") or 0)
ratio = min(1.0, relevant_months / total_months)
return ratio, {"ratio": round(ratio, 2), "relevant_months": relevant_months}
def pre_llm_months(c: dict, jd: JDUnderstanding | None = None) -> tuple[float, dict]:
"""G4-3: Pre-2022 IR/search experience, normalized."""
if jd is None:
jd = get_jd()
ch = schema.career_history(c)
evidence = {}
for role in ch:
sd = schema.parse_date(role.get("start_date"))
if sd and sd.year < jd.pre_llm_cutoff_year:
role_text = f"{role.get('title','')} {role.get('description','')}".lower()
if any(marker in role_text for marker in jd.post_llm_markers):
continue
hits = [kw for kw in jd.pre_llm_keywords if kw in role_text]
if hits:
months = role.get("duration_months") or 0
# Normalize: 36+ months = full score
score = min(1.0, months / 36.0)
evidence["company"] = role.get("company", "")
evidence["keywords"] = hits[:2]
return score, evidence
return 0.0, evidence
def career_trajectory(c: dict) -> tuple[float, dict]:
"""G4-4: Career progression quality (title upgrades, company quality trend)."""
ch = schema.career_history(c)
if len(ch) < 2:
return 0.5, {"reason": "single_role"}
# Check title progression (from oldest to newest)
seniority_scores = []
for role in reversed(ch): # oldest first
sr, _ = title_scoring.seniority_score(role.get("title", ""))
seniority_scores.append(sr)
# Score upward trajectory
upward_moves = 0
for i in range(1, len(seniority_scores)):
if seniority_scores[i] > seniority_scores[i-1] + 0.05:
upward_moves += 1
traj_score = min(1.0, 0.3 + upward_moves * 0.25)
# Company quality trend
cq_scores = [company_tier.company_quality_score(r.get("company", "")) for r in reversed(ch)]
improving = sum(1 for i in range(1, len(cq_scores)) if cq_scores[i] > cq_scores[i-1] + 0.05)
score = 0.6 * traj_score + 0.4 * min(1.0, 0.3 + improving * 0.25)
return min(1.0, score), {"upward_moves": upward_moves, "company_improving": improving}
def company_quality_avg(c: dict) -> tuple[float, dict]:
"""G4-6: Average company quality across career."""
ch = schema.career_history(c)
if not ch:
return 0.5, {"count": 0}
scores = [company_tier.company_quality_score(r.get("company", "")) for r in ch]
return sum(scores) / len(scores), {"count": len(scores)}
def career_stability(c: dict) -> tuple[float, dict]:
"""G4-7: Average tenure length (penalize job hopping)."""
ch = schema.career_history(c)
if len(ch) < 2:
return 0.6, {"avg_tenure_months": ch[0].get("duration_months", 0) if ch else 0}
total_months = sum((r.get("duration_months") or 0) for r in ch)
avg_tenure = total_months / len(ch)
# Score: 24+ months = 1.0, 12 months = 0.6, <12 = 0.3
if avg_tenure >= 24:
score = 1.0
elif avg_tenure >= 12:
score = 0.6 + 0.4 * (avg_tenure - 12) / 12
else:
score = 0.3 + 0.3 * avg_tenure / 12
return min(1.0, score), {"avg_tenure_months": round(avg_tenure), "roles": len(ch)}
def promotion_velocity(c: dict) -> tuple[float, dict]:
"""G4-8: Speed of title promotions."""
ch = schema.career_history(c)
if len(ch) < 2:
return 0.5, {"promotions": 0}
# Count title level upgrades
levels = []
for role in reversed(ch): # oldest first
title = role.get("title", "").lower()
if any(w in title for w in ["principal", "distinguished", "fellow", "vp", "chief"]):
levels.append(5)
elif any(w in title for w in ["staff", "lead", "director", "head of"]):
levels.append(4)
elif "senior" in title or "sr." in title or "sr " in title:
levels.append(3)
elif any(w in title for w in ["junior", "jr.", "jr ", "intern", "associate"]):
levels.append(1)
else:
levels.append(2)
promotions = sum(1 for i in range(1, len(levels)) if levels[i] > levels[i-1])
total_years = schema.years_of_experience(c) or 1
score = min(1.0, 0.4 + promotions * 0.2)
return score, {"promotions": promotions, "years": total_years}
# ===========================================================================
# G5: RETRIEVAL & EVALUATION
# ===========================================================================
def retrieval_depth(c: dict, text: str | None = None) -> tuple[float, dict]:
"""G5-1: Retrieval system sophistication."""
if text is None:
text = schema.unified_text_blob(c)
retrieval_keywords = [
"bm25", "elasticsearch", "opensearch", "faiss", "pinecone",
"weaviate", "qdrant", "milvus", "vector database", "vector db",
"hybrid search", "hybrid retrieval", "semantic search",
"dense retrieval", "ann", "embedding index",
"cross-encoder", "bi-encoder", "colbert", "re-ranking",
"query understanding", "relevance feedback",
]
found = [kw for kw in retrieval_keywords if kw in text]
# Score based on sophistication
has_vector = any(kw in found for kw in ["faiss", "pinecone", "weaviate", "qdrant", "milvus", "vector db", "vector database"])
has_hybrid = any(kw in found for kw in ["hybrid search", "hybrid retrieval", "bm25", "elasticsearch", "opensearch"])
has_dense = any(kw in found for kw in ["dense retrieval", "semantic search", "embedding index", "ann"])
has_advanced = any(kw in found for kw in ["cross-encoder", "bi-encoder", "colbert", "re-ranking"])
score = 0.0
if found:
score += min(0.4, len(found) * 0.1)
if has_vector:
score += 0.2
if has_hybrid:
score += 0.2
if has_dense:
score += 0.1
if has_advanced:
score += 0.1
return min(1.0, score), {"found": found[:5], "has_hybrid": has_hybrid,
"has_vector": has_vector, "has_advanced": has_advanced}
def evaluation_experience(c: dict, text: str | None = None) -> tuple[float, dict]:
"""G5-2: Evaluation framework experience."""
if text is None:
text = schema.unified_text_blob(c)
eval_keywords = [
"ndcg", "mrr", "map@", "precision@", "recall@",
"a/b test", "ab test", "offline evaluation", "online evaluation",
"evaluation framework", "evaluation pipeline", "offline-to-online",
"ranking quality", "relevance judgment",
]
found = [kw for kw in eval_keywords if kw in text]
has_ndcg = "ndcg" in found
has_ab = any("a/b" in kw or "ab" in kw for kw in found)
has_offline = "offline evaluation" in text or "offline-to-online" in text
score = min(1.0, len(found) * 0.2)
if has_ndcg and has_ab:
score += 0.2
if has_offline:
score += 0.1
return min(1.0, score), {"found": found[:5], "has_ndcg": has_ndcg,
"has_ab": has_ab}
def system_design_evidence(c: dict, text: str | None = None) -> tuple[float, dict]:
"""G5-3: System design / architecture work."""
if text is None:
text = schema.unified_text_blob(c)
design_keywords = [
"architecture", "designed", "system design", "tech design",
"end-to-end", "microservice", "service-oriented",
"data pipeline", "ml pipeline", "feature pipeline",
"api design", "scalable", "distributed",
]
found = [kw for kw in design_keywords if kw in text]
score = min(1.0, len(found) * 0.15)
return score, {"found": found[:5]}
# ===========================================================================
# G6: BEHAVIOURAL
# ===========================================================================
def recency(c: dict) -> tuple[float, dict]:
"""G6-1: How recently active on the platform."""
s = schema.signals(c)
last_active = schema.parse_date(s.get("last_active_date"))
evidence = {}
if last_active:
days = (REFERENCE_DATE - last_active).days
evidence["days_since_active"] = days
if days <= 30:
score = 1.00
elif days <= 60:
score = 0.92
elif days <= 90:
score = 0.82
elif days <= 180:
score = 0.70
else:
score = 0.55
else:
evidence["days_since_active"] = 90
score = 0.82
return score, evidence
def responsiveness(c: dict) -> tuple[float, dict]:
"""G6-2: Response rate + speed."""
s = schema.signals(c)
evidence = {}
rr = s.get("recruiter_response_rate")
rr = float(rr) if isinstance(rr, (int, float)) else 0.3
evidence["recruiter_response_rate"] = rr
if rr >= 0.70:
resp = 1.00
elif rr >= 0.50:
resp = 0.92
elif rr >= 0.30:
resp = 0.82
elif rr >= 0.15:
resp = 0.68
else:
resp = 0.55
speed_h = s.get("avg_response_time_hours")
speed_h = float(speed_h) if isinstance(speed_h, (int, float)) else 48.0
speed_sc = max(0.0, 1.0 - speed_h / 168.0)
score = 0.65 * resp + 0.35 * speed_sc
return score, evidence
def market_demand(c: dict) -> tuple[float, dict]:
"""G6-3: Recruiter interest signals."""
s = schema.signals(c)
saves = min(float(s.get("saved_by_recruiters_30d") or 0), 20.0) / 20.0
appearances = min(float(s.get("search_appearance_30d") or 0), 200.0) / 200.0
return 0.6 * saves + 0.4 * appearances, {"saves": s.get("saved_by_recruiters_30d", 0)}
def github_activity(c: dict) -> tuple[float, dict]:
"""G6-4: GitHub coding activity."""
s = schema.signals(c)
gh = s.get("github_activity_score")
gh = float(gh) if isinstance(gh, (int, float)) else -1.0
if gh >= 50:
score = 1.00
elif gh >= 20:
score = 0.92
elif gh >= 5:
score = 0.82
elif gh >= 0:
score = 0.72
else:
score = 0.65 # No GitHub linked, not penalized to zero
return score, {"github_activity_score": gh}
def availability_score(c: dict) -> tuple[float, dict]:
"""G6-5: Open to work + notice period."""
s = schema.signals(c)
evidence = {}
# Open to work
otw = 1.05 if s.get("open_to_work_flag") else 0.95
# Notice period
notice = s.get("notice_period_days")
notice = int(notice) if isinstance(notice, (int, float)) else 45
evidence["notice_period_days"] = notice
jd = get_jd()
preferred = jd.notice_preferred_days
if notice <= preferred:
notice_sc = 1.00
elif notice <= 60:
notice_sc = 0.95
elif notice <= 90:
notice_sc = 0.85
else:
notice_sc = 0.75
raw = 0.6 * notice_sc + 0.4 * (1.0 if s.get("open_to_work_flag") else 0.5)
return max(0.50, min(1.10, raw)), evidence
def interview_completion(c: dict) -> tuple[float, dict]:
"""G6-6: Interview follow-through rate."""
s = schema.signals(c)
rate = s.get("interview_completion_rate")
rate = float(rate) if isinstance(rate, (int, float)) else 0.7
return rate, {"interview_completion_rate": rate}
def platform_trust(c: dict) -> tuple[float, dict]:
"""G6-7: Profile verification and completeness."""
s = schema.signals(c)
completeness = s.get("profile_completeness_score")
completeness = float(completeness) if isinstance(completeness, (int, float)) else 50.0
verification = sum([
bool(s.get("verified_email")),
bool(s.get("verified_phone")),
bool(s.get("linkedin_connected")),
]) / 3.0
interview_rate = s.get("interview_completion_rate")
interview_rate = float(interview_rate) if isinstance(interview_rate, (int, float)) else 0.7
score = 0.40 * (completeness / 100.0) + 0.35 * verification + 0.25 * interview_rate
return score, {"completeness": completeness}
# ===========================================================================
# G7: RESUME QUALITY
# ===========================================================================
def quantified_outcomes(c: dict, text: str | None = None) -> tuple[float, dict]:
"""G7-1: Number of quantified achievements."""
if text is None:
text = schema.unified_text_blob(c)
# Count numbers with context
patterns = [
r'\d+(?:\.\d+)?%', r'\d+\s*ms', r'\d+\s*(?:million|billion)',
r'\d+x\s+(?:improvement|speedup|increase)',
r'\d+[\d,]*\s*(?:users|requests|queries|events)',
]
count = 0
for p in patterns:
count += len(re.findall(p, text, re.IGNORECASE))
score = min(1.0, count / 5.0)
return score, {"count": count}
def truthiness(c: dict, text: str | None = None) -> tuple[float, dict]:
"""G7-2: Cross-validation of skill claims vs career evidence."""
if text is None:
text = schema.unified_text_blob(c)
jd = get_jd()
# Get skills listed in skills[] that are JD-relevant
jd_skills = set()
for d in [jd.required_skills, jd.preferred_skills]:
for skills in d.values():
jd_skills.update(skills)
listed_jd_skills = []
for s in schema.skills(c):
name = (s.get("name") or "").lower()
if any(jd_sk in name for jd_sk in jd_skills):
listed_jd_skills.append(name)
# Check how many are actually backed by career context
backed = 0
unbacked = 0
for skill_name in listed_jd_skills:
if skill_name in text:
backed += 1
else:
unbacked += 1
total = backed + unbacked
if total == 0:
return 0.5, {"backed": 0, "unbacked": 0}
score = backed / total
return score, {"backed": backed, "unbacked": unbacked}
def keyword_stuffing_risk(c: dict) -> tuple[float, dict]:
"""G7-3: Probability of keyword stuffing."""
skills = schema.skills(c)
if len(skills) < 12:
return 0.0, {"risk": "low", "skill_count": len(skills)}
# High skill count + most with zero duration and high proficiency
zero_dur_expert = sum(
1 for s in skills
if (s.get("duration_months") or 0) == 0
and s.get("proficiency") in ("advanced", "expert")
)
risk = zero_dur_expert / len(skills)
return min(1.0, risk), {"risk": "high" if risk > 0.3 else "medium" if risk > 0.1 else "low",
"zero_dur_expert": zero_dur_expert}
def profile_completeness(c: dict) -> tuple[float, dict]:
"""G7-4: Profile completeness score."""
s = schema.signals(c)
score = s.get("profile_completeness_score")
score = float(score) if isinstance(score, (int, float)) else 50.0
return score / 100.0, {"raw": score}
# ===========================================================================
# G8: SAFETY
# ===========================================================================
def disqualifier_penalty(c: dict, text: str | None = None) -> tuple[float, list[str]]:
"""G8-1: Multi-factor disqualifier penalty (multiplicative)."""
if text is None:
text = schema.unified_text_blob(c)
jd = get_jd()
penalty = 1.0
reasons = []
title = schema.current_title(c).lower()
ch = schema.career_history(c)
# Non-engineering title
if any(bt in title for bt in jd.bad_title_patterns):
ml_hits = _count_hits(text, list(jd.required_skills.keys()))
context_hits = 0
for skills in jd.required_skills.values():
context_hits += len([s for s in skills if s in text])
if context_hits < 4:
penalty *= 0.05
reasons.append("non_engineering_title")
# Research-only title + zero production
if any(h in title for h in jd.research_only_titles):
prod_score, _ = production_strength(c, text)
if prod_score == 0.0:
penalty *= 0.15
reasons.append("research_only_no_production")
# Entire career at consulting
def _is_consulting(r: dict) -> bool:
comp = (r.get("company") or "").lower()
ind = (r.get("industry") or "").lower()
return (any(f in comp for f in jd.consulting_firms) or
any(k in ind for k in jd.consulting_industries))
current_ind = (schema.profile(c).get("current_industry") or "").lower()
current_is_consulting = (
any(f in schema.current_company(c).lower() for f in jd.consulting_firms)
or any(k in current_ind for k in jd.consulting_industries)
)
role_flags = [_is_consulting(r) for r in ch]
if len(ch) >= 2 and all(role_flags) and (not schema.current_company(c) or current_is_consulting):
penalty *= 0.50
reasons.append("consulting_only_career")
# Non-target domain without rescue
if any(d in text for d in jd.non_target_domains):
if not any(k in text for k in jd.non_target_rescue):
penalty *= 0.40
reasons.append("non_nlp_domain")
# Architect drift
if any(h in title for h in jd.architect_titles):
current_role = next((r for r in ch if r.get("is_current")), None)
if current_role and (current_role.get("duration_months") or 0) >= 18:
role_text = (current_role.get("description") or "").lower()
if not _count_hits(role_text, jd.production_evidence + ["code", "coding", "implement"]):
penalty *= 0.60
reasons.append("architect_no_recent_code")
# Title-chaser
if len(ch) >= 3:
avg_tenure = sum((r.get("duration_months") or 0) for r in ch) / len(ch)
if avg_tenure < 16:
penalty *= 0.75
reasons.append("short_average_tenure")
# Current services company penalty
comp = schema.current_company(c).lower()
comp_score = company_tier.company_quality_score(comp)
if comp_score <= 0.35:
penalty *= 0.65
reasons.append("current_services_company")
return penalty, reasons
# ===========================================================================
# G9: LOCATION
# ===========================================================================
def location_score(c: dict) -> tuple[float, dict]:
"""G9-1: Location match to JD preferences."""
jd = get_jd()
p = schema.profile(c)
loc = (p.get("location") or "").lower()
country = (p.get("country") or "").lower()
relocate = bool(schema.signals(c).get("willing_to_relocate", False))
if any(city in loc for city in jd.preferred_locations):
return 1.0, {"match": "preferred"}
if any(city in loc for city in jd.welcome_locations):
return 0.90, {"match": "welcome"}
if "india" in country:
return (0.85 if relocate else 0.75), {"match": "india"}
return (0.50 if relocate else 0.20), {"match": "international"}
# ===========================================================================
# HELPERS
# ===========================================================================
def _count_hits(text: str, phrases: list[str]) -> list[str]:
return [p for p in phrases if p in text]
def title_relevance(c: dict) -> tuple[float, str]:
"""G1-5: Title match to JD (delegates to title_scoring)."""
return title_scoring.title_relevance_score(schema.current_title(c))
def seniority_feature(c: dict) -> tuple[float, str]:
"""G1-6: Seniority level (delegates to title_scoring)."""
return title_scoring.seniority_score(schema.current_title(c))
def company_quality_feature(c: dict) -> float:
"""G4-5: Current company quality (delegates to company_tier)."""
return company_tier.company_quality_score(schema.current_company(c))
# ===========================================================================
# V6.1 NEW FEATURES β winning differentiators
# Each addresses a specific trap or signal the JD calls out but V6 misses.
# ===========================================================================
# Tier-5 signature: a senior engineer at a product company who built and owned
# a real system, even if they don't use the JD's exact keywords.
# The JD says: "A Tier 5 candidate may not use the words 'RAG' or 'Pinecone'
# in their profile, but if their career history shows they built a recommendation
# system at a product company, they're a fit."
def tier5_signature(c: dict, text: str | None = None,
features: dict | None = None) -> tuple[float, dict]:
"""V6.1-1: Tier-5 candidate signature β strong career evidence even without JD keywords."""
if text is None:
text = schema.unified_text_blob(c)
if features is None:
features = {}
title_rel = features.get("title_relevance", 0)
company_q = features.get("company_quality", 0)
career_depth = features.get("career_depth_ratio", 0)
ownership = features.get("ownership_hierarchy", 0)
production = features.get("production_strength", 0)
skill_cov = features.get("skill_coverage", 0)
yoe_band = features.get("yoe_band_score", 0)
# Tier-5 conditions (all must be true)
cond_title = title_rel >= 0.85
cond_company = company_q >= 0.80
cond_depth = career_depth >= 0.50
cond_ownership = ownership >= 0.65
cond_production = production >= 0.40
cond_yoe = yoe_band >= 0.55
# Tier-5 fires when 5+ of 6 conditions are met AND skill_coverage is moderate
# (the whole point: Tier-5 candidates don't have perfect keyword coverage)
conditions_met = sum([cond_title, cond_company, cond_depth,
cond_ownership, cond_production, cond_yoe])
# Bonus when skill_coverage is low but other signals are strong β exactly
# the "doesn't use the words RAG or Pinecone" pattern from the JD.
if conditions_met >= 5 and skill_cov < 0.50:
score = 1.0 # full Tier-5 signature
sig_type = "pure_tier5"
elif conditions_met >= 5:
score = 0.85
sig_type = "tier5_with_keywords"
elif conditions_met >= 4:
score = 0.50
sig_type = "partial_tier5"
else:
score = 0.0
sig_type = "none"
return score, {"type": sig_type, "conditions_met": conditions_met,
"skill_coverage": skill_cov}
# Behavioral twin trap: perfect-on-paper candidate who is behaviorally unavailable.
# 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."
def behavioral_twin(c: dict, features: dict | None = None) -> tuple[float, dict]:
"""V6.1-2: Detect 'behavioral twin' β looks perfect on paper but is unavailable.
Returns a PENALTY score in [0, 1] where 1.0 = no penalty, 0.0 = severe penalty.
Multiply the final composite by this penalty.
"""
if features is None:
features = {}
s = schema.signals(c)
days_active = features.get("recency", 0) # not days, but score
last_active = schema.parse_date(s.get("last_active_date"))
if last_active:
actual_days = (REFERENCE_DATE - last_active).days
else:
actual_days = 180
rr = float(s.get("recruiter_response_rate") or 0)
otw = bool(s.get("open_to_work_flag"))
notice = int(s.get("notice_period_days") or 45)
interview_rate = float(s.get("interview_completion_rate") or 0.7)
offer_rate = s.get("offer_acceptance_rate")
offer_rate = float(offer_rate) if isinstance(offer_rate, (int, float)) and offer_rate >= 0 else 0.5
# Build a penalty in [0, 1] (1 = no penalty, 0 = severe penalty)
penalty = 1.0
reasons = []
# Stale activity (>180 days)
if actual_days > 180:
penalty *= 0.75
reasons.append(f"inactive_{actual_days}d")
elif actual_days > 120:
penalty *= 0.90
reasons.append(f"inactive_{actual_days}d")
# Very low recruiter response rate
if rr < 0.15:
penalty *= 0.70
reasons.append(f"low_response_rate_{rr:.2f}")
elif rr < 0.30:
penalty *= 0.90
reasons.append(f"low_response_rate_{rr:.2f}")
# Long notice period (>90 days, the JD says "bar gets higher")
if notice > 120:
penalty *= 0.80
reasons.append(f"long_notice_{notice}d")
elif notice > 90:
penalty *= 0.92
reasons.append(f"long_notice_{notice}d")
# Low interview completion
if interview_rate < 0.50:
penalty *= 0.85
reasons.append(f"low_interview_completion_{interview_rate:.2f}")
# Low offer acceptance (signal of being unserious)
if 0 <= offer_rate < 0.30:
penalty *= 0.88
reasons.append(f"low_offer_acceptance_{offer_rate:.2f}")
return max(0.30, penalty), {"reasons": reasons, "days_inactive": actual_days,
"response_rate": rr, "notice_days": notice}
# LangChain-only recent AI experience β JD explicit disqualifier:
# "If your 'AI experience' consists primarily of recent (under 12 months) projects
# using LangChain to call OpenAI β we will probably not move forward"
def langchain_only_recent(c: dict, text: str | None = None,
features: dict | None = None) -> tuple[float, dict]:
"""V6.1-3: Detect LangChain-only recent AI experience.
Returns a PENALTY multiplier in [0, 1] where 1 = no penalty.
"""
if text is None:
text = schema.unified_text_blob(c)
if features is None:
features = {}
ch = schema.career_history(c)
pre_llm = features.get("pre_llm_months", 0)
total_yoe = schema.years_of_experience(c)
# Count LangChain-only signals across career
langchain_kw = ["langchain", "llamaindex", "openai api", "chatgpt", "gpt-4",
"gpt-3.5", "claude", "gemini", "anthropic"]
production_ml_kw = ["production", "deployed", "shipped", "serving", "live traffic"]
# Recent role (current or most recent) description
current_role = next((r for r in ch if r.get("is_current")), ch[0] if ch else None)
if not current_role:
return 1.0, {"reasons": []}
current_desc = (current_role.get("description") or "").lower()
current_duration = current_role.get("duration_months") or 0
# Is recent role LangChain-heavy?
has_langchain = any(kw in current_desc for kw in langchain_kw)
has_production = any(kw in current_desc for kw in production_ml_kw)
# Recent role < 12 months AND LangChain-heavy AND no pre-LLM experience
if has_langchain and current_duration < 12 and pre_llm < 0.10 and total_yoe < 36:
return 0.35, {"reasons": ["langchain_only_recent_no_pre_llm"],
"duration": current_duration, "pre_llm": pre_llm}
# LangChain-heavy but with some production evidence β partial penalty
if has_langchain and not has_production and pre_llm < 0.20:
return 0.65, {"reasons": ["langchain_no_production_evidence"],
"duration": current_duration, "pre_llm": pre_llm}
return 1.0, {"reasons": []}
# Closed-source 5+ years without external validation β JD explicit disqualifier:
# "People whose work has been entirely on closed-source proprietary systems for 5+
# years without external validation (papers, talks, open-source)."
def closed_source_isolation(c: dict, text: str | None = None,
features: dict | None = None) -> tuple[float, dict]:
"""V6.1-4: Detect closed-source isolation β 5+ years with no external validation.
Returns a PENALTY multiplier in [0, 1].
"""
if text is None:
text = schema.unified_text_blob(c)
if features is None:
features = {}
yoe = schema.years_of_experience(c)
s = schema.signals(c)
# External validation signals
github = s.get("github_activity_score")
github = float(github) if isinstance(github, (int, float)) and github >= 0 else -1
external_val_kw = ["open source", "open-source", "published", "paper",
"conference talk", "blog post", "github.com",
"patent", "arxiv", "workshop", "neurips", "icml", "iclr",
"acl", "emnlp", "kdd", "www ", "sigir"]
has_external = any(kw in text.lower() for kw in external_val_kw)
# 5+ years experience AND no external validation AND no GitHub activity
if yoe >= 5 and not has_external and github < 5:
# Severity scales with YoE
if yoe >= 10:
return 0.55, {"reasons": ["closed_source_5yr_isolation_severe"],
"yoe": yoe, "github": github}
else:
return 0.75, {"reasons": ["closed_source_5yr_isolation"],
"yoe": yoe, "github": github}
return 1.0, {"reasons": []}
# Pre-LLM Γ Ownership interaction β rare and valuable.
# The JD wants "people who understood retrieval and ranking before it became fashionable"
# AND "shipped at least one end-to-end ranking system." This interaction captures both.
def pre_llm_x_ownership(c: dict, features: dict | None = None) -> tuple[float, dict]:
"""V6.1-5: Pre-LLM IR experience Γ Ownership interaction."""
if features is None:
features = {}
pre_llm = features.get("pre_llm_months", 0)
ownership = features.get("ownership_hierarchy", 0)
# Both must be present
if pre_llm < 0.20 or ownership < 0.50:
return 0.0, {"pre_llm": pre_llm, "ownership": ownership}
# Geometric mean β rewards having BOTH
score = (pre_llm * ownership) ** 0.5
return min(1.0, score * 1.2), {"pre_llm": pre_llm, "ownership": ownership,
"interaction": score}
# Salary compatibility β V6 missed this signal entirely.
# JD says "Notice period: sub-30-day preferred. 30+ day notice candidates are still
# in scope but the bar gets higher." Salary mismatch is an implicit disqualifier.
def salary_compatibility(c: dict) -> tuple[float, dict]:
"""V6.1-6: Salary expectations vs typical Senior AI Engineer range in India.
Senior AI Engineer in India (Series A): 40-80 LPA typical.
Below 25 LPA suggests junior-level expectations (mismatch with senior role).
Above 120 LPA suggests they're at a level beyond this role.
"""
s = schema.signals(c)
sal = s.get("expected_salary_range_inr_lpa") or {}
sal_min = float(sal.get("min", 0) or 0)
sal_max = float(sal.get("max", 0) or 0)
if sal_min == 0 and sal_max == 0:
return 0.70, {"reason": "no_salary_data"}
# Use the midpoint
mid = (sal_min + sal_max) / 2
# Sweet spot: 40-90 LPA
if 40 <= mid <= 90:
return 1.0, {"mid": mid, "reason": "sweet_spot"}
if 30 <= mid < 40:
return 0.85, {"mid": mid, "reason": "slightly_low"}
if 90 < mid <= 110:
return 0.85, {"mid": mid, "reason": "slightly_high"}
if 25 <= mid < 30:
return 0.65, {"mid": mid, "reason": "low_expectations"}
if 110 < mid <= 150:
return 0.70, {"mid": mid, "reason": "high_expectations"}
if mid < 25:
return 0.40, {"mid": mid, "reason": "junior_level_salary"}
if mid > 150:
return 0.50, {"mid": mid, "reason": "overqualified_salary"}
return 0.70, {"mid": mid, "reason": "default"} |