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.
|
- **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
|
||||||
|
|||||||
@@ -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 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:
|
||||||
|
|||||||
Reference in new issue
Block a user