subhrajit36/Synapse

★ 1Forks 0PythonGitHub ↗Compare

Project website ↗

README

Synapse — Graph-Based Talent Matching

Reframes resume-JD matching as graph reasoning over a skill knowledge graph, not keyword overlap.


What Synapse Does

Traditional ATS Synapse
"Candidate lacks Kubernetes → reject" "Candidate has Docker → Kubernetes is 1 hop away (bridgeable gap)"
Black-box score Explainable: matched skills, bridged gaps, true gaps, path lengths
Keyword matching Graph distance + semantic similarity

High-Level Design (HLD)

flowchart TB

CLIENT["👤 Browser page / MCP client"]
GEM["Gemini API<br/>structured skill extraction"]

%% ============ ① OFFLINE — developer machine, never the server ============
subgraph S1["① Graph construction — offline, run by a developer"]
  direction LR
  ONET["O*NET taxonomy dump"]
  BUILD["build_graph.py<br/>skill + role nodes,<br/>rank-based SIMILAR edges"]
  PKL["skill_graph.pkl<br/>dev + test artifact"]
  MIG["scripts/migrate_graph.py<br/>wipe SIMILAR, rewrite,<br/>verify — pool untouched"]
  ONET --> BUILD --> PKL --> MIG
end

%% ============ ② AURADB — system of record ============
subgraph S2["② Neo4j AuraDB — system of record"]
  direction LR
  SKILLS["Skill / Role nodes<br/>SIMILAR, REQUIRES"]
  POOL["Candidate nodes<br/>HAS_SKILL → Skill"]
end

MIG -->|"migrate, never at boot"| SKILLS

%% ============ ③ SERVING PROCESS ============
subgraph S3["③ One uvicorn process — FastMCP transport + HTTP routes + static page"]
  direction TB

  subgraph S3A["Ingestion — LangGraph, one document at a time"]
    direction LR
    READ["read<br/>pdf / docx / txt / md,<br/>bytes or path;<br/>normalize + overlapping chunks"]
    EXTRACT["extract<br/>one chunk group per superstep"]
    BACKOFF["backoff<br/>retry as a node,<br/>fatal errors skip it"]
    LINK["link<br/>alias → surface → embedding,<br/>below threshold = unresolved"]
    PERSIST["persist<br/>candidate profile + skills"]
    FINAL["finalize"]
    READ --> EXTRACT
    EXTRACT --> BACKOFF
    BACKOFF --> EXTRACT
    EXTRACT --> LINK
    LINK --> PERSIST
    PERSIST --> FINAL
  end

  CKPT["SQLite checkpoint<br/>one thread per document"]
  SERVER["server.py — transport only<br/>MCP tools, JSON routes, page"]
  ENGINE["engine.py<br/>typed contracts, lazy resources,<br/>pool cache"]
  NX["NetworkX skill graph<br/>materialised once, in process"]
  MATCH["Matcher — pure<br/>multi-source Dijkstra,<br/>matched / bridged / unreachable"]

  EXTRACT -.->|"cursor + partial skills"| CKPT
  SERVER --> ENGINE
  ENGINE --> NX
  NX --> MATCH
end

EXTRACT <--> GEM
PERSIST --> POOL
SKILLS -->|"one query at first use"| NX
POOL -->|"candidate pool, cached"| ENGINE

CLIENT -->|"upload resumes"| READ
CLIENT -->|"JD skills, rank, explain"| SERVER
SERVER -->|"MatchResult per candidate:<br/>components, bridge paths, reason codes"| CLIENT
Loading

Three things the diagram is asserting. The graph is built offline and migrated, never constructed at boot. AuraDB is the system of record for both the skill graph and the candidate pool, and the skill graph is materialised into NetworkX once per process so the scoring path stays the one the evaluation measured. And a ranking query never calls Gemini — extraction is an ingestion-time concern, so serving does not depend on a rate-limited external API.


Key Achievements

Phase Milestone Result
A Intelligence core Working end-to-end locally (NetworkX, Gemini Flash extraction)
B Evaluation 7-arm study with bootstrap CIs — graph reasoning beats cosine-only by +0.406 on bridge>weak
C Productionization Ready to start (FastEmbed → Neo4j AuraDB → FastMCP → Render)

The Edge Substrate Experiment (Phase B.2)

We tested 4 edge substrates to find the best skill↔skill adjacency for bridging:

Substrate Method bridge>weak (heldout) Bridge Precision Decision
Embedding ✅ BAAI/bge-small-en-v1.5 cosine k-NN 0.859 [0.761, 0.944] 48.8% Production
Categorical O*NET Element Name cliques 0.547 [0.395, 0.695] 29.2% Control
Typed (sub) LLM-classified substitute only 0.500 [0.357, 0.635] 77.9% Research
Typed (sub+prereq) + prerequisite edges 0.731 [0.607, 0.848] 29.8% Research

Why Embedding Won

  • Only substrate meeting the falsification criterion: bridge>weak ≥ 0.80 on heldout
  • Beats strong baseline: +0.406 over cosine-only (0.454) — not a forced mechanism probe
  • Reproducible: Frozen arm reproduces original Phase B result exactly
  • FastEmbed ready: CPU-only ONNX, no GPU, fits 512MB RAM

What Typed Edges Achieved (Research Track)

  • 77.9% bridge precision vs 48.8% — LLM correctly identifies substitutes
  • AUC dropped 0.906 → 0.610 — edges carry signal beyond category membership
  • Failed ranking: Sparsity (201 edges / 213 nodes) + directed prerequisites + complement exclusion = insufficient connectivity
  • Preserved for future: Bidirectional prerequisites, node coverage expansion

Bootstrap Confidence Intervals — Why They Matter

Arm bridge>weak (95% CI) nDCG@10 (95% CI)
Embedding 0.859 [0.761, 0.944] 0.922 [0.875, 0.959]
Categorical 0.547 [0.395, 0.695] 0.880 [0.834, 0.923]
Typed (sub) 0.500 [0.357, 0.635] 0.904 [0.876, 0.930]
Cosine-only 0.454 [0.000, 0.000] 0.908 [0.000, 0.000]

Importance: With only 15 heldout JDs, point estimates are noisy. The CIs prove:

  • Embedding clearly beats categorical (CIs don't overlap)
  • Embedding clearly beats cosine-only (lower bound 0.761 > 0.454)
  • Typed edges cannot claim superiority (upper bound 0.635 < 0.80 target)

Protocol: JD-level resampling, 1000 iterations, config swept per-arm on train split only.


Tech Stack (Locked)

Layer Tool
Orchestration LangGraph
LLM Extraction Gemini Flash (Google AI Studio, free tier)
Embeddings FastEmbed (BAAI/bge-small-en-v1.5, ONNX, CPU)
Graph (dev) NetworkX + pickle
Graph (prod) Neo4j AuraDB Free
API FastMCP (HTTP/SSE)
Deployment Docker → Render Free Web Service

Quickstart

# 1. Setup
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt

# 2. Build graph (dev: NetworkX pickle)
python -m synapse.graph.build_graph

# 3. Run evaluation (7 arms, bootstrap CIs)
python -m synapse.eval.run_eval_arms

# 4. See results
cat src/synapse/eval/RESULTS.md

# 5. Phase C3: ingest documents through the LangGraph pipeline
export GEMINI_API_KEY=...
python -m synapse.ingest.pipeline data/samples --doc-type resume -v

Ingestion pipeline (Phase C3)

synapse.ingest.pipeline wires Reader → Extractor into a LangGraph graph over a typed IngestState. Three things it buys over calling the extractor directly:

  • Retry as a node, not a try/except. A 429 or 503 routes to a backoff node that waits and re-runs the chunk; a fatal error (bad key, quota) skips retrying entirely. The decision and its reason land in state, so a slow run is explainable after the fact.
  • One chunk per superstep. Extraction advances a cursor, so the checkpointer saves progress between every Gemini call.
  • Resumable batches. With --checkpoint (SQLite, default data/checkpoints/ingest.sqlite), a run killed during a rate-limit pause resumes at the chunk it stopped on instead of re-billing the whole document against the 15 RPM free tier. Each document gets its own checkpoint thread.
python -m synapse.ingest.pipeline data/raw \
    --doc-type resume --checkpoint data/checkpoints/ingest.sqlite --out data/extractions
# re-run the exact same command after an interruption to resume

MCP server (Phase C4)

python -m synapse.mcp.server --warm -v          # HTTP on 127.0.0.1:8000/mcp
python -m synapse.mcp.server --transport sse    # SSE transport, same tools
python -m synapse.mcp.server --transport stdio  # for local MCP clients
curl localhost:8000/health                      # {"status":"ok",...}

Four tools, all read-only, all thin wrappers over synapse.matching:

Tool Purpose
rank_candidates FR5 — rank a pool against a JD, with score components per candidate
get_bridgeable_gaps FR4 — split missing skills into bridgeable (with the path) and real gaps
explain_score NFR6 — one candidate's full derivation plus the linking trace
graph_stats What graph is loaded, how it is configured; doubles as a warm-up call

Skill names are canonicalized before scoring (K8s → Kubernetes). Surfaces that reach no node come back in jd_unresolved / unresolved_skills rather than being dropped, because an unresolved JD skill leaves the demand denominator and would otherwise inflate the score silently. Unreachable gaps report distance: -1 with reason: "no_path" — MCP payloads are strict JSON and infinity does not survive the wire.

/health deliberately does not touch the graph: Render's free tier cold-starts, and a probe that unpickled 213 skills would report unhealthy while a healthy instance was merely waking up. Graph readiness is graph_stats.

Graph source: AuraDB (Phase C2/C6)

AuraDB is the system of record. The graph is materialised into NetworkX once at first use, so the scoring path stays byte-identical to the one Phase B measured — Matcher runs the same in-process Dijkstra either way. One query, ~1.2MB, negligible against the 512MB budget.

NEO4J_URI=neo4j+s://<instance>.databases.neo4j.io
NEO4J_USERNAME=<from the Aura credentials file>
NEO4J_PASSWORD=<from the Aura credentials file>
NEO4J_DATABASE=<from the Aura credentials file>

SYNAPSE_GRAPH_SOURCE=neo4j    # production; fails startup if Aura is unreachable
SYNAPSE_GRAPH_SOURCE=pickle   # tests and offline work; never touches the network

There is deliberately no automatic fallback. A service that quietly serves a stale local pickle when Aura is down violates NFR4 while reporting itself healthy, so neo4j raises instead. graph_stats reports the active graph_source, and the loader asserts the graph's shape (SYNAPSE_EXPECTED_SKILLS, SYNAPSE_EXPECTED_PAIRS, defaulting to 213 / 15,459) so a wrong-shaped deploy fails at startup rather than producing subtly wrong rankings.

AuraDB currently holds the pre-fix dense graph. It must be re-migrated from the rebuilt artifact before SYNAPSE_GRAPH_SOURCE=neo4j will start — the shape assertion will refuse it, which is the assertion doing its job.

Two things about the migrated data that will otherwise mislead you:

  • SIMILAR is stored in both directions — 30,918 relationships for 15,459 logical pairs. Compare count_similar_pairs() against a NetworkX edge count, not count_edges()["SIMILAR"], or it looks like a 2× mismatch.
  • Two roles have no REQUIRES edges (... Occupations, All Other), so roles must be loaded as nodes, not derived from the edge list.

Verified against the live instance: identical rankings and identical gap analysis from both sources, 251 nodes / 17,877 edges each.

Web UI (Phase D)

The same uvicorn process serves the MCP transport and a single static page — one service, no build step, no Node.

GET  /              -> the page
GET  /api/jds       -> JDs in the versioned eval snapshot
POST /api/rank      -> ranked candidates + full MatchResult each (one round trip)

Pick a JD on the left, click a candidate on the right to expand its score into direct_match_score, bridge_score and gap_penalty, its matched skills, its bridged skills with the path that produced them (← via <skill>, n hops, distance d), its unreachable gaps with a reason code, and the active ScoringParams. Every number is read from MatchResult; nothing is computed in the browser. Each candidate's ground-truth tier from the eval set is shown beside its score, so the ranking can be checked against the labels.


Known limitation — FR4 is not validated

FR3 (ranking) is validated. FR4 (gap explanation) is not. Treat the bridgeable/gap distinction as unproven, and read the reported path distance and hop count rather than the label.

The density regression, and its fix

For a period the graph was 68.5% dense with a diameter of 2, and every missing skill classified as "bridgeable" — the label carried no information at all.

The cause was an embedder swap, not the scoring. strong_sim=0.60 and bridge_cutoff=0.70 were both calibrated against all-MiniLM-L6-v2. The C1 migration to BAAI/bge-small-en-v1.5 kept both numbers and changed their meaning: bge compresses short-string cosines into a high narrow band whose all-pair median (~0.63) sits above the build threshold, so two unrelated skills were expected to clear it; and its edge distances are roughly half MiniLM's, so a 2-hop path cost ~0.46 against a cutoff of 0.70 that had been rejecting ~0.91.

Both halves are corrected:

  • Edge construction is rank-based only. The absolute-threshold pass is off by default, because "which are this skill's nearest neighbours" is scale-free while "which pairs exceed 0.6 cosine" is not. add_semantic_edges now aborts the build if the result exceeds a plausible density ceiling, so this class of regression fails loudly instead of shipping.
  • bridge_cutoff is rescaled to 0.35, which is 0.70 expressed in the new embedder's distance units. This is a scale correction, not a sweep result.

The rebuilt graph is ~3.4% dense with mean degree ~7, and roughly a quarter of missing skills classify as bridgeable — matching the pre-migration regime.

FR4 still is not independently validated. Restoring the discrimination is not the same as measuring its precision, and that measurement has not been re-run.

Caveats on the reported numbers

Everything in RESULTS.md predates the fixes above and does not describe the current graph. Re-run the arms before citing any of it.

  • The graph artifact was rebuilt after those results were generated, so they are not reproducible against it even before the density fix.
  • The bootstrap CIs predate a fix to bootstrap_ci, which had been reporting one ranker's interval for every ranker and none at all for the frozen baselines.
  • TUNED_PARAMS carries max_bridge_credit=0.9. Without that ceiling, bridge_credit_scale=2.0 let a bridged skill out-earn a direct match, and a candidate holding none of a role's skills outranked one holding all of them.
  • bridge_cutoff is no longer the swept value, for the scale reason above.

Project Structure

synapse/
├── data/eval/
│   ├── EDGE_SUBSTRATE_STUDY.md    # Complete experiment documentation
│   ├── v2/dataset.json            # 30 JDs × 18 candidates (540 pairs)
│   └── typed_edge_cache.jsonl     # LLM classification cache
├── src/synapse/
│   ├── ingest/
│   │   ├── reader.py             # Node 1: load + chunk
│   │   ├── extractor.py          # Node 2: Gemini structured extraction
│   │   └── pipeline.py           # Phase C3: LangGraph graph + checkpointing
│   ├── matching/        # EntityLinker + Matcher (scoring + gaps)
│   ├── mcp/
│   │   ├── engine.py             # Phase C4: loaded graph + typed tool contracts
│   │   ├── server.py             # Phase C4: FastMCP tools + Phase D routes
│   │   └── static/index.html     # Phase D: the score-decomposition page
│   ├── graph/
│   │   ├── build_graph.py        # Embedding + Categorical graphs
│   │   ├── migrate_to_neo4j.py   # Phase C2: NetworkX → Neo4j
│   │   └── typed_edges.py        # Research: LLM edge classification
│   └── eval/
│       ├── run_eval_arms.py      # 7-arm evaluation + bootstrap
│       ├── RESULTS.md            # Final numbers with CIs
│       └── ABLATION.md
├── CLAUDE.md              # Phase A→C implementation plan
├── Edge_substrate_plan.md # Phase B.2 experiment plan
├── requirements.txt
└── README.md

License

O*NET data © U.S. Department of Labor, used under CC BY 4.0.

Contributors

swaraj123822subhrajit36

Issues