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.
- **`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
+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 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: