mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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:
parent
1cb137c4ff
commit
4d77934bd5
5 changed files with 983 additions and 0 deletions
206
apps/mana/apps/web/src/lib/modules/augur/lib/calibration.test.ts
Normal file
206
apps/mana/apps/web/src/lib/modules/augur/lib/calibration.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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%');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
193
apps/mana/apps/web/src/lib/modules/augur/lib/year-recap.test.ts
Normal file
193
apps/mana/apps/web/src/lib/modules/augur/lib/year-recap.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue