From 934a5f6fea0d89fde245400a98b164a60048b524 Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:25:26 -0400 Subject: [PATCH] fix(rank): round weighted scores without binary drift (#588) * fix(rank): round weighted scores without binary drift * docs(changelog): record fix for #588 * changelog: place the #588 entry under [Unreleased] Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Mads Lorentzen Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ tests/test_rank_rounding_cli.py | 53 +++++++++++++++++++++++++++++++++ tools/rank_state.py | 10 +++++-- 3 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 tests/test_rank_rounding_cli.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b7abdcc6..5e4c7968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -430,6 +430,8 @@ per-file diff commands. - **Upstream version checks decode Git output as UTF-8** (#587) - future non-Latin framework lines and non-Latin branch names no longer depend on the host code page, guarding a latent case absent from the shipped framework files. +- **`rank_state.py apply` rounds weighted scores exactly** (#588) - decimal half-points such as 31/55/51/96 now produce 60/Good Fit instead of 59/Moderate Fit from binary floating-point drift. + ### Security - **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a diff --git a/tests/test_rank_rounding_cli.py b/tests/test_rank_rounding_cli.py new file mode 100644 index 00000000..e1f32470 --- /dev/null +++ b/tests/test_rank_rounding_cli.py @@ -0,0 +1,53 @@ +"""Documented scoring JSON -> apply CLI -> exact weighted score and verdict.""" +import json +import subprocess +import sys +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +TOOL = Path(__file__).resolve().parent.parent / "tools/rank_state.py" +DIMENSIONS = ("technical", "experience", "behavioral", "career") + + +class ExactRankRoundingCLI(unittest.TestCase): + def apply(self, cases): + with TemporaryDirectory() as directory: + root = Path(directory) + state, results = root / "seen_jobs.json", root / "scores.json" + state.write_text(json.dumps({"seen": {key: { + "title": "SOC Analyst", "company": "Acme", "status": "new", + "url": f"https://example.com/jobs/{key}", "deadline": None, + } for key in cases}}), encoding="utf-8") + results.write_text(json.dumps([{ + "key": key, "status": "scored", "scores": dict(zip(DIMENSIONS, values)), + "location_verdict": "PASS", "language_gate": "PASS", "deadline": None, + } for key, values in cases.items()]), encoding="utf-8") + result = subprocess.run([ + sys.executable, str(TOOL), "apply", "--state", str(state), + "--results", str(results), "--today", "2026-10-16", + ], capture_output=True, text=True, encoding="utf-8") + self.assertEqual(result.returncode, 0, result.stderr) + output = json.loads(result.stdout) + stored = json.loads(state.read_text(encoding="utf-8"))["seen"] + self.assertEqual(output["errors"], []) + actual = {row["key"]: (row["score"], row["verdict"]) for row in output["ranked"]} + self.assertEqual(actual, {key: (value["rank_score"], value["rank_verdict"]) + for key, value in stored.items()}) + return actual + + def test_halfpoint_boundaries_do_not_drop_a_verdict_band(self): + # Integer percent weights give numerators 5950 and 2950 over 100, + # exactly 59.5 and 29.5. The existing nearest/half-up rule yields 60/30. + self.assertEqual(self.apply({ + "good": (31, 55, 51, 96), "weak": (31, 31, 57, 13), + }), {"good": (60, "Good Fit"), "weak": (30, "Weak Fit")}) + + def test_fractional_and_endpoint_controls(self): + self.assertEqual(self.apply({ + "below": (31, 55, 51, 95.9), "above": (31, 55, 51, 96.1), + "fractional": (72.5, 72.5, 72.5, 72.5), + "zero": (0, 0, 0, 0), "hundred": (100, 100, 100, 100), + }), {"below": (59, "Moderate Fit"), "above": (60, "Good Fit"), + "fractional": (73, "Good Fit"), "zero": (0, "Poor Fit"), + "hundred": (100, "Strong Fit")}) diff --git a/tools/rank_state.py b/tools/rank_state.py index 1f03c3d5..47b60527 100644 --- a/tools/rank_state.py +++ b/tools/rank_state.py @@ -45,6 +45,7 @@ import sys import tempfile import unicodedata from datetime import date, datetime, timedelta, timezone +from fractions import Fraction from pathlib import Path ROOT = Path(__file__).resolve().parent.parent @@ -282,15 +283,18 @@ def cmd_sweep(args) -> int: def overall_score(scores: dict) -> int: if not isinstance(scores, dict): raise ValueError("scores must be an object") - total = 0.0 + total = Fraction(0) for dim, weight in WEIGHTS.items(): value = scores.get(dim) if isinstance(value, bool) or not isinstance(value, (int, float)): raise ValueError(f"missing or non-numeric score '{dim}'") if not 0 <= value <= 100: raise ValueError(f"score '{dim}' must be finite and between 0 and 100") - total += float(value) * weight - return int(total + 0.5) + # Scores and documented percentage weights are decimal quantities. + # Binary float summation can turn 59.5 into 59.49999999999999 and drop + # an otherwise valid result into the lower verdict band. + total += Fraction(str(value)) * Fraction(str(weight)) + return int(total + Fraction(1, 2)) def band(score: int) -> str: