fuzzyworld

when-am-i / commit

feat: fairer shuffle — equal per calendar, skip recents

38f135ad6407e1f2cf31ae1928918eae0800707b · Pavel Safronov · 2026-06-20 19:15:41 -0700 · parents 9f3f1e6fedd1

Claude Code usage: this commit ≈0 out / ≈0 cached · cost unknown (no per-model trailer) · session 3 tally 37.2K in / 500.1K out / 63.5M cached · usage by commit and session

feat: fairer shuffle — equal per calendar, skip recents

Rework pickRandomScene so each distinct calendar is equally likely (grouping
candidates by calendar instead of weighting by how many regions/eras use it),
and skip calendars passed in `recent` so consecutive presses don't repeat — with
a fallback to the full set when every calendar is recent. The scene now reports
its calendar so the caller can track recents.

Usage: 37.2K in / 500.1K out / 63.5M cached (session tally)
rtk:   2.2M saved, 98.7% over 277 cmds (global/lifetime)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 src/utils/shuffle.test.ts | 36 ++++++++++++++++++++++++++++++++++++
 src/utils/shuffle.ts      | 43 ++++++++++++++++++++++++++++++++++---------
 2 files changed, 70 insertions(+), 9 deletions(-)

diff --git a/src/utils/shuffle.test.ts b/src/utils/shuffle.test.ts
index 6e3ccd6..0539c5a 100644
--- a/src/utils/shuffle.test.ts
+++ b/src/utils/shuffle.test.ts
@@ -28,6 +28,20 @@ function seq(values: number[]): () => number {
     return () => values[i++ % values.length];
 }
 
+// Two calendars where the first (Julian) is used by *more* eras than the second
+// (Persian) — to prove selection is per-calendar, not weighted by era count.
+const twoCalendars: CalendarRegion[] = [
+    {
+        region: { name: 'A', geo: square },
+        eras: [
+            { name: 'J1', start: '1600-01-01', end: '1650-01-01', calendar: 'Julian' },
+            { name: 'J2', start: '1650-01-01', end: '1700-01-01', calendar: 'Julian' },
+            { name: 'J3', start: '1700-01-01', end: '1750-01-01', calendar: 'Julian' },
+            { name: 'P1', start: '1600-01-01', end: '1700-01-01', calendar: 'Persian' },
+        ],
+    },
+];
+
 describe('pickRandomScene', () => {
     it('returns null when the dataset has only Gregorian (default) eras', () => {
         const onlyGregorian: CalendarRegion[] = [
@@ -41,6 +55,7 @@ describe('pickRandomScene', () => {
         expect(scene).not.toBeNull();
         if (!scene) return;
 
+        expect(scene.calendar).toBe('Julian');
         // The point is inside the region.
         expect(pointInPolygon(scene.lat, scene.lng, square)).toBe(true);
 
@@ -50,6 +65,27 @@ describe('pickRandomScene', () => {
         expect(eras.map((e) => e.calendar)).not.toContain('Gregorian');
     });
 
+    it('weights each calendar equally, not by how many eras use it', () => {
+        // The first rng call selects the calendar from [Julian, Persian]. A low
+        // value picks the first, a high value the second — regardless of the fact
+        // that Julian has 3 eras and Persian only 1.
+        expect(pickRandomScene(twoCalendars, seq([0.1]))?.calendar).toBe('Julian');
+        expect(pickRandomScene(twoCalendars, seq([0.9]))?.calendar).toBe('Persian');
+    });
+
+    it('skips calendars shown recently', () => {
+        // With Julian "recent", only Persian is eligible, so any rng yields it.
+        expect(pickRandomScene(twoCalendars, seq([0.1]), ['Julian'])?.calendar).toBe('Persian');
+        expect(pickRandomScene(twoCalendars, seq([0.9]), ['Julian'])?.calendar).toBe('Persian');
+    });
+
+    it('falls back to the full set when every calendar is recent', () => {
+        // Both calendars recent → still returns one rather than null.
+        const scene = pickRandomScene(twoCalendars, seq([0.1]), ['Julian', 'Persian']);
+        expect(scene).not.toBeNull();
+        expect(['Julian', 'Persian']).toContain(scene?.calendar);
+    });
+
     it('produces dates strictly before a bounded era end (half-open)', () => {
         // rng near 1 should still keep the day before the end boundary.
         const scene = pickRandomScene(regions, seq([0, 0.5, 0.5, 0.999999]));
diff --git a/src/utils/shuffle.ts b/src/utils/shuffle.ts
index fe6c1d4..73f8992 100644
--- a/src/utils/shuffle.ts
+++ b/src/utils/shuffle.ts
@@ -6,6 +6,14 @@
 // region and a random date within one of that region's non-Gregorian eras, so
 // each press surfaces one of the historic reckonings (Islamic, Persian, Julian,
 // a lunar calendar) rather than the everyday Gregorian default.
+//
+// Two fairness rules (so the variety actually feels varied):
+//   1. Each *calendar* is equally likely, regardless of how many regions/eras
+//      use it — otherwise a calendar shared by many regions (e.g. Julian) would
+//      dominate. We pick a calendar first, then a region/era within it.
+//   2. Calendars shown recently are skipped (the caller passes the recent list),
+//      so consecutive presses don't repeat — unless every calendar is recent, in
+//      which case we fall back to the full set rather than return nothing.
 
 import type { CalendarRegion, Era, Geometry, LinearRing } from '../types';
 import { pointInPolygon } from './geo';
@@ -17,6 +25,8 @@ export interface ShuffleScene {
     lat: number;
     lng: number;
     date: PickerDate;
+    /** The calendar this scene lands on (so the caller can avoid repeats). */
+    calendar: string;
 }
 
 // Eras open at one end (null start / null end) still need a finite window to
@@ -73,28 +83,43 @@ function randomDateInEra(era: Era, rng: () => number): PickerDate {
 }
 
 /**
- * Pick a random scene that lands on a non-default (non-Gregorian) calendar:
- * a random point inside some region and a random date within one of that
- * region's non-Gregorian eras. Returns null if the dataset has no such era.
+ * Pick a random scene that lands on a non-default (non-Gregorian) calendar.
+ * Each distinct calendar is equally likely (not weighted by how many regions/
+ * eras use it); calendars in `recent` are skipped so presses don't repeat, with
+ * a fallback to the full set when every calendar is recent. Returns null if the
+ * dataset has no non-Gregorian era at all.
  *
  * `rng` is injectable for deterministic tests; it defaults to Math.random.
  */
 export function pickRandomScene(
     regions: CalendarRegion[],
     rng: () => number = Math.random,
+    recent: readonly string[] = [],
 ): ShuffleScene | null {
-    const candidates: { geo: Geometry; era: Era }[] = [];
+    // Group every non-Gregorian (region, era) candidate by its calendar, so we
+    // can give each calendar equal weight (rule 1).
+    const byCalendar = new Map<string, { geo: Geometry; era: Era }[]>();
     for (const cr of regions) {
         for (const era of cr.eras) {
-            if (era.calendar !== 'Gregorian') {
-                candidates.push({ geo: cr.region.geo, era });
-            }
+            if (era.calendar === 'Gregorian') continue;
+            const list = byCalendar.get(era.calendar) ?? [];
+            list.push({ geo: cr.region.geo, era });
+            byCalendar.set(era.calendar, list);
         }
     }
-    if (candidates.length === 0) return null;
+    const calendars = [...byCalendar.keys()];
+    if (calendars.length === 0) return null;
+
+    // Prefer calendars not shown recently (rule 2); fall back to all of them if
+    // the recent window covers everything, so we always return a scene.
+    const recentSet = new Set(recent);
+    const eligible = calendars.filter((c) => !recentSet.has(c));
+    const pool = eligible.length > 0 ? eligible : calendars;
 
+    const calendar = pool[Math.floor(rng() * pool.length)];
+    const candidates = byCalendar.get(calendar)!;
     const { geo, era } = candidates[Math.floor(rng() * candidates.length)];
     const { lat, lng } = randomPointInGeometry(geo, rng);
     const date = randomDateInEra(era, rng);
-    return { lat, lng, date };
+    return { lat, lng, date, calendar };
 }