diff --git a/apps/mana/apps/web/src/lib/modules/augur/lib/calibration.test.ts b/apps/mana/apps/web/src/lib/modules/augur/lib/calibration.test.ts new file mode 100644 index 000000000..b1ee4834f --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/lib/calibration.test.ts @@ -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 { + 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']); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/augur/lib/correlation-engine.test.ts b/apps/mana/apps/web/src/lib/modules/augur/lib/correlation-engine.test.ts new file mode 100644 index 000000000..bbc1b33fe --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/lib/correlation-engine.test.ts @@ -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 { + 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) + ); + } + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/augur/lib/living-oracle.test.ts b/apps/mana/apps/web/src/lib/modules/augur/lib/living-oracle.test.ts new file mode 100644 index 000000000..61b323c4c --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/lib/living-oracle.test.ts @@ -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 { + 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%'); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/augur/lib/reminders.test.ts b/apps/mana/apps/web/src/lib/modules/augur/lib/reminders.test.ts new file mode 100644 index 000000000..542305fd3 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/lib/reminders.test.ts @@ -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; + +function entry(overrides: Partial = {}): 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'); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/augur/lib/year-recap.test.ts b/apps/mana/apps/web/src/lib/modules/augur/lib/year-recap.test.ts new file mode 100644 index 000000000..d26c31f39 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/augur/lib/year-recap.test.ts @@ -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 { + 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'); + }); +});