On-demand Scout service (replaces the nightly aggregator): - FastAPI agent-mesh service; card name "matchmaking-service" (HTTP /a2a/tasks + Redis-stream worker matching the sibling-service pattern) - run_search: parallel multi-board sweep (Naukri/blackfalcondata + Foundit + LinkedIn), city-filtered at the board, cheapest-per-result first - per-board adapters + normalizers -> ScoutJob with rich read-only details and offsite apply links; recall mode + MVQ guard - result cache for dev replay ($0); per-board cost knobs; retry-on-5xx - research/: engine design, board cost economics, India-actor shortlist, POC
25 KiB
Matchmaking v2 (Scout) — Engine Design & Locked Plan
Canonical design doc. Captures the engine research, the locked decisions, and the open items. Written 2026-06-12. Revised 2026-06-16 with live-evidence findings. Supersedes the dead
matchmakingservice.Companion docs (gathered evidence — read alongside):
SIGNAL_AUDIT_V2.md— evidence-based signal inventory; supersedes §2/§5 below.AUTOAPPLY_STATE.md— validated browser-use auto-apply engine; supersedes/extends §6.DECOMMISSION_PLAN.md— how v2 slots in over the old matchmaking (port:8006).2026-06-16 deltas in one line: the engine is richer mostly by wiring (interview/roleplay/ assessment are LIVE data, not "gaps"); a new high-trust signal (course watch-behavior) was found; auto-apply is now a validated board-agnostic vision agent (not just hosted-submit/ handoff); v2 reuses port
:8006(not:8011); Pathways is cut.
0. Why v2 exists
The old matchmaking service is being decommissioned. Diagnosis (verified June 11–12):
- Wrong shape: it was a nightly job aggregator — scraped ~13.8k jobs into its own Postgres and scored 21 users against the stale pile. 986,770 match-score rows for 21 users.
- Dead scheduling: not cron — a self-rescheduling RQ chain in Redis that went empty (likely an ElastiCache eviction) and silently stopped. Ingestion dead since May 27.
- Weak engine: ranked on the lowest-trust signals (self-declared prefs + claimed skills
- an often-absent QScore that, at 30% weight, collapsed everyone to a neutral 60).
v2 is a fresh, purely on-demand service. No aggregation. Fetch live listings when the user searches, score only those.
1. Locked architecture (the shape)
- On-demand only. Per user search, fetch ~25–200 live listings from LinkedIn + Naukri +
Indeed via Apify, score only those. No corpus, no nightly batch.
- Sync fetch is viable: Apify
run-sync-get-dataset-items(300s cap), cold start ~1.2s. - 3-board search ≈ $0.15–0.25 uncached; shared cache keyed
role+location, TTL 1–6h. - Vendor picks — VALIDATED 2026-06-16 (live probe,
poc/browser-apply/apify_probe.py):- Direct-ATS
bovi/greenhouse-lever-ashby-job-scraper= the autonomous backbone — public JSON endpoints (no auth), returns clean ATSapply_url, 100% Tier-1 offsite in-sample, ~$1.50/1k. Company/preset-driven (not free-text). This is the new, highest-yield inbound. - LinkedIn
harvestapi/linkedin-job-search— 60% offsite (applyMethod.companyApplyUrl), real ATS diversity. Needs a one-timeapprovePermissions(full-account-access actor). - Indeed
misceres/indeed-scraper— ~35% external, but ~0% clean-ATS for India/ML (breadth, low autonomous yield). - Naukri → use
muhammetakkurtt/naukri-job-scraper(11k users, no permission gate), NOTmemo23or the fishyinfinity_and_beyond. ItsapplyRedirectUrlis the true external link, but ~93% point to bespoke small-company career pages, not clean ATS. - Measured combined yield: ~43% have an offsite link, ~14% immediately Tier-1 autonomous
on a free-text search (more if you lead with direct-ATS). Full data →
apify-inboundmemory.
- Direct-ATS
- Sync fetch is viable: Apify
- Surface: ONE responsive web app, no extension, no native app download.
- The cross-origin limitation is now solved a third way (validated): a server-side/cloud
AI browser agent (browser-use) fills any board's form by vision — no extension, no ATS
creds needed. See §6 +
AUTOAPPLY_STATE.md. (Hosted-submit/prepared-handoff remain fallbacks.)
- The cross-origin limitation is now solved a third way (validated): a server-side/cloud
AI browser agent (browser-use) fills any board's form by vision — no extension, no ATS
creds needed. See §6 +
- Slots in over the OLD matchmaking — port
:8006, not a new:8011(revised; seeDECOMMISSION_PLAN.md). v2 inherits the existingapi.growqr.ai/matchmaking/*route + the frontend's matchmaking wiring. Still FastAPI in thecourse-servicemold: Redis-Streams worker, registered in orchestratorAGENT_URLS, reached via the orchestrator WS, frontend talks to it through the existinguseAgentSession(...)hook. The old matchmaking's own Postgres (corpus) is dropped — v2 is on-demand, no corpus.
1.1 Apify call contracts (verified live 2026-06-17)
Probed each actor's real input schema (poc/browser-apply/apify_probe.py, key loaded). Field-level
contract + the ScoutPrefs→params coverage matrix live in ENGINE_INPUTS.md. Headlines:
- MVQ (Minimum Viable Query) = title + a resolvable location. No MVQ → do not call Apify
(engine returns
{needs:[...]}; frontend also gates "Find my matches"). Cache key =title+geo, TTL 1–6h. - Free-text lane:
harvestapi/linkedin-job-search(jobTitles[],locations[]),misceres/indeed-scraper(position,location,country),muhammetakkurtt/naukri-job-scraper(keyword,cities[],maxJobs*). - Curated lane (separate):
bovi/greenhouse-lever-ashbyis company-list-driven; user inputs only filter within it (titleKeyword,locationKeyword,remoteOnly,keywords[]). - Naukri is the richest target for India — uniquely exposes
department,industry, ₹salaryRange, numericexperience. Lead with it. Gotcha: Naukriindustry/department/citiestake numeric codes, not labels (labels are display-only).
1.2 Input coercion layer (the board adapters)
Nothing from the UI goes straight to an actor — a per-board adapter parses/maps/converts each
ScoutPrefs field to that actor's exact schema. Code: engine/board_adapters/ (coerce.py +
build_{naukri,linkedin,indeed,ats}_input(prefs)).
6 coercion dimensions: ① parse/split ("City · Country"→{city,country,remote}) · ② enum-map
(label→board enum) · ③ unit-convert (₹→lakhs bucket; seniority⇄years) · ④ id-lookup (name→code:
LinkedIn geoIds/industryIds, Naukri numeric codes) · ⑤ cardinality (multi-select→array where
supported, else fan-out N searches + dedup — Indeed/bovi take single strings) · ⑥ presence
(Any→omit, Remote→flag, Anywhere→geo-only).
Verified lookup maps (built into coerce.py):
| Map | Examples |
|---|---|
COUNTRY_ISO2 (Indeed) |
India→IN, US→US, UK→GB |
WORKMODE_* |
Remote→remote, On-site→office, Any→omit |
SENIORITY_YEARS (Naukri experience) |
Fresher 0 · Junior 2 · Mid 5 · Senior 9 · Lead 13 |
SENIORITY_LINKEDIN (experienceLevel) |
Junior→associate, Mid→mid-senior, Senior→director |
COMP_NAUKRI_SALARY (salaryRange) |
₹16–22L→15to25, ₹22–30L→25to50 |
ROLE_NAUKRI_DEPT (codes) |
Product→10 · Engineering→5 · Design→15 · Data→3 · Sales→14 · Ops→2 |
INDUSTRY_NAUKRI (codes) |
Fintech→114 · SaaS→110 · E-commerce→108 · EdTech→133 · HealthTech→131 · Consulting→126 |
CITY_NAUKRI (codes, 1→N) |
Bengaluru→[97] · Mumbai→[134] · Delhi NCR→[6,73,220,350] · Hyderabad→[17] · Pune→[139] |
INDUSTRY_LINKEDIN_ID |
best-effort — TODO verify vs LinkedIn taxonomy; falls back to keyword fold |
System-set (not user): maxJobs/maxItems (25–200 budget) · freshness (freshness=7/postedLimit=week/
recentWindowDays=30 — the liveness gate) · sortBy=date · fetchDetails/followApplyRedirects=true
(real offsite apply URL) · Naukri country implicit IN.
Composite-keyword fallback for boards lacking department/industry (LinkedIn/Indeed/bovi):
keyword = [seniorityPrefix] + title + [industryTerm] (e.g. Indeed position="Senior Payments PM fintech"),
while Naukri stays clean (keyword="Payments PM" + structured department/industry/salaryRange).
Hard rule — degrade, don't break: any value that doesn't resolve to a board enum/code is never sent as an invalid enum. It falls back to the keyword fold or post-fetch filter, and is logged. A missing lookup loses precision, never the call.
Engine-side-only inputs (no board param on any actor — used for ranking/floors/exclusions/ordering):
priorities, stretch, intent, availability, dealBreakers, sort. See ENGINE_INPUTS.md §6.
2. Signals — keep / discard (the input audit)
➜
SIGNAL_AUDIT_V2.mdsupersedes this section (evidence-based, from live-DB sampling 2026-06-16). Deltas: (a) course watch-behavior is a NEW high-trust booster — revealed preference ("completes React content → step toward frontend") beats stated; (b) interview/ roleplay/assessment are LIVE data, not gaps (§5) but are already digested into QScore, so v1 uses the QScore quotient as the competence proxy to avoid double-counting; (c) Pathways is CUT (feature and its questionnaire-as-signal — weak link: broken skill-gap, impractical roles). The trust⟂coverage discipline below is unchanged and is what keeps "more signals" from re-causing the v1 collapse.
Governing tension: trust ⟂ coverage. High-trust signals have low coverage; high-coverage signals have low trust. Design rule: a signal earns SCORE WEIGHT only if its coverage is high enough that absence isn't the norm; otherwise it is an ADDITIVE BOOSTER (bonus when present, never a penalty when absent). The old engine broke this rule — that's the v1 collapse bug.
KEEP
Tier A — backbone (high coverage; filter + light score)
years_experience(date-derived, not the self-declared band)current_role/ title (prefer social-branding LinkedIn-verified)preferred_locations+ work mode (hard filter)target_roles/target_industries(filter, not score — it's want not is)- skills (normalized — see §3.2)
Tier B — verified enrichers (medium coverage; real weight when present)
- verified title / company / industry (social-branding; industry only via BrightData path)
- degree / field_of_study / institution
- certifications
- assessment scores (
passed/percentage— user-service, shipped Jun 11; earned, grows) - QScore quotient vector (10-D; enricher/tiebreaker — not additive-only, coverage-penalized)
Tier C — premium boosters (low coverage; ADDITIVE ONLY, never penalize absence)
- interview 8-D rubric (~5–10% coverage)
- roleplay 6-D rubric (<3%)
- improvement velocity / coachability
- video on-camera score (<1%)
- (Doubles as an adoption flywheel: "do a mock interview → sharper matches.")
DISCARD (noise for matching)
- ATS
overall_score⚠️ measures resume quality/formatting, not job fit — the trap. - parser confidence,
resume_checksignals (QA, not the person) - follower/connection counts, post engagement (vanity)
brand_score,pathway_archetype(rare/external/unpopulated)- self-declared
salary_range(zeros/guesses — use the listing's market salary) - engagement streak/trends (engagement ≠ fit)
- names, avatar, email; cover-letter analysis & coaching narratives (for humans, not the scorer)
- institution_tier — discarded deliberately; sidesteps the worst India pedigree-bias vector.
3. THE ENGINE (the v2 redesign)
3.0 Core verdict (research-backed)
- Embedding/cosine similarity must NOT be the ranker [proven, 3-0]. It rewards topical word-overlap over qualification fit, can't enforce hard constraints (seniority, certs), and collapses on sparse profiles — exactly the v1 failure mode. Embedding = one input signal.
- Winner: a deterministic white-box utility score, fused with other rankers by WEIGHTED-AVERAGE RANK [proven: WAvg rank +31.9% nDCG@10; beats RRF +5.8% and every single ranker]. "Retrieve cheap, rerank expensive" still applies — the boards did retrieval; we spend compute reranking the 25–200 fetched listings.
3.1 The cascade (per search)
① NORMALIZE skills → ESCO canonical; titles → canonical role
② HARD FILTER location/work-mode, must-have certs, seniority floor (deterministic gate —
embeddings CAN'T do this)
③ WHITE-BOX U = Σ_{f∈present} c_f·w_f·φ_f / Σ_{f∈present} c_f·w_f
UTILITY factors: skill_match, experience, location, salary, semantic_sim (ONE input),
industry/company, + QScore-quotient / assessment boosters
④ FUSION weighted-average RANK over: utility ‖ embedding-sim ‖ LLM soft-score
⑤ LLM RERANK GPT-5.4 on top ~20: calibrated 0–1 fit vs a 6-level rubric;
+ EXPLAIN ALSO logged as cold-start supervision; emits "why" + growth nudge
⑥ OUTPUT ranked list + breakdown + explanation
↓
LOG soft-scores + user save/apply → accrue labels → swap ③④ priors for a learned
pairwise/listwise ranker (margin loss / CMMD — NOT pointwise MSE)
3.2 Skill matching (the core accuracy lever) [proven method]
Exact taxonomy lookup is a dead end (only 41/202 soft skills exact-match ESCO; real-world span-match F1 caps ~27%). The stack:
- Synonym canonicalization to ESCO (
k8s→Kubernetes,ML→Machine Learning) — seed from ESCO + v1 role synonyms + India tech terms. - Depth-2 knowledge-graph traversal of
RELATED_TOedges for implied/transferable skills (Kubernetes → "container orchestration"). Powers STRETCH. - Contrastive embedding alignment to the taxonomy to beat the ~27% ceiling. Validate locally — the F1@5 0.72 result is on Chinese ads. v1 ships synonym+KG+cosine-to-ESCO; upgrade to a trained contrastive aligner later.
- Required vs preferred weighting: required near-gating, preferred additive.
- Taxonomy = ESCO. EU-origin — sanity-check India role/skill coverage early; extend, don't swap.
3.3 Coverage-aware combination [our design — no validated formula exists]
Renormalize over present signals only, each weighted by confidence c_f (coverage × trust
from the audit): U = Σ_{present} c_f·w_f·φ_f / Σ_{present} c_f·w_f.
A missing signal redistributes its weight to what we know — never penalizes, never flattens.
This makes the v1 "QScore absent → everyone 60" bug impossible by construction. Validate c_f
offline (confirm it doesn't over-reward sparse profiles).
3.4 Cold-start weighting (no labels at launch) [proven]
- Day 1: expert-prior weights — starting point
skill .35 / experience .25 / location .15 / salary .10 / semantic .10 / company .05. Priors to calibrate, not optima. - From day 1, in parallel: GPT-5.4 as teacher — calibrated 0–1 soft scores on a 6-level rubric, logged on every match. Synthesizes supervision (study: discriminative specificity 3.5× — 0.058 → 0.208) instead of waiting for outcomes (<0.05% dense).
- As save/apply accrues: train a pairwise/listwise ranker + contrastive/paraphrase augmentation; shift fusion weight off the priors.
3.5 FIT vs STRETCH [our design — research left open]
- FIT: skill-gap ≈ 0; score current-state overlap; mild over-qualification penalty.
- STRETCH: target = one level up / adjacent; score by reachability = fraction of the job's required skills the user has OR is within depth-2 KG hops of. "Reach but reachable" = small, bridgeable gap → this is the prototype's growth nudge.
- Maps onto the Scout intent picker: current-state → "more like this"; aspiration → "step up / new".
4. Evaluation & model roles — no human in the loop
- Runtime is fully autonomous. No human ever reviews, approves, or scores a user's matches.
- Eval grading is automated and cross-model to avoid self-grading inflation:
Role Model Match generation, cold-start soft-score teacher, explanations/growth nudges GPT-5.4 Independent accuracy grading (LLM-as-judge) Opus 4.8 - Cross-family grading kills self-preference bias (no model marks its own homework).
- Residual generic LLM-judge bias (position/verbosity) → prompt hygiene: randomize order, pairwise, strip length cues.
- Un-gameable ground truth = real save/apply rate once live. Opus is the pre-launch proxy.
- Metrics: nDCG@10, MRR, P@5, calibration MAE on a synthetic + dogfood gold set (~50–100 India-representative pairs; no hired annotators). Online: interleaving + A/B once live.
- Note: Opus-as-judge is a deliberate, fine exception to "always GPT-5.4" (it's Anthropic, not an OpenAI call). Eval pipeline needs an Anthropic API key alongside OpenAI.
5. Transport readiness (do the pipes exist?)
Backbone exists: orchestrator context_resolver builds user_context from resume-builder +
social-branding /api/state/{clerk_id}; matchmaking calls qscore-service directly.
REVISED 2026-06-16 — the "gaps" were assumptions; the data is LIVE. Verified by sampling the
prod DBs over SSH (see SIGNAL_AUDIT_V2.md §3):
- ✅ READY: all Tier A + QScore quotient vector.
- ✅ LIVE (was "partial"): verified LinkedIn title/company/industry — social-branding does a live LinkedIn fetch; LinkedIn education; certifications.
- ✅ LIVE (was "❌ GAP"): assessment scores (user-service), interview rubrics (
growqr_interview, 210 reviews — 8-D rubric + video presence + confidence-ratio), roleplay rubrics (growqr_roleplay, Gemini video analysis), improvement velocity (trend_data/historical_comparison). None are a build — they're a query. Caveat: filter degenerate sessions (no-show/low-talk poison scores). - Transport already exists: interview & roleplay services already receive résumé/LinkedIn
context via the orchestrator
context_resolver. matchmaking-v2 reuses that exact pattern — no new transport to build. v1 wiring = consume the assembleduser_context+ one extra read of course watch-behavior (growqr_course). - Still: QScore is the competence proxy for v1 (it already consumes interview/roleplay); read raw rubrics later only if they add lift beyond the aggregate.
6. Auto-apply (the heart) — "one tap at the end"
➜
AUTOAPPLY_STATE.mdsupersedes/extends this section (validated POC, 2026-06-16). Headline update: the original "full auto-submit = NO-GO" is REVISED — a server-side AI browser agent (browser-use + gpt-4.1 vision) is now a validated, board-agnostic "master key":
- Proven: a real application was submitted to OpenAI on AshbyHQ (on-screen success confirmed); the generic adapter then filled Greenhouse forms it had never seen (4/5) and India-native ATS (Zoho Recruit, Keka) with no tuning, honestly bouncing login-walled / no-form pages.
- Architecture: one generic engine + a thin adapter registry (Ashby adapter carries earned quirks; generic adapter handles the long tail by vision). Verification-first: screenshot-as- ground-truth + a hard accuracy gate (never trust the agent's self-report).
- Tiered reality: autonomous for public ATS (Ashby/Greenhouse/Lever) + India-native ATS; login-gated boards (LinkedIn Easy Apply, Naukri dashboard, Workday/Oracle) stay human/Cloud-assisted.
- Economics (measured): gpt-4.1 vision-off ~$0.13/app (clean ATS), ~$0.21–0.27 (India ATS), ~3–6 min/app; gpt-4o-mini is a false economy (failed + not cheaper). Latency needs parallelism.
- Anti-bot is the make-or-break risk: Ashby spam-flagged the fast LinkedIn-routed submit; the fix was direct-to-ATS + human-like pacing + verified upload. reCAPTCHA/hCaptcha remain a wall (simple text CAPTCHAs are vision-solvable). New POC code:
poc/browser-apply/(the oldpoc/auto-apply/mock is retired).
By the time the user taps, all the work is done (found, tailored, filled). The tap ends an application, it doesn't start one. In the no-install web app, "one tap" resolves two ways:
- 🛰️ Hosted submit — backend POSTs the application to an ATS API (Greenhouse / Lever / Ashby). True one-tap-done, any device, zero install. Gated by employer creds → coverage = the integrations we provision; grows over time.
- 🔗 Prepared handoff — one tap opens the board's own page (LinkedIn/Naukri) with résumé + cover letter + answers staged; user taps submit there. Zero install, any device, zero ban risk.
- Phase 4 full auto-submit: NO-GO for now (unattended submit ~46% reliable + ToS-exposed; the survivor pattern is user-device extension autofill + human submit). Opt-in only when revisited.
POC validates the hosted-submit path end-to-end → see poc/auto-apply/.
Hard rule: never fire test applications at real employers' live postings. POC-0 = mock ATS;
POC-1 = our own ATS sandbox/test job.
6.1 LISTING FRESHNESS / LIVENESS — first-class gate (learned from the POC, June 12)
The personalized demo tapped a real LinkedIn posting that was already "no longer accepting applications." Then we empirically checked ~7 recent AI-Architect postings: essentially all were dead (closed / HTTP 404 / redirect-to-search = LinkedIn's expired-job behavior). Findings:
- A perfect match for a dead job is worse than no match — it's actively undelightful.
- Permalinks rot in days. Never hand a user a cached posting URL — it's probably dead.
- Tap-time server-side liveness re-check is unreliable: LinkedIn serves bot-walled / guest content (our httpx check false-returned "open" for a job we knew was closed), and expired URLs 302→search or 404. You cannot trust a scrape to tell you a posting is open. Requirements for v2:
- Freshness at the DATA layer: the on-demand Apify fetch returns posting date /
isExpired/ applicant count — filter & rank by recency, drop/flag stale before presenting. The whole point of on-demand: every shown listing was fetched seconds ago, so it's fresh by construction. - Honest age signals in the UI ("posted 3d ago", "closes soon") — derive from posting age (no scraper returns a true deadline field).
- Apply handoff targets a live view, never a stored permalink: the freshly-fetched listing or the board's live search for the role (always current). Recover gracefully, never dead-end.
7. Proven vs. validate-locally (honesty ledger)
| Locked & proven (adopt method) | Our design — must validate locally |
|---|---|
| Hybrid fusion > embedding-only ranker | Exact fusion weights (60/25/15 was tuned on a tiny set) |
| Weighted-avg rank > RRF | The coverage-renormalization formula + c_f values |
| Synonym + KG-expand + contrastive skills > exact match | FIT/STRETCH reachability scoring |
| LLM-as-teacher for cold-start; pairwise > pointwise | India pedigree/fairness mitigations |
| Embedding-only collapses on sparse profiles | ESCO coverage for India roles |
Headline accuracy numbers do NOT transfer (single-paper, small-benchmark, domain-mismatched — gig matching, Chinese job ads, offline corpora). Adopt the patterns, re-tune on GrowQR data.
8. Open items
Resolved 2026-06-16:
- ✅ Apify inbound validated (Naukri →
muhammetakkurtt; direct-ATS = autonomous backbone; offsite- yield measured). ✅ Auto-apply POC validated end-to-end (master key, real submission, India ATS). - ✅ Signal audit refreshed against live evidence (
SIGNAL_AUDIT_V2.md). ✅ Agent capability pass (interview/roleplay strong, Pathways cut). ✅ Slot-in/decommission plan (DECOMMISSION_PLAN.md).
Still open:
- Build the engine itself — wire the v2 input set (Backbone + verified LinkedIn + QScore +
course watch-behavior) via the reused
context_resolver; implement the §3 cascade. - Latency benchmark of the actors from ap-south-1; parallelize apply (3–6 min/app is too slow serial).
- Submit-time anti-bot lane (the make-or-break): Cloud stealth / CAPTCHA / human-final-tap.
- Build the ~50–100 pair India gold set; validate Opus-judge agreement. ESCO India coverage check.
- Label volume for the learned ranker (old corpus was 21 users — accrue via the live save/apply loop).
UI — in progress (2026-06-17, branch job-ui-final):
- ✅ Scout UI built in-place in
dashboard-ui/scout(not a separateindex.htmlPOC) around the locked feature set; screen-by-screen decisions + as-shipped divergences tracked inSCOUT_UI_SPEC.md("BUILD PROGRESS" section). Re-verified honest against this doc — all cuts/improvements, no unbacked claims. Notable: cascading Country→City location (feeds Tier-Apreferred_locations+ ported location-fit); a no-resume "Any/open" state that is the UI face of §3.3 (sparse profile → coverage renormalizes, never fabricates); loader + scope made honest (≤120, on-demand framing). - 🔧 UI still waiting on the engine for: apply-result statuses (proof screenshots + auto/failed→manual split), per-job resume-tailoring spinner, and the real signal/match-breakdown wiring behind the cards.
Key sources (engine research, 2026)
- Synapse (arXiv 2604.02539) — hybrid fusion > embedding-only; WAvg rank fusion +31.9% nDCG@10.
- JobMatchAI (arXiv 2603.14558) — white-box utility weights; ESCO synonym + depth-2 KG expansion.
- ConFit / ConFit v2 (arXiv 2401.16349) — contrastive + paraphrase augmentation; label sparsity <0.05%.
- Person-Job Fit distillation (arXiv 2601.10321) — LLM-as-teacher soft scores; specificity 0.058→0.208; CMMD.
- JobSkape (arXiv 2402.03242) — ESCO span-match F1 ~26–27% ceiling.
- CareerBERT (arXiv 2503.02056) — embedding degradation on short/sparse profiles (coverage collapse).
- Springer 12651-025-00409-x — only 41/202 soft skills exact-match ESCO.