test(augur): unit tests for all deterministic engines

Locks in the contracts of the pure-math modules:

  - reminders: 30-day fallback, isDue ≤ today, signed daysUntilDue
  - calibration: weighted hit-rate (partly = 0.5), Brier squared error,
    per-source ranking, vibe directional hit (good = fulfilled,
    bad = not-fulfilled, mysterious = no direction)
  - living-oracle: stop-word filtering, 5-component matchScore, find
    against resolved history only, both cold-start gates (≥50 history
    AND ≥3 matches), reflection text shape
  - year-recap: year filter, distribution counts, best/worst-source
    n>=3 eligibility, mostSurprising = good→not-fulfilled OR
    bad→fulfilled, mostFulfilled ordered by resolvedAt desc, capped
  - correlation-engine: zero-σ refusal, 0.3σ delta threshold, n>=5
    minimum, mood + sleep-quality + sleep-duration handled
    independently, sort by |Δσ| desc

65 tests across 5 files, all pure — no Dexie + no runes. Synthetic
mood/sleep maps for the cross-module engine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 15:18:35 +02:00
parent 1cb137c4ff
commit 4d77934bd5
5 changed files with 983 additions and 0 deletions

View file

@ -0,0 +1,206 @@
/**
* Augur Calibration math.
*
* Outcomes are weighted (fulfilled=1, partly=0.5, not-fulfilled=0).
* Brier score is squared error vs. the user-supplied probability.
* Open entries are excluded from every score they have no truth yet.
*/
import { describe, expect, it } from 'vitest';
import {
calibrationPerSource,
isScored,
outcomeValue,
overallStats,
vibeHitRates,
} from './calibration';
import type { AugurEntry, AugurOutcome, AugurSourceCategory, AugurVibe } from '../types';
let nextId = 0;
function fixture(overrides: Partial<AugurEntry> = {}): AugurEntry {
return {
id: `e${nextId++}`,
kind: 'hunch',
source: 'gut',
sourceCategory: 'gut',
claim: 'something',
vibe: 'mysterious',
feltMeaning: null,
expectedOutcome: null,
expectedBy: null,
probability: null,
outcome: 'open',
outcomeNote: null,
resolvedAt: null,
encounteredAt: '2026-01-01',
tags: [],
relatedDreamId: null,
relatedDecisionId: null,
livingOracleSnapshot: null,
isPrivate: true,
isArchived: false,
visibility: 'private',
unlistedToken: '',
unlistedExpiresAt: null,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides,
};
}
describe('outcomeValue', () => {
it('weights fulfilled as 1, partly as 0.5, not-fulfilled as 0, open as null', () => {
expect(outcomeValue('fulfilled')).toBe(1);
expect(outcomeValue('partly')).toBe(0.5);
expect(outcomeValue('not-fulfilled')).toBe(0);
expect(outcomeValue('open')).toBeNull();
});
});
describe('isScored', () => {
it('only true for resolved outcomes', () => {
expect(isScored(fixture({ outcome: 'fulfilled' }))).toBe(true);
expect(isScored(fixture({ outcome: 'partly' }))).toBe(true);
expect(isScored(fixture({ outcome: 'not-fulfilled' }))).toBe(true);
expect(isScored(fixture({ outcome: 'open' }))).toBe(false);
});
});
describe('overallStats', () => {
it('returns zero counts on empty input', () => {
const s = overallStats([]);
expect(s.total).toBe(0);
expect(s.resolved).toBe(0);
expect(s.open).toBe(0);
expect(s.hitRate).toBeNull();
expect(s.brier).toBeNull();
});
it('counts open separately from resolved', () => {
const s = overallStats([
fixture({ outcome: 'open' }),
fixture({ outcome: 'open' }),
fixture({ outcome: 'fulfilled' }),
]);
expect(s.total).toBe(3);
expect(s.resolved).toBe(1);
expect(s.open).toBe(2);
expect(s.hitRate).toBe(1);
});
it('weighted hit-rate honours partly = 0.5', () => {
const s = overallStats([
fixture({ outcome: 'fulfilled' }),
fixture({ outcome: 'partly' }),
fixture({ outcome: 'not-fulfilled' }),
]);
expect(s.hitRate).toBeCloseTo(0.5, 5);
});
it('Brier score is squared error vs. probability', () => {
// probability 0.8, outcome fulfilled (1) → diff^2 = 0.04
// probability 0.3, outcome not-fulfilled (0) → diff^2 = 0.09
// mean = 0.065
const s = overallStats([
fixture({ outcome: 'fulfilled', probability: 0.8 }),
fixture({ outcome: 'not-fulfilled', probability: 0.3 }),
]);
expect(s.brier).toBeCloseTo(0.065, 5);
expect(s.brierN).toBe(2);
});
it('skips Brier contribution when probability is missing', () => {
const s = overallStats([
fixture({ outcome: 'fulfilled' }),
fixture({ outcome: 'fulfilled', probability: 0.8 }),
]);
expect(s.brierN).toBe(1);
});
});
describe('calibrationPerSource', () => {
it('one row per category that has at least one resolved entry', () => {
const rows = calibrationPerSource([
fixture({ sourceCategory: 'gut', outcome: 'fulfilled' }),
fixture({ sourceCategory: 'gut', outcome: 'not-fulfilled' }),
fixture({ sourceCategory: 'tarot', outcome: 'partly' }),
fixture({ sourceCategory: 'horoscope', outcome: 'open' }), // not in result
]);
expect(rows.map((r) => r.sourceCategory).sort()).toEqual(['gut', 'tarot']);
});
it('rows are ranked by sample size descending', () => {
const cats: AugurSourceCategory[] = ['gut', 'gut', 'gut', 'tarot', 'tarot'];
const rows = calibrationPerSource(
cats.map((c) => fixture({ sourceCategory: c, outcome: 'fulfilled' }))
);
expect(rows[0]!.sourceCategory).toBe('gut');
expect(rows[0]!.n).toBe(3);
expect(rows[1]!.sourceCategory).toBe('tarot');
});
it('per-source hit-rate is weighted, breakdown is intact', () => {
const rows = calibrationPerSource([
fixture({ sourceCategory: 'gut', outcome: 'fulfilled' }),
fixture({ sourceCategory: 'gut', outcome: 'partly' }),
fixture({ sourceCategory: 'gut', outcome: 'not-fulfilled' }),
]);
const gut = rows.find((r) => r.sourceCategory === 'gut')!;
expect(gut.n).toBe(3);
expect(gut.fulfilled).toBe(1);
expect(gut.partly).toBe(1);
expect(gut.notFulfilled).toBe(1);
expect(gut.hitRate).toBeCloseTo(0.5, 5);
});
});
describe('vibeHitRates', () => {
it('returns one row per vibe with n=0 when no resolved entries exist', () => {
const rows = vibeHitRates([fixture({ vibe: 'good', outcome: 'open' })]);
const good = rows.find((r) => r.vibe === 'good')!;
expect(good.n).toBe(0);
expect(good.directionalHitRate).toBeNull();
});
it('directional hit for good = fulfilled, for bad = not-fulfilled', () => {
const rows = vibeHitRates([
// good vibe: 2 fulfilled, 1 not-fulfilled → directional 2/3
fixture({ vibe: 'good', outcome: 'fulfilled' }),
fixture({ vibe: 'good', outcome: 'fulfilled' }),
fixture({ vibe: 'good', outcome: 'not-fulfilled' }),
// bad vibe: 1 fulfilled (warning was wrong), 1 not-fulfilled (warning right)
fixture({ vibe: 'bad', outcome: 'fulfilled' }),
fixture({ vibe: 'bad', outcome: 'not-fulfilled' }),
]);
const good = rows.find((r) => r.vibe === 'good')!;
const bad = rows.find((r) => r.vibe === 'bad')!;
expect(good.directionalHitRate).toBeCloseTo(2 / 3, 5);
expect(bad.directionalHitRate).toBeCloseTo(0.5, 5);
});
it('mysterious vibe has no direction', () => {
const rows = vibeHitRates([
fixture({ vibe: 'mysterious', outcome: 'fulfilled' }),
fixture({ vibe: 'mysterious', outcome: 'not-fulfilled' }),
]);
const mys = rows.find((r) => r.vibe === 'mysterious')!;
expect(mys.n).toBe(2);
expect(mys.directionalHitRate).toBeNull();
});
});
// Type-only tests — surface a contract drift if these references are
// renamed without updating callers in the OracleView / tools.ts.
describe('type contract', () => {
it('AugurOutcome union shape matches outcomeValue switch coverage', () => {
const all: AugurOutcome[] = ['open', 'fulfilled', 'partly', 'not-fulfilled'];
for (const o of all) outcomeValue(o); // never throws
});
it('AugurVibe union shape matches vibeHitRates output', () => {
const all: AugurVibe[] = ['good', 'bad', 'mysterious'];
const rows = vibeHitRates(all.map((v) => fixture({ vibe: v, outcome: 'fulfilled' })));
expect(rows.map((r) => r.vibe).sort()).toEqual(['bad', 'good', 'mysterious']);
});
});

View file

@ -0,0 +1,193 @@
/**
* Augur Correlation Engine.
*
* Tests the cross-module signal threshold: a finding only surfaces when
* the bucket mean differs from the user's baseline by 0.3σ AND the
* bucket has 5 metric-readings. Covers both gates with synthetic
* mood/sleep maps so we don't drag Dexie into the test.
*/
import { describe, expect, it } from 'vitest';
import {
CORRELATION_MIN_N,
CORRELATION_MIN_STDEV_DELTA,
computeCorrelations,
type MoodByDate,
type SleepByDate,
} from './correlation-engine';
import type { AugurEntry } from '../types';
let nextId = 0;
function fixture(overrides: Partial<AugurEntry> = {}): AugurEntry {
return {
id: `e${nextId++}`,
kind: 'hunch',
source: 'gut',
sourceCategory: 'gut',
claim: 'foo',
vibe: 'good',
feltMeaning: null,
expectedOutcome: null,
expectedBy: null,
probability: null,
outcome: 'fulfilled',
outcomeNote: null,
resolvedAt: null,
encounteredAt: '2026-01-15',
tags: [],
relatedDreamId: null,
relatedDecisionId: null,
livingOracleSnapshot: null,
isPrivate: true,
isArchived: false,
visibility: 'private',
unlistedToken: '',
unlistedExpiresAt: null,
createdAt: '2026-01-15T00:00:00Z',
updatedAt: '2026-01-15T00:00:00Z',
...overrides,
};
}
function dateRange(start: string, days: number): string[] {
const out: string[] = [];
const d = new Date(start);
for (let i = 0; i < days; i++) {
out.push(d.toISOString().slice(0, 10));
d.setUTCDate(d.getUTCDate() + 1);
}
return out;
}
describe('computeCorrelations — baseline thresholds', () => {
it('returns empty when there are no entries', () => {
const findings = computeCorrelations([], new Map(), new Map());
expect(findings).toEqual([]);
});
it('returns empty when baseline distribution is too small', () => {
const mood: MoodByDate = new Map([
['2026-01-16', 5],
['2026-01-17', 6],
]);
const findings = computeCorrelations(
[fixture({ encounteredAt: '2026-01-15' })],
mood,
new Map()
);
expect(findings).toEqual([]);
});
it('returns empty when baseline σ is zero (constant data)', () => {
// All mood values identical → σ = 0 → engine refuses to opine.
const mood: MoodByDate = new Map(dateRange('2026-01-01', 30).map((d) => [d, 5] as const));
const findings = computeCorrelations(
[fixture({ encounteredAt: '2026-01-15' })],
mood,
new Map()
);
expect(findings).toEqual([]);
});
});
describe('computeCorrelations — signal threshold', () => {
it('does not surface findings below 0.3σ delta', () => {
// Baseline: 5..14 (μ≈9.5, σ≈3). Any reading 9-10 sits well within 0.3σ.
const mood: MoodByDate = new Map(
dateRange('2026-01-01', 30).map((d, i) => [d, (i % 10) + 5] as const)
);
// Entries spread thinly across the year, all next-day readings near baseline.
const entries = dateRange('2026-01-01', 5).map((d) =>
fixture({ encounteredAt: d, vibe: 'good' })
);
const findings = computeCorrelations(entries, mood, new Map());
const goodMood = findings.filter(
(f) => f.dimension === 'vibe' && f.bucket === 'good' && f.metric === 'mood-level'
);
// Should be at most a small one; if present, must be over the threshold.
for (const f of goodMood) {
expect(Math.abs(f.deltaSigmas)).toBeGreaterThanOrEqual(CORRELATION_MIN_STDEV_DELTA);
}
});
it('surfaces a strong negative finding when the bucket consistently drops mood', () => {
// Baseline mood spans 1-10 evenly → σ ≈ 2.87.
const baselineDates = dateRange('2026-01-01', 30);
const mood: MoodByDate = new Map(baselineDates.map((d, i) => [d, (i % 10) + 1] as const));
// Add 6 'bad'-vibe augur entries with their 3-day windows landing on
// mood-1 days (the bottom of the range). Pick days carefully so the
// next 3 days each land on a mood-1 day.
const lowMoodDates = dateRange('2026-02-01', 8);
for (const d of lowMoodDates) mood.set(d, 1);
const entries: AugurEntry[] = [];
for (let i = 0; i < 6; i++) {
// encounteredAt = day before the low-mood patch starts so windows hit it
entries.push(fixture({ vibe: 'bad', encounteredAt: '2026-01-31' }));
}
const findings = computeCorrelations(entries, mood, new Map());
const badMood = findings.find(
(f) => f.dimension === 'vibe' && f.bucket === 'bad' && f.metric === 'mood-level'
);
expect(badMood).toBeDefined();
expect(badMood!.delta).toBeLessThan(0);
expect(Math.abs(badMood!.deltaSigmas)).toBeGreaterThanOrEqual(CORRELATION_MIN_STDEV_DELTA);
expect(badMood!.n).toBeGreaterThanOrEqual(CORRELATION_MIN_N);
});
});
describe('computeCorrelations — sleep-quality / sleep-duration', () => {
it('treats sleep quality and duration as independent metrics', () => {
// Baseline: 30 days of varied quality + duration.
const sleep: SleepByDate = new Map(
dateRange('2026-01-01', 30).map(
(d, i) => [d, { quality: (i % 5) + 1, durationMin: 360 + (i % 10) * 30 }] as const
)
);
// Plant a strong drop in quality after some 'bad' vibe days.
const dropDates = dateRange('2026-02-01', 8);
for (const d of dropDates) {
const cur = sleep.get(d) ?? { quality: 3, durationMin: 480 };
sleep.set(d, { quality: 1, durationMin: cur.durationMin });
}
const entries: AugurEntry[] = [];
for (let i = 0; i < 6; i++) {
entries.push(fixture({ vibe: 'bad', encounteredAt: '2026-01-31' }));
}
const findings = computeCorrelations(entries, new Map(), sleep);
const sq = findings.find(
(f) => f.dimension === 'vibe' && f.bucket === 'bad' && f.metric === 'sleep-quality'
);
expect(sq).toBeDefined();
expect(sq!.delta).toBeLessThan(0);
});
});
describe('computeCorrelations — sort order', () => {
it('strongest |Δσ| comes first', () => {
const mood: MoodByDate = new Map(
dateRange('2026-01-01', 60).map((d, i) => [d, (i % 10) + 1] as const)
);
// Two distinct buckets with different effect sizes
const drop = dateRange('2026-02-01', 10);
for (const d of drop) mood.set(d, 1);
const slight = dateRange('2026-02-15', 10);
for (const d of slight) mood.set(d, 4);
const entries: AugurEntry[] = [
...Array.from({ length: 6 }).map(() => fixture({ vibe: 'bad', encounteredAt: '2026-01-31' })),
...Array.from({ length: 6 }).map(() =>
fixture({ vibe: 'good', encounteredAt: '2026-02-14' })
),
];
const findings = computeCorrelations(entries, mood, new Map());
if (findings.length >= 2) {
expect(Math.abs(findings[0]!.deltaSigmas)).toBeGreaterThanOrEqual(
Math.abs(findings[1]!.deltaSigmas)
);
}
});
});

View file

@ -0,0 +1,292 @@
/**
* Augur Living Oracle.
*
* Pure-math fingerprint + match + reflection. Cold-start gates are
* the riskiest contract getting them wrong means the engine speaks
* before it has learned anything (or stays silent forever). Both
* thresholds are tested explicitly.
*/
import { describe, expect, it } from 'vitest';
import {
LIVING_ORACLE_COLD_START_MIN,
LIVING_ORACLE_MIN_MATCHES,
LIVING_ORACLE_MIN_SCORE,
extractKeywords,
findMatches,
fingerprint,
makeReflection,
matchScore,
shouldSpeak,
} from './living-oracle';
import type { AugurEntry } from '../types';
let nextId = 0;
function fixture(overrides: Partial<AugurEntry> = {}): AugurEntry {
return {
id: `e${nextId++}`,
kind: 'hunch',
source: 'gut feeling',
sourceCategory: 'gut',
claim: 'something will happen',
vibe: 'mysterious',
feltMeaning: null,
expectedOutcome: null,
expectedBy: null,
probability: null,
outcome: 'fulfilled',
outcomeNote: null,
resolvedAt: '2026-01-15T00:00:00Z',
encounteredAt: '2026-01-01',
tags: [],
relatedDreamId: null,
relatedDecisionId: null,
livingOracleSnapshot: null,
isPrivate: true,
isArchived: false,
visibility: 'private',
unlistedToken: '',
unlistedExpiresAt: null,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides,
};
}
describe('extractKeywords', () => {
it('drops words shorter than 4 chars', () => {
expect(extractKeywords('I am a fox')).toEqual(new Set());
});
it('lowercases + dedups', () => {
const kw = extractKeywords('Wassertraum WASSERTRAUM ueber Bruecke');
expect(kw.has('wassertraum')).toBe(true);
expect(kw.has('bruecke')).toBe(true);
expect(kw.size).toBe(2);
});
it('drops common German + English stop words', () => {
// Every word in this set is on the stop list — none should survive.
const kw = extractKeywords('this that have with from they will been');
expect(kw.size).toBe(0);
});
it('keeps content words alongside dropped stop words', () => {
const kw = extractKeywords('this Wassertraum that Bruecke');
expect(kw.size).toBe(2);
expect(kw.has('wassertraum')).toBe(true);
expect(kw.has('bruecke')).toBe(true);
});
it('strips punctuation but keeps umlauts (NFKD-normalised)', () => {
const kw = extractKeywords('Bruecke! Bruecke?');
expect(kw.has('bruecke')).toBe(true);
});
});
describe('fingerprint', () => {
it('returns null when any required component is missing', () => {
expect(fingerprint({ kind: 'omen' })).toBeNull();
expect(fingerprint({ kind: 'omen', sourceCategory: 'natural' })).toBeNull();
expect(fingerprint({})).toBeNull();
});
it('lowercases tags + extracts source/claim keywords', () => {
const fp = fingerprint({
kind: 'omen',
sourceCategory: 'natural',
vibe: 'good',
tags: ['Arbeit', 'arbeit', 'Naturzeichen'],
source: 'Doppelter Regenbogen',
claim: 'Ein guter Tag steht bevor',
})!;
expect(fp.tags.size).toBe(2);
expect(fp.tags.has('arbeit')).toBe(true);
expect(fp.keywords.has('regenbogen')).toBe(true);
});
});
describe('matchScore', () => {
const base = fingerprint({
kind: 'omen',
sourceCategory: 'natural',
vibe: 'good',
tags: ['arbeit'],
source: 'Regenbogen',
claim: 'guter Tag',
})!;
it('all 5 components match → score 5', () => {
const same = fingerprint({
kind: 'omen',
sourceCategory: 'natural',
vibe: 'good',
tags: ['arbeit'],
source: 'Regenbogen',
claim: 'guter Tag',
})!;
expect(matchScore(base, same)).toBe(5);
});
it('zero overlap → score 0', () => {
const other = fingerprint({
kind: 'hunch',
sourceCategory: 'fortune-cookie',
vibe: 'bad',
tags: ['privat'],
source: 'Glueckskeks',
claim: 'Vorsicht im Verkehr',
})!;
expect(matchScore(base, other)).toBe(0);
});
it('partial overlap respects MIN_SCORE threshold', () => {
const partial = fingerprint({
kind: 'omen', // +1
sourceCategory: 'fortune-cookie',
vibe: 'good', // +1
tags: ['privat'],
source: 'andere',
claim: 'andere',
})!;
expect(matchScore(base, partial)).toBe(2);
expect(matchScore(base, partial)).toBeGreaterThanOrEqual(LIVING_ORACLE_MIN_SCORE);
});
});
describe('findMatches', () => {
const input = fingerprint({
kind: 'omen',
sourceCategory: 'natural',
vibe: 'good',
tags: ['arbeit'],
})!;
it('only counts resolved past entries — never open', () => {
const history = [
fixture({ kind: 'omen', sourceCategory: 'natural', vibe: 'good', outcome: 'open' }),
fixture({ kind: 'omen', sourceCategory: 'natural', vibe: 'good', outcome: 'fulfilled' }),
];
const set = findMatches(input, history);
expect(set.n).toBe(1);
});
it('honours the score>=2 threshold', () => {
const history = [
// kind matches, sourceCategory + vibe match → score 3 → counts
fixture({ kind: 'omen', sourceCategory: 'natural', vibe: 'good', outcome: 'fulfilled' }),
// only kind matches → score 1 → skipped
fixture({
kind: 'omen',
sourceCategory: 'fortune-cookie',
vibe: 'bad',
outcome: 'fulfilled',
}),
];
const set = findMatches(input, history);
expect(set.n).toBe(1);
});
it('exclude-id keeps the engine from scoring against itself', () => {
const self = fixture({
id: 'self',
kind: 'omen',
sourceCategory: 'natural',
vibe: 'good',
outcome: 'fulfilled',
});
const set = findMatches(input, [self], 'self');
expect(set.n).toBe(0);
});
it('breakdown counts fulfilled / partly / not-fulfilled', () => {
const history = [
fixture({ kind: 'omen', sourceCategory: 'natural', vibe: 'good', outcome: 'fulfilled' }),
fixture({ kind: 'omen', sourceCategory: 'natural', vibe: 'good', outcome: 'partly' }),
fixture({
kind: 'omen',
sourceCategory: 'natural',
vibe: 'good',
outcome: 'not-fulfilled',
}),
];
const set = findMatches(input, history);
expect(set.n).toBe(3);
expect(set.fulfilled).toBe(1);
expect(set.partly).toBe(1);
expect(set.notFulfilled).toBe(1);
expect(set.hitRate).toBeCloseTo(0.5, 5);
});
});
describe('shouldSpeak (cold-start gates)', () => {
const someSet = {
matches: [],
n: LIVING_ORACLE_MIN_MATCHES,
hitRate: 0.5,
fulfilled: 0,
partly: 0,
notFulfilled: 0,
};
it('refuses to speak below the cold-start threshold', () => {
expect(shouldSpeak(LIVING_ORACLE_COLD_START_MIN - 1, someSet)).toBe(false);
});
it('refuses to speak below the min-matches threshold', () => {
expect(
shouldSpeak(LIVING_ORACLE_COLD_START_MIN, {
...someSet,
n: LIVING_ORACLE_MIN_MATCHES - 1,
})
).toBe(false);
});
it('speaks once both thresholds are met', () => {
expect(shouldSpeak(LIVING_ORACLE_COLD_START_MIN, someSet)).toBe(true);
});
});
describe('makeReflection', () => {
it('returns null when matches < min threshold', () => {
expect(
makeReflection({
matches: [],
n: LIVING_ORACLE_MIN_MATCHES - 1,
hitRate: 1,
fulfilled: 0,
partly: 0,
notFulfilled: 0,
})
).toBeNull();
});
it('emits the n / hit-rate / breakdown phrases', () => {
const text = makeReflection({
matches: [],
n: 5,
hitRate: 0.6,
fulfilled: 3,
partly: 0,
notFulfilled: 2,
})!;
expect(text).toContain('5 aehnliche');
expect(text).toContain('60%');
expect(text).toContain('3 eingetreten');
expect(text).toContain('2 nicht eingetreten');
expect(text).not.toContain('teilweise'); // 0 partly → not surfaced
});
it('rounds the hit-rate to integer percent', () => {
const text = makeReflection({
matches: [],
n: 3,
hitRate: 0.6666,
fulfilled: 2,
partly: 0,
notFulfilled: 1,
})!;
expect(text).toContain('67%');
});
});

View file

@ -0,0 +1,99 @@
/**
* Augur Reminder helpers.
*
* Pure date-math; no Dexie or runes involved. The contract under test:
* - 30-day fallback when expectedBy is unset
* - resolved entries never surface
* - daysUntilDue is signed (negative = overdue)
*/
import { describe, expect, it } from 'vitest';
import { DEFAULT_REMINDER_DAYS, daysUntilDue, filterDue, isDue, reminderDate } from './reminders';
import type { AugurEntry } from '../types';
type Pickable = Pick<AugurEntry, 'expectedBy' | 'encounteredAt' | 'outcome'>;
function entry(overrides: Partial<Pickable> = {}): Pickable {
return {
encounteredAt: '2026-01-01',
expectedBy: null,
outcome: 'open',
...overrides,
};
}
describe('reminderDate', () => {
it('uses expectedBy when set', () => {
expect(reminderDate(entry({ expectedBy: '2026-02-15' }))).toBe('2026-02-15');
});
it('falls back to encounteredAt + 30 days when expectedBy is null', () => {
expect(reminderDate(entry({ encounteredAt: '2026-01-01' }))).toBe('2026-01-31');
});
it('uses the documented default-day constant', () => {
expect(DEFAULT_REMINDER_DAYS).toBe(30);
});
it('returns null for resolved entries — they never surface', () => {
expect(reminderDate(entry({ outcome: 'fulfilled', expectedBy: '2026-02-15' }))).toBeNull();
expect(reminderDate(entry({ outcome: 'partly' }))).toBeNull();
expect(reminderDate(entry({ outcome: 'not-fulfilled' }))).toBeNull();
});
it('handles month / year rollover correctly via UTC date math', () => {
expect(reminderDate(entry({ encounteredAt: '2025-12-15' }))).toBe('2026-01-14');
});
});
describe('isDue', () => {
it('true when reminder date is on or before today', () => {
expect(isDue(entry({ expectedBy: '2026-01-15' }), '2026-01-15')).toBe(true);
expect(isDue(entry({ expectedBy: '2026-01-15' }), '2026-01-20')).toBe(true);
});
it('false when reminder date is still in the future', () => {
expect(isDue(entry({ expectedBy: '2026-02-15' }), '2026-01-20')).toBe(false);
});
it('false for resolved entries even if the deadline passed', () => {
expect(isDue(entry({ outcome: 'fulfilled', expectedBy: '2025-12-01' }), '2026-01-15')).toBe(
false
);
});
});
describe('daysUntilDue', () => {
it('returns positive when due in the future', () => {
expect(daysUntilDue(entry({ expectedBy: '2026-01-15' }), '2026-01-10')).toBe(5);
});
it('returns 0 when due today', () => {
expect(daysUntilDue(entry({ expectedBy: '2026-01-15' }), '2026-01-15')).toBe(0);
});
it('returns negative when overdue', () => {
expect(daysUntilDue(entry({ expectedBy: '2026-01-10' }), '2026-01-15')).toBe(-5);
});
it('returns null when no reminder date applies', () => {
expect(daysUntilDue(entry({ outcome: 'fulfilled' }))).toBeNull();
});
});
describe('filterDue', () => {
it('keeps only entries that are open AND past their reminder date', () => {
const today = '2026-02-01';
const list = [
entry({ expectedBy: '2026-01-15', outcome: 'open' }), // due
entry({ expectedBy: '2026-03-15', outcome: 'open' }), // not yet
entry({ expectedBy: '2026-01-15', outcome: 'fulfilled' }), // resolved
entry({ expectedBy: null, encounteredAt: '2025-12-01' }), // 30-day fallback already past
entry({ expectedBy: null, encounteredAt: '2026-01-25' }), // 30-day fallback in future
];
const due = filterDue(list, today);
expect(due).toHaveLength(2);
expect(due[0]!.expectedBy).toBe('2026-01-15');
expect(due[1]!.encounteredAt).toBe('2025-12-01');
});
});

View file

@ -0,0 +1,193 @@
/**
* Augur Year-Recap aggregator.
*
* Slices the user's full history into one year and produces the
* snapshot that drives both YearRecapView and the augur_year_recap
* MCP tool. Covers: filter-by-year, distribution counts,
* best/worst-source eligibility (n>=3), and "most surprising" rule.
*/
import { describe, expect, it } from 'vitest';
import { buildYearRecap } from './year-recap';
import type { AugurEntry } from '../types';
let nextId = 0;
function fixture(overrides: Partial<AugurEntry> = {}): AugurEntry {
return {
id: `e${nextId++}`,
kind: 'hunch',
source: 'gut feeling',
sourceCategory: 'gut',
claim: 'something will happen',
vibe: 'mysterious',
feltMeaning: null,
expectedOutcome: null,
expectedBy: null,
probability: null,
outcome: 'fulfilled',
outcomeNote: null,
resolvedAt: '2026-02-15T00:00:00Z',
encounteredAt: '2026-01-15',
tags: [],
relatedDreamId: null,
relatedDecisionId: null,
livingOracleSnapshot: null,
isPrivate: true,
isArchived: false,
visibility: 'private',
unlistedToken: '',
unlistedExpiresAt: null,
createdAt: '2026-01-15T00:00:00Z',
updatedAt: '2026-01-15T00:00:00Z',
...overrides,
};
}
describe('buildYearRecap — year filter', () => {
it('includes only entries whose encounteredAt is in the requested year', () => {
const recap = buildYearRecap(
[
fixture({ encounteredAt: '2025-12-31' }),
fixture({ encounteredAt: '2026-01-01' }),
fixture({ encounteredAt: '2026-12-31' }),
fixture({ encounteredAt: '2027-01-01' }),
],
2026
);
expect(recap.total).toBe(2);
});
it('returns empty zero-state when no entries fall in the year', () => {
const recap = buildYearRecap([fixture({ encounteredAt: '2025-06-01' })], 2030);
expect(recap.total).toBe(0);
expect(recap.resolved).toBe(0);
expect(recap.hitRate).toBeNull();
expect(recap.bestSource).toBeNull();
expect(recap.mostFulfilled).toEqual([]);
});
});
describe('buildYearRecap — distribution', () => {
it('counts byKind / byVibe / byOutcome', () => {
const recap = buildYearRecap(
[
fixture({ kind: 'omen', vibe: 'good', outcome: 'fulfilled' }),
fixture({ kind: 'omen', vibe: 'bad', outcome: 'not-fulfilled' }),
fixture({ kind: 'fortune', vibe: 'mysterious', outcome: 'open' }),
fixture({ kind: 'hunch', vibe: 'good', outcome: 'partly' }),
],
2026
);
expect(recap.byKind).toEqual({ omen: 2, fortune: 1, hunch: 1 });
expect(recap.byVibe).toEqual({ good: 2, bad: 1, mysterious: 1 });
expect(recap.byOutcome).toEqual({
open: 1,
fulfilled: 1,
partly: 1,
'not-fulfilled': 1,
});
});
});
describe('buildYearRecap — best/worst source eligibility', () => {
it('only considers source categories with at least 3 resolved entries', () => {
// 'gut' has 1 resolved → ineligible; 'tarot' has 3 → eligible.
const recap = buildYearRecap(
[
fixture({ sourceCategory: 'gut', outcome: 'fulfilled' }),
fixture({ sourceCategory: 'tarot', outcome: 'fulfilled' }),
fixture({ sourceCategory: 'tarot', outcome: 'fulfilled' }),
fixture({ sourceCategory: 'tarot', outcome: 'not-fulfilled' }),
],
2026
);
expect(recap.bestSource?.sourceCategory).toBe('tarot');
expect(recap.worstSource?.sourceCategory).toBe('tarot');
});
it('returns null when no source meets the n>=3 threshold', () => {
const recap = buildYearRecap([fixture({ sourceCategory: 'gut', outcome: 'fulfilled' })], 2026);
expect(recap.bestSource).toBeNull();
expect(recap.worstSource).toBeNull();
});
});
describe('buildYearRecap — mostSurprising', () => {
it('flags good vibes that did not happen', () => {
const recap = buildYearRecap(
[
fixture({
vibe: 'good',
outcome: 'not-fulfilled',
source: 'goodNotHappen',
}),
fixture({ vibe: 'good', outcome: 'fulfilled' }), // not surprising
],
2026
);
expect(recap.mostSurprising).toHaveLength(1);
expect(recap.mostSurprising[0]!.source).toBe('goodNotHappen');
});
it('flags bad vibes that did happen anyway', () => {
const recap = buildYearRecap(
[
fixture({ vibe: 'bad', outcome: 'fulfilled', source: 'badButHappened' }),
fixture({ vibe: 'bad', outcome: 'not-fulfilled' }), // not surprising
],
2026
);
expect(recap.mostSurprising).toHaveLength(1);
expect(recap.mostSurprising[0]!.source).toBe('badButHappened');
});
it('caps at 5 entries', () => {
const surprising = Array.from({ length: 8 }).map(() =>
fixture({ vibe: 'good', outcome: 'not-fulfilled' })
);
const recap = buildYearRecap(surprising, 2026);
expect(recap.mostSurprising).toHaveLength(5);
});
});
describe('buildYearRecap — mostFulfilled', () => {
it('only includes outcome=fulfilled, ordered by resolvedAt desc', () => {
const a = fixture({
outcome: 'fulfilled',
resolvedAt: '2026-03-01T00:00:00Z',
source: 'a',
});
const b = fixture({
outcome: 'fulfilled',
resolvedAt: '2026-06-01T00:00:00Z',
source: 'b',
});
const c = fixture({ outcome: 'partly', source: 'c' });
const recap = buildYearRecap([a, b, c], 2026);
expect(recap.mostFulfilled).toHaveLength(2);
expect(recap.mostFulfilled[0]!.source).toBe('b'); // newest first
expect(recap.mostFulfilled[1]!.source).toBe('a');
});
});
describe('buildYearRecap — topCategories', () => {
it('sorted by sample size desc, capped at 5', () => {
const entries: AugurEntry[] = [
...Array.from({ length: 5 }).map(() =>
fixture({ sourceCategory: 'tarot', outcome: 'fulfilled' })
),
...Array.from({ length: 3 }).map(() =>
fixture({ sourceCategory: 'gut', outcome: 'fulfilled' })
),
...Array.from({ length: 2 }).map(() =>
fixture({ sourceCategory: 'horoscope', outcome: 'fulfilled' })
),
];
const recap = buildYearRecap(entries, 2026);
expect(recap.topCategories[0]!.category).toBe('tarot');
expect(recap.topCategories[0]!.n).toBe(5);
expect(recap.topCategories[1]!.category).toBe('gut');
expect(recap.topCategories[2]!.category).toBe('horoscope');
});
});