mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-02 13:12:50 +00:00
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:
3 files changed
+62
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user