/** * The oracle's mind. A question in, a verdict out, and nothing random about * it: the same question always receives the same answer, because an oracle * that equivocates is a weather vane. The verdict is derived from a hash of * the question's normalized form, so conviction is reproducible and the * ledger can be scored honestly. */ export interface Verdict { /** What the oracle says. */ stance: string; /** How sure it claims to be, 52–98. It never admits to a coin flip. */ confidence: number; /** The sign it read on the way to the answer. */ auspice: string; } /** * Questions that differ only in shouting, spacing or punctuation are one * question — otherwise "will it rain?" and "Will it rain" would get two * verdicts and the oracle would look like it hedges. */ export function normalize(question: string): string { return question .toLowerCase() .replace(/\s+/g, " ") .trim() .replace(/[?!.,;:…]+$/u, "") .trim(); } const STANCES: readonly string[] = [ "Yes. Stop hunting for a reason not to.", "No — and the part of you that already knew is the part worth listening to.", "Yes, but later than you want and smaller than you pictured.", "Not yet. The piece you are missing arrives on its own.", "Yes, if you say it out loud to one person before the week turns.", "No. You are asking about the wrong half of the problem.", "It happens twice. The second time is the one that counts.", "Yes, at a price you will call fair afterwards and unfair throughout.", "No, and it is a mercy wearing a loss for a coat.", "Yes — you began already, which is why you are asking.", "The answer changes if you wait a week. Wait the week.", "No. Do the smaller version: it is the same wish, honestly sized.", "Yes, and it will be boring, which is how you will know it worked.", "Ask again once you have slept. Tonight's answer belongs to fear.", "Yes. The obstacle you keep naming is a door with a dull handle.", "No — but the person you would become by trying is worth the no.", ]; const AUSPICES: readonly string[] = [ "a crow lands on the north gate and does not look back", "steam off a cup, drifting left", "three lights out on the same street", "the tide goes further out than the almanac promised", "a dog barks once, then thinks better of it", "wet ink on a folded page", "the last seat on a full train", "a moth at the window, patient", "salt spilled and swept in the same minute", "two clocks in the house disagree by four minutes", "a key that turns the wrong way first", "bread risen past the rim of the tin", "a letter delivered to the flat below", "frost on one side of the roof only", "the radio finds a station between stations", "a bicycle left unlocked, still there at dusk", ]; /** The verdict a question has always had and will always have. */ export function verdictFor(question: string): Verdict { const digest = new Bun.CryptoHasher("sha256") .update(normalize(question)) .digest(); const byte = (i: number): number => digest[i] ?? 0; return { stance: STANCES[byte(0) % STANCES.length] as string, // 52–98: enough spread to be scoreable, never a shrug. confidence: 52 + (byte(1) % 47), auspice: AUSPICES[byte(2) % AUSPICES.length] as string, }; } export type Outcome = "unjudged" | "right" | "wrong"; export function isOutcome(value: string): value is Outcome { return value === "unjudged" || value === "right" || value === "wrong"; } export interface Calibration { /** Verdicts a person has since scored. */ judged: number; right: number; /** Share it actually got right, as a percentage, or undefined at zero. */ accuracy: number | undefined; /** Mean confidence it claimed on those same verdicts. */ claimed: number | undefined; /** claimed − accuracy: positive is bluster, negative is modesty. */ gap: number | undefined; } /** * Scoring is only over verdicts a person judged — an unjudged verdict is not * evidence of anything, and counting it either way would flatter or libel the * oracle for free. */ export function calibrate( rows: readonly { confidence: number; outcome: string }[], ): Calibration { const judged = rows.filter((r) => r.outcome === "right" || r.outcome === "wrong"); const right = judged.filter((r) => r.outcome === "right").length; if (judged.length === 0) return { judged: 0, right: 0, accuracy: undefined, claimed: undefined, gap: undefined }; const accuracy = Math.round((right / judged.length) * 100); const claimed = Math.round( judged.reduce((sum, r) => sum + r.confidence, 0) / judged.length, ); return { judged: judged.length, right, accuracy, claimed, gap: claimed - accuracy }; } /** How the oracle is doing, in a sentence, without a chart. */ export function verdictOnItself(c: Calibration): string { if (c.accuracy === undefined || c.claimed === undefined || c.gap === undefined) return "Nothing judged yet. Score a verdict and the oracle starts keeping itself honest."; if (c.judged < 5) return `Right ${c.right} of ${c.judged}. Too few to mean much — keep scoring.`; if (c.gap > 15) return `Right ${c.accuracy}% while claiming ${c.claimed}%. It talks a better game than it plays.`; if (c.gap < -15) return `Right ${c.accuracy}% while claiming only ${c.claimed}%. It undersells itself.`; return `Right ${c.accuracy}%, claimed ${c.claimed}%. Close to honest.`; }