Skip to content

Commit 5037a31

Browse files
committed
fix(rules): make TEMP-002's date sort a total comparator
`sort((a, b) => (a.iso < b.iso ? -1 : 1))` returns 1 for EQUAL keys, so compare(a,b) and compare(b,a) are both 1 — a self-contradictory comparator. Which of two same-day dates lands at sorted[0] then depends on V8's tie handling for an invalid comparator, and that date supplies the finding's excerpt and position. This was the only such comparator left in src/engine; every other call site already uses a proper three-way or numeric compare. No golden churn: no corpus fixture has two same-day dates in a document that reaches this branch, which is why the latent non-determinism never surfaced. A regression test runs the tied-date fixture 25 times and asserts one distinct result. Also corrects a stale count in a comment: the dark-patterns block in src/engine/rules/index.ts said "— 9" while listing DARK_001..DARK_014. The README figure was fixed earlier in this campaign; the comment beside the code was not.
1 parent 0482245 commit 5037a31

3 files changed

Lines changed: 29 additions & 2 deletions

File tree

src/engine/rules/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ export const LAUNCH_RULES: readonly Rule[] = [
269269
PERS_007,
270270
PERS_008,
271271
PERS_009,
272-
// Dark patterns — 9
272+
// Dark patterns — 14
273273
DARK_001,
274274
DARK_002,
275275
DARK_003,

src/engine/rules/temporal/TEMP-002.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,25 @@ describe("a case citation's date is the opinion's, not the document's (v1.2.0)",
169169
expect(TEMP_002.check(ctx)).toBeNull();
170170
});
171171
});
172+
173+
/**
174+
* The date sort must be a TOTAL comparator. Returning 1 for equal keys makes
175+
* it self-contradictory, and which of two same-day dates lands first then
176+
* depends on V8's tie handling for an invalid comparator — while that date
177+
* supplies the finding's excerpt and position.
178+
*/
179+
describe("TEMP-002 — same-day dates sort deterministically", () => {
180+
const doc = (): ReturnType<typeof buildContext> =>
181+
buildContext([
182+
"Dates",
183+
"Signed January 1, 2020 and also dated 01/01/2020. The term commences March 1, 2026 and ends March 1, 2031.",
184+
]);
185+
186+
it("returns an identical finding across repeated runs", () => {
187+
const results = Array.from({ length: 25 }, () => {
188+
const f = TEMP_002.check(doc());
189+
return f === null ? "null" : `${f.title}|${f.excerpts?.[0]?.text ?? ""}`;
190+
});
191+
expect(new Set(results).size).toBe(1);
192+
});
193+
});

src/engine/rules/temporal/TEMP-002.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,12 @@ export const rule: Rule = {
108108
// (2026-03-01 → 2031-03-01) and a one-year insurance policy period
109109
// (2026-01-01 → 2027-01-01) are not back-dated.
110110
if (abs.length < 3) return null;
111-
const sorted = [...abs].sort((a, b) => (a.iso! < b.iso! ? -1 : 1));
111+
// Three-way compare: returning 1 for EQUAL keys makes the comparator
112+
// self-contradictory (compare(a,b) and compare(b,a) both 1), and which of
113+
// two same-day dates lands at sorted[0] then depends on V8's tie handling
114+
// for an invalid comparator. That date supplies the finding's excerpt and
115+
// position, so the output has to be ordered by a total comparator.
116+
const sorted = [...abs].sort((a, b) => (a.iso! < b.iso! ? -1 : a.iso! > b.iso! ? 1 : 0));
112117
const earliest = new Date(sorted[0]!.iso!);
113118
const next = new Date(sorted[1]!.iso!);
114119
const gapDays = (next.getTime() - earliest.getTime()) / 86_400_000;

0 commit comments

Comments
 (0)