Training
AutoScientist trained this adapter in run 8dc3d64a-cd0f-4807-a6e9-4eadb542ed3d: one iteration of supervised fine-tuning, LoRA rank 16, alpha 32, no dropout, on every attention and MLP projection, 3 epochs at learning rate 2e-4. The run took 33 minutes.
The 2,686 training rows pair real US tech internship postings with synthetic resumes. Adaption's Adapt wrote each resume from a candidate card our code sampled, so we know exactly what each resume holds, and Adapt also read each posting into a requirement card. The Keep, Explore and Skip labels come from Claude labelers applying one written rubric. The training data isn't published here.
Adaption trains on Together's copy of Qwen3.5-4B (togethercomputer/Qwen3.5-4B), which isn't on the Hub. In this upload, adapter_config.json points at Qwen/Qwen3.5-4B, the public checkpoint the demo merges the adapter into. The weights are as AutoScientist returned them (archive sha256 e0895cbe622cad1efce65807dbd6d3004b6bb5419a99cc6b77d7a9fd2700eb35).
Use
The model expects the prompt Gradus builds, framed as one user turn with the Qwen3.5 chat template and thinking off. Gradus doesn't sample the label. It reads the scores of the three label tokens at the next position, takes a softmax over them, and computes s = P(Keep) + 0.5 P(Explore). Keep is s ≥ 0.589, Skip is s < 0.273, and Explore is anything in between. The prompt builder and the decider are in the repo (matcher.pairs.build_prompt, matcher.decider), so install it first with uv sync.
import torchfrom transformers import AutoModelForCausalLM, AutoTokenizerfrom peft import PeftModelfrom matcher.pairs import build_promptfrom matcher.decider import decide tok = AutoTokenizer.from_pretrained("Rolfier/gradus-qwen3.5-4b-lora")base = AutoModelForCausalLM.from_pretrained("Qwen/Qwen3.5-4B", dtype=torch.bfloat16, device_map="auto")model = PeftModel.from_pretrained(base, "Rolfier/gradus-qwen3.5-4b-lora").eval() prompt = build_prompt(resume_text, profile_text, posting_text)text = tok.apply_chat_template([{"role": "user", "content": prompt}], add_generation_prompt=True, tokenize=False, enable_thinking=False)ids = [tok.encode(w, add_special_tokens=False)[0] for w in ("Keep", "Explore", "Skip")]enc = tok(text, add_special_tokens=False, return_tensors="pt").to(model.device)with torch.no_grad(): scores = model(**enc).logits[0, -1, ids].float().tolist()print(decide(scores))
The eval and the demo read the same three scores in float32 (matcher.evalstats.LabelHead), because bfloat16 logits can tie, so the snippet's labels can differ from theirs on near ties. In Gradus, plain rules on the posting's requirement card skip postings that rule the candidate out (work authorization, enrollment, degree, location) before the model sees them.
Results
On a sealed test scored once (1,139 candidate and posting pairs over 290 real postings), on the 513 rows where the resume decides, Gradus with this adapter got 81% accuracy, against 59% for the untrained Qwen3.5-4B behind the same rules. Macro-F1 went from 0.46 to 0.68, and the rules alone get 0.48. Of the 388 rows where the posting asks for something the resume lacks, the trained model caught 346, up from 244. The repo's docs/RESULTS.md has the intervals and the other tests.
Limits
The resumes in training and testing are synthetic, the postings are US tech internships in English, and the labels come from Claude labelers rather than recruiters. The model is better at catching deal-breakers than at picking the best fits. Gradus is for candidates sorting postings. Don't use this model to screen or rank people.