From 0e054f16e770a7fc4deca4824361f4de883a96ab Mon Sep 17 00:00:00 2001 From: Mads Lorentzen Date: Wed, 19 Aug 2026 19:54:32 +0200 Subject: [PATCH] fix(upskill): give Step 3.3 a rule for blank fit_rating rows /outcome-created tracker rows (applications made outside the workflow) never got a fit evaluation, so fit_rating is blank - and Step 3.3's weight formula divides by it with no stated rule. Blank read as 0 means weight 1.0, the maximum: the job the framework knows least about would dominate the heatmap and the learning plan. Blank now falls back to a matched ranked entry's rank_score, else skip+count+report once - the same pattern the skill already applies to missing gaps. Review finding F29 (2026-08-19). Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/upskill/SKILL.md | 2 +- CHANGELOG.md | 8 ++++++++ tests/test_upskill_skill.py | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.claude/skills/upskill/SKILL.md b/.claude/skills/upskill/SKILL.md index 5859abb..57b5822 100644 --- a/.claude/skills/upskill/SKILL.md +++ b/.claude/skills/upskill/SKILL.md @@ -56,7 +56,7 @@ This mode now merges two sources — tracker rows (Step 2.1) and ranked postings 1. **Dedupe.** Match tracker rows against ranked entries on case-insensitive company + role (casefold + strip on both fields) — the same match `/notion-sync`'s Step 2 describes. A job present in both counts once. 2. **Recorded gaps beat inferred skills.** For any job that has a recorded `gaps` array (from a ranked entry, or from a tracker row that matched one), use those gap bullets directly as the skill list for that job instead of inferring from `role`/`sector`/`notes`. For a ranked-only job with no `gaps` (already skipped and counted in Step 2.3) or a tracker-only row, fall back to inferring likely required skills from `role`, `sector`, and `notes` — optionally WebFetch the row's `source` URL for more detail, but skip if the URL is missing or dead. -3. **One weight per job**, both 0–100 on the same scale: `(100 - fit_rating) / 100` for tracker rows, `(100 - rank_score) / 100` for ranked-only rows. If a job is in both (Step 3.1 matched it), prefer the tracker's numeric `fit_rating` for the weight. +3. **One weight per job**, both 0–100 on the same scale: `(100 - fit_rating) / 100` for tracker rows, `(100 - rank_score) / 100` for ranked-only rows. If a job is in both (Step 3.1 matched it), prefer the tracker's numeric `fit_rating` for the weight. A **blank or non-numeric `fit_rating`** (rows `/outcome` creates for applications made outside the workflow never got a fit evaluation) contributes no weight: fall back to a matched ranked entry's `rank_score` when Step 3.1 found one, otherwise skip the row, count it, and report the count once in the terminal — the same treatment Step 2.3 gives a missing `gaps` field, and for the same reason. Never treat a blank as 0: that reads as weight 1.0, the maximum, and lets the one job the framework knows nothing about dominate the heatmap. 4. **Score.** Build a **skill frequency map**: for each extracted skill (recorded gap bullet or inferred skill), count how many jobs mention it, then multiply each job's contribution by its weight from Step 3.3. Track whether each contribution came from a recorded gap or an inferred one, for Step 5's provenance column. Final score for each skill: `sum of (weight × occurrence)` across all jobs. diff --git a/CHANGELOG.md b/CHANGELOG.md index 565ca4a..3c96fb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,14 @@ per-file diff commands. ### Fixed +- **`/upskill` no longer divides by a blank `fit_rating`** - `/outcome` creates tracker + rows for applications made outside the workflow with no fit evaluation, so their + `fit_rating` is blank, and Step 3.3's `(100 - fit_rating) / 100` had no rule for that. + The naive blank-as-0 reading yields weight 1.0 (the maximum), letting the one job the + framework knows nothing about dominate the skill-gap heatmap. A blank or non-numeric + `fit_rating` now falls back to a matched ranked entry's `rank_score`, else the row is + skipped, counted, and reported once - mirroring the skill's own missing-`gaps` + handling. Pinned by `tests/test_upskill_skill.py`. - **`/rank`'s expiry sweep parses stored deadlines defensively** - the sweep changes status automatically from a date comparison against values on disk, but portals have shipped non-ISO shapes into `seen_jobs.json` (`"ASAP"`, `DD.MM.YYYY`, free text), and diff --git a/tests/test_upskill_skill.py b/tests/test_upskill_skill.py index 7327fd2..62eaa46 100644 --- a/tests/test_upskill_skill.py +++ b/tests/test_upskill_skill.py @@ -82,6 +82,26 @@ class UpskillSkillSpec(unittest.TestCase): self.assertIn("(100 - fit_rating) / 100", step3) self.assertIn("(100 - rank_score) / 100", step3) + def test_step3_handles_blank_fit_rating(self): + """/outcome creates tracker rows for applications made outside the + workflow, and no rule anywhere fills fit_rating on that path - yet + Step 3.3 divides by it. A naive read of blank as 0 yields weight 1.0 + (the maximum), making the one job the framework knows nothing about + dominate the heatmap. The skill already handles missing gaps with + skip+count+report; the same pattern must cover fit_rating.""" + sections = _sections(SKILL.read_text(encoding="utf-8")) + step3 = sections.get("Step 3: Pass 1 — Hard Skill Diff", "") + self.assertIn( + "blank or non-numeric `fit_rating`", + step3, + "Step 3.3 must state what happens to a row whose fit_rating is blank", + ) + self.assertIn( + "Never treat a blank as 0", + step3, + "the blank-as-0 reading (weight 1.0, maximum) is the failure mode and must be forbidden explicitly", + ) + def test_step5_heatmap_shows_gap_provenance(self): sections = _sections(SKILL.read_text(encoding="utf-8")) step5 = sections.get("Step 5: Build Gap Heatmap", "")