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 <noreply@anthropic.com>

---------

Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-21 07:25:26 +02:00
1 parent 6fcf0f045f
commit 934a5f6fea
3 files changed
+62 -3

No files matched your search

+2
View File
@@ -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. - **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 ### Security
- **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a - **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a
+53
View File
@@ -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")})
+7 -3
View File
@@ -45,6 +45,7 @@ import sys
import tempfile import tempfile
import unicodedata import unicodedata
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from fractions import Fraction
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
@@ -282,15 +283,18 @@ def cmd_sweep(args) -> int:
def overall_score(scores: dict) -> int: def overall_score(scores: dict) -> int:
if not isinstance(scores, dict): if not isinstance(scores, dict):
raise ValueError("scores must be an object") raise ValueError("scores must be an object")
total = 0.0 total = Fraction(0)
for dim, weight in WEIGHTS.items(): for dim, weight in WEIGHTS.items():
value = scores.get(dim) value = scores.get(dim)
if isinstance(value, bool) or not isinstance(value, (int, float)): if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"missing or non-numeric score '{dim}'") raise ValueError(f"missing or non-numeric score '{dim}'")
if not 0 <= value <= 100: if not 0 <= value <= 100:
raise ValueError(f"score '{dim}' must be finite and between 0 and 100") raise ValueError(f"score '{dim}' must be finite and between 0 and 100")
total += float(value) * weight # Scores and documented percentage weights are decimal quantities.
return int(total + 0.5) # 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: def band(score: int) -> str: