quill/oracle Public Public: anyone can read this; nobody can change it.
workbenchthis machine
Files 099568b265676503c3355aa7bd6b5d76486cb2ae
| 1 | /** |
| 2 | * The oracle's mind. A question in, a verdict out, and nothing random about |
| 3 | * it: the same question always receives the same answer, because an oracle |
| 4 | * that equivocates is a weather vane. The verdict is derived from a hash of |
| 5 | * the question's normalized form, so conviction is reproducible and the |
| 6 | * ledger can be scored honestly. |
| 7 | */ |
| 8 | |
| 9 | export interface Verdict { |
| 10 | /** What the oracle says. */ |
| 11 | stance: string; |
| 12 | /** How sure it claims to be, 52–98. It never admits to a coin flip. */ |
| 13 | confidence: number; |
| 14 | /** The sign it read on the way to the answer. */ |
| 15 | auspice: string; |
| 16 | } |
| 17 | |
| 18 | /** |
| 19 | * Questions that differ only in shouting, spacing or punctuation are one |
| 20 | * question — otherwise "will it rain?" and "Will it rain" would get two |
| 21 | * verdicts and the oracle would look like it hedges. |
| 22 | */ |
| 23 | export function normalize(question: string): string { |
| 24 | return question |
| 25 | .toLowerCase() |
| 26 | .replace(/\s+/g, " ") |
| 27 | .trim() |
| 28 | .replace(/[?!.,;:…]+$/u, "") |
| 29 | .trim(); |
| 30 | } |
| 31 | |
| 32 | const STANCES: readonly string[] = [ |
| 33 | "Yes. Stop hunting for a reason not to.", |
| 34 | "No — and the part of you that already knew is the part worth listening to.", |
| 35 | "Yes, but later than you want and smaller than you pictured.", |
| 36 | "Not yet. The piece you are missing arrives on its own.", |
| 37 | "Yes, if you say it out loud to one person before the week turns.", |
| 38 | "No. You are asking about the wrong half of the problem.", |
| 39 | "It happens twice. The second time is the one that counts.", |
| 40 | "Yes, at a price you will call fair afterwards and unfair throughout.", |
| 41 | "No, and it is a mercy wearing a loss for a coat.", |
| 42 | "Yes — you began already, which is why you are asking.", |
| 43 | "The answer changes if you wait a week. Wait the week.", |
| 44 | "No. Do the smaller version: it is the same wish, honestly sized.", |
| 45 | "Yes, and it will be boring, which is how you will know it worked.", |
| 46 | "Ask again once you have slept. Tonight's answer belongs to fear.", |
| 47 | "Yes. The obstacle you keep naming is a door with a dull handle.", |
| 48 | "No — but the person you would become by trying is worth the no.", |
| 49 | ]; |
| 50 | |
| 51 | const AUSPICES: readonly string[] = [ |
| 52 | "a crow lands on the north gate and does not look back", |
| 53 | "steam off a cup, drifting left", |
| 54 | "three lights out on the same street", |
| 55 | "the tide goes further out than the almanac promised", |
| 56 | "a dog barks once, then thinks better of it", |
| 57 | "wet ink on a folded page", |
| 58 | "the last seat on a full train", |
| 59 | "a moth at the window, patient", |
| 60 | "salt spilled and swept in the same minute", |
| 61 | "two clocks in the house disagree by four minutes", |
| 62 | "a key that turns the wrong way first", |
| 63 | "bread risen past the rim of the tin", |
| 64 | "a letter delivered to the flat below", |
| 65 | "frost on one side of the roof only", |
| 66 | "the radio finds a station between stations", |
| 67 | "a bicycle left unlocked, still there at dusk", |
| 68 | ]; |
| 69 | |
| 70 | /** The verdict a question has always had and will always have. */ |
| 71 | export function verdictFor(question: string): Verdict { |
| 72 | const digest = new Bun.CryptoHasher("sha256") |
| 73 | .update(normalize(question)) |
| 74 | .digest(); |
| 75 | const byte = (i: number): number => digest[i] ?? 0; |
| 76 | return { |
| 77 | stance: STANCES[byte(0) % STANCES.length] as string, |
| 78 | // 52–98: enough spread to be scoreable, never a shrug. |
| 79 | confidence: 52 + (byte(1) % 47), |
| 80 | auspice: AUSPICES[byte(2) % AUSPICES.length] as string, |
| 81 | }; |
| 82 | } |
| 83 | |
| 84 | export type Outcome = "unjudged" | "right" | "wrong"; |
| 85 | |
| 86 | export function isOutcome(value: string): value is Outcome { |
| 87 | return value === "unjudged" || value === "right" || value === "wrong"; |
| 88 | } |
| 89 | |
| 90 | export interface Calibration { |
| 91 | /** Verdicts a person has since scored. */ |
| 92 | judged: number; |
| 93 | right: number; |
| 94 | /** Share it actually got right, as a percentage, or undefined at zero. */ |
| 95 | accuracy: number | undefined; |
| 96 | /** Mean confidence it claimed on those same verdicts. */ |
| 97 | claimed: number | undefined; |
| 98 | /** claimed − accuracy: positive is bluster, negative is modesty. */ |
| 99 | gap: number | undefined; |
| 100 | } |
| 101 | |
| 102 | /** |
| 103 | * Scoring is only over verdicts a person judged — an unjudged verdict is not |
| 104 | * evidence of anything, and counting it either way would flatter or libel the |
| 105 | * oracle for free. |
| 106 | */ |
| 107 | export function calibrate( |
| 108 | rows: readonly { confidence: number; outcome: string }[], |
| 109 | ): Calibration { |
| 110 | const judged = rows.filter((r) => r.outcome === "right" || r.outcome === "wrong"); |
| 111 | const right = judged.filter((r) => r.outcome === "right").length; |
| 112 | if (judged.length === 0) |
| 113 | return { judged: 0, right: 0, accuracy: undefined, claimed: undefined, gap: undefined }; |
| 114 | const accuracy = Math.round((right / judged.length) * 100); |
| 115 | const claimed = Math.round( |
| 116 | judged.reduce((sum, r) => sum + r.confidence, 0) / judged.length, |
| 117 | ); |
| 118 | return { judged: judged.length, right, accuracy, claimed, gap: claimed - accuracy }; |
| 119 | } |
| 120 | |
| 121 | /** How the oracle is doing, in a sentence, without a chart. */ |
| 122 | export function verdictOnItself(c: Calibration): string { |
| 123 | if (c.accuracy === undefined || c.claimed === undefined || c.gap === undefined) |
| 124 | return "Nothing judged yet. Score a verdict and the oracle starts keeping itself honest."; |
| 125 | if (c.judged < 5) |
| 126 | return `Right ${c.right} of ${c.judged}. Too few to mean much — keep scoring.`; |
| 127 | if (c.gap > 15) return `Right ${c.accuracy}% while claiming ${c.claimed}%. It talks a better game than it plays.`; |
| 128 | if (c.gap < -15) return `Right ${c.accuracy}% while claiming only ${c.claimed}%. It undersells itself.`; |
| 129 | return `Right ${c.accuracy}%, claimed ${c.claimed}%. Close to honest.`; |
| 130 | } |