From 57e82d2b59ad39176d619c20672802474fe63934 Mon Sep 17 00:00:00 2001 From: Mads Lorentzen Date: Wed, 19 Aug 2026 19:53:29 +0200 Subject: [PATCH] fix(rank): treat non-ISO stored deadlines as absent in urgency and the sweep Rule 6's expiry sweep mutates status automatically from stored deadline values, yet had no rule for the non-ISO shapes portals have shipped into seen_jobs.json ("ASAP", DD.MM.YYYY, free text) - "ASAP" is incomparable and "01.09.2026" is ambiguous between 1 Sep and 9 Jan. /outcome, which merely displays dates, already carried the defensive-parse rule. A non-YYYY-MM-DD stored value is now handled like an absent one (left alone, never compared, never guessed at) and reported once with its portal. Includes the F24-style coupling test. Review finding F17 (2026-08-19). Co-Authored-By: Claude Opus 5 (1M context) --- .claude/commands/rank.md | 4 ++-- CHANGELOG.md | 7 +++++++ tests/test_rank_command.py | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.claude/commands/rank.md b/.claude/commands/rank.md index e7d6e85..caafce5 100644 --- a/.claude/commands/rank.md +++ b/.claude/commands/rank.md @@ -73,8 +73,8 @@ Back in the main context, for each scored job: 2. Map to the framework's verdict bands (Strong Fit 75+, Good Fit 60-74, Moderate Fit 45-59, Weak Fit 30-44, Poor Fit <30). 3. **Location veto:** `FAIL` (e.g. requires relocation) excludes the job from the shortlist no matter the score - list it separately with the reason. `FLAG` (e.g. heavy travel) stays in the ranking but carries a visible ⚠ marker for the user to judge. 4. **Language veto:** `language_gate: FAIL` (posting requires a language the candidate hasn't declared at all) excludes the job from the shortlist, same as a location FAIL - list it under "Excluded" with the quoted requirement from `language_note`. `language_gate: FLAG` (declared language, requirement reads above the declared level) stays in the ranking with a visible ⚠ marker and `language_note` shown alongside the score, same treatment as a location FLAG. -5. **Deadline urgency:** a deadline within 7 days gets a 🔥 marker and wins ties. A deadline that has already passed moves the job to `expired`. Take the deadline from the scoring agent's Step 2 JSON for a job scored in this run, and from the stored `deadline` in `seen_jobs.json` for one that already carries it - a stored value costs no fetch, so urgency is re-derived on every run without re-reading the posting. When both exist and disagree, the freshly scored value wins and replaces the stored one. -6. **Expiry sweep over already-ranked entries.** Before presenting, check the stored `deadline` of every `ranked` entry this run did not re-score. Any whose deadline has passed becomes `expired`; any within 7 days is listed under a short **Closing soon** heading in Step 5 with its 🔥 marker. This needs no fetch and no agent - it is a date comparison against values already on disk, and it is what finally enforces `/scrape`'s "only open positions" rule beyond the moment of fetching. **An entry with no stored `deadline` is left alone, never guessed at** - most entries predate the column, and inferring a deadline from `first_seen` would retire jobs on a date nobody set. `--all` re-scores entries of any status including `expired`, so a job the sweep retired can still be revived by a later `--all` that re-fetches it and finds the posting live: the sweep is reversible, which is what makes an automated status change acceptable here at all. +5. **Deadline urgency:** a deadline within 7 days gets a 🔥 marker and wins ties. A deadline that has already passed moves the job to `expired`. Take the deadline from the scoring agent's Step 2 JSON for a job scored in this run, and from the stored `deadline` in `seen_jobs.json` for one that already carries it - a stored value costs no fetch, so urgency is re-derived on every run without re-reading the posting. When both exist and disagree, the freshly scored value wins and replaces the stored one. A stored value that does not parse as `YYYY-MM-DD` is skipped for urgency as well - rule 6's defensive-parse rule applies wherever a stored deadline is compared. +6. **Expiry sweep over already-ranked entries.** Before presenting, check the stored `deadline` of every `ranked` entry this run did not re-score. Any whose deadline has passed becomes `expired`; any within 7 days is listed under a short **Closing soon** heading in Step 5 with its 🔥 marker. This needs no fetch and no agent - it is a date comparison against values already on disk, and it is what finally enforces `/scrape`'s "only open positions" rule beyond the moment of fetching. **An entry with no stored `deadline` is left alone, never guessed at** - most entries predate the column, and inferring a deadline from `first_seen` would retire jobs on a date nobody set. **Parse stored deadlines defensively:** a stored value that is not a `YYYY-MM-DD` date is treated exactly like an absent one - left alone, never compared, never guessed at - and reported once in the Step 5 summary with its portal, so the bad value gets traced to its source instead of silently steering the sweep (portals have shipped `"ASAP"`, `DD.MM.YYYY`, and free-text deadline shapes into stored data). `--all` re-scores entries of any status including `expired`, so a job the sweep retired can still be revived by a later `--all` that re-fetches it and finds the posting live: the sweep is reversible, which is what makes an automated status change acceptable here at all. Sort by overall score (descending), urgency as tiebreaker. diff --git a/CHANGELOG.md b/CHANGELOG.md index acf4217..565ca4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,13 @@ per-file diff commands. ### Fixed +- **`/rank`'s expiry sweep parses stored deadlines defensively** - the sweep changes + status automatically from a date comparison against values on disk, but portals have + shipped non-ISO shapes into `seen_jobs.json` (`"ASAP"`, `DD.MM.YYYY`, free text), and + `/rank` had no rule for them while the display-only `/outcome` already did. A stored + deadline that is not `YYYY-MM-DD` is now treated exactly like an absent one wherever a + stored deadline is compared (urgency and sweep), and reported once with its portal. + Pinned by `tests/test_rank_command.py`. - **Language Gate preamble no longer claims the gate is untracked** (`framework_version` 1.2.3 -> 1.2.4 in `04-job-evaluation.md`) - the paragraph still said the result "is not a field `/scrape` or `/rank` track", written before the gate was wired into both diff --git a/tests/test_rank_command.py b/tests/test_rank_command.py index dbb8d06..356e805 100644 --- a/tests/test_rank_command.py +++ b/tests/test_rank_command.py @@ -132,6 +132,25 @@ class RankCommandSpec(unittest.TestCase): "stale claim: the gate result IS tracked by /scrape and /rank now", ) + def test_sweep_parses_stored_deadlines_defensively(self): + """Rule 6's expiry sweep mutates status automatically from stored + deadline values, and portals have shipped non-ISO shapes into + seen_jobs.json ("ASAP" from jobindex, DD.MM.YYYY from jobbank, + free text from jobdanmark's detail fallback). /outcome carries a + defensive date-parse rule for mere display; the command that + silently changes state needs one at least as much.""" + text = COMMAND.read_text(encoding="utf-8") + self.assertIn( + "Parse stored deadlines defensively", + text, + "rule 6's sweep must state the defensive-parse rule", + ) + self.assertRegex( + text, + r"not a `YYYY-MM-DD` date[^.]*treated exactly like an absent one", + "a non-ISO stored deadline must be handled as absent, not compared or guessed at", + ) + def test_step2_schema_includes_language_gate_fields(self): sections = _sections(COMMAND.read_text(encoding="utf-8")) step2 = sections.get("Step 2: Batch-Fetch and Score", "")