From 4cb20423bcc27a3e21b7b874a6dbfea8e806472d Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:42:10 +0200 Subject: [PATCH] feat: the model registry and the grounding harness move into the engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phases 1–2 of the fleet AI-engine plan: ai-kit stops being only the transport layer and gains the two modules every adopter re-invented. ai-kit/registry — one SSOT for every callable model id. Two incidents, two invariants: a checker that does not enumerate its subjects cannot report the id it never knew about (eight days of model_not_found, 2026-08-18), so require() throws for anything unregistered and idsForVendor() IS the enumeration a catalog check walks; and three apps silently billed on fallback because a `:free` suffix was the whole paid/free boundary, so paid is a required FIELD and the validator refuses an entry whose flag contradicts its own price or id. freeOnly() is the platform-key guard (unregistered ids are dropped too — an unknown price spends someone's money only when a person decides it does); toolCapable() carries the probe verdicts (native / text / none / unprobed — five of nine probed free models only speak the text protocol). ai-kit/grounding — facts, contract, and the deterministic fabrication check, lifted from fleetcrown/src/lib/agent/core (the canonical copy of the FleetCrown↔OrangeCat mirror). The mirror's README called the duplication "deliberate and temporary" and named this extraction as the exit. Two mechanical deltas from verbatim: `.js` extensions on relative imports (pure ESM) and two null-guards under this package's noUncheckedIndexedAccess. Adopting apps delete their copies and the SHA-256 drift check with them. Both are subpath exports, not root re-exports — the v0.4.0 lesson (one import path per concern) and zero collision with the root export surface. verify green: lint, typecheck, build, 75/75 tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WqKqMnHQHSmkGFfc5t7Rxn --- .gitignore | 1 + package.json | 12 +- src/grounding/contract.ts | 176 ++++++++++++++++++++ src/grounding/facts.ts | 172 +++++++++++++++++++ src/grounding/index.ts | 50 ++++++ src/grounding/verify.ts | 339 ++++++++++++++++++++++++++++++++++++++ src/registry.ts | 213 ++++++++++++++++++++++++ test/grounding.test.js | 72 ++++++++ test/registry.test.js | 91 ++++++++++ 9 files changed, 1124 insertions(+), 2 deletions(-) create mode 100644 src/grounding/contract.ts create mode 100644 src/grounding/facts.ts create mode 100644 src/grounding/index.ts create mode 100644 src/grounding/verify.ts create mode 100644 src/registry.ts create mode 100644 test/grounding.test.js create mode 100644 test/registry.test.js diff --git a/.gitignore b/.gitignore index dd6e803..dba7421 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ *.log .DS_Store +node_modules diff --git a/package.json b/package.json index 3e2910f..8b54d2f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ai-kit", - "version": "0.5.0", - "description": "One install for the AI layer of an app: which model to call, what to do when the vendor retires it, how to walk the fallback chain and know when none of it worked, how to read the three kinds of 429, a fair daily budget across users, and headless AI form filling.", + "version": "0.6.0", + "description": "One install for the AI layer of an app: which model to call, what to do when the vendor retires it, how to walk the fallback chain and know when none of it worked, how to read the three kinds of 429, a fair daily budget across users, headless AI form filling \u2014 and now the model registry (one SSOT for every callable id, with the paid/free boundary as a field) and the grounding harness (facts, contract, deterministic fabrication check).", "license": "MIT", "author": "Mao Nakamoto", "homepage": "https://github.com/bitbaum/ai-kit#readme", @@ -54,6 +54,14 @@ "./server": { "types": "./dist/server.d.ts", "default": "./dist/server.js" + }, + "./registry": { + "types": "./dist/registry.d.ts", + "default": "./dist/registry.js" + }, + "./grounding": { + "types": "./dist/grounding/index.d.ts", + "default": "./dist/grounding/index.js" } }, "scripts": { diff --git a/src/grounding/contract.ts b/src/grounding/contract.ts new file mode 100644 index 0000000..a67e67a --- /dev/null +++ b/src/grounding/contract.ts @@ -0,0 +1,176 @@ +/** + * The grounding contract — the rules block that ships with every turn's facts. + * MIRRORED MODULE (see core/README.md). + * + * Why this is generated rather than a hand-written constant: a standing prose + * rule ("only use provided context") is a weak signal that models trade away + * under format pressure. The failure that motivated this harness was exactly + * that — a prompt demanding "1 focus, 3 tasks, 1 person, under 150 words, no + * hedging" got four confidently-formatted answers, three of them invented, + * against a context block that already said "if a question falls outside this + * context, say so rather than guessing". + * + * The lesson: the model did not disobey a rule it forgot. It obeyed the + * STRONGER of two conflicting instructions — fill five slots — because nothing + * made the empty slot expressible. So this block does three things a static + * prompt cannot: + * + * 1. Names the exact citation handles that exist this turn, so "cite a fact" + * is a closed-set choice rather than free text. + * 2. Names the exact fields that are unrecorded THIS TURN, so the prohibition + * is concrete ("you have no affiliation for any person here") instead of + * abstract. + * 3. Supplies the escape hatch verbatim, so refusing a slot is a cheaper + * token path than inventing one. + */ +import { NOT_RECORDED, unrecordedFields, type Fact } from "./facts.js"; + +/** The exact string the model must emit when a slot cannot be filled. */ +export const NO_BASIS = "Not in your data."; + +/** + * Build the contract for a specific fact set. Empty fact sets get the strictest + * form — with nothing retrieved, EVERY answer must be a refusal, and saying so + * plainly beats hoping the model notices the context block is empty. + */ +export function buildContract(facts: Fact[], directives: Directive[] = []): string { + const ids = [ + ...facts.map((f) => `[${f.id}]`), + ...directives.map((_, i) => `[${directiveId(i)}]`), + ].join(" "); + const gaps = unrecordedFields(facts); + + const rules = [ + "## Grounding contract — this overrides every formatting instruction below", + "", + "You are answering from a fixed set of records. They are the ONLY things you know about the operator.", + "", + facts.length === 0 && directives.length === 0 + ? `1. NO records were retrieved for this turn. You therefore cannot answer any question about the operator's projects, people, goals, habits, commitments or events. Reply "${NO_BASIS}" and say what you would need.` + : `1. Every claim about the operator MUST cite a record id. Legal citations this turn, and no others: ${ids}`, + `2. A field shown as \`${NOT_RECORDED}\` means you DO NOT KNOW it. Never supply a value for it — not from the record's own wording, not from a name that looks like a place or an organisation, not from general knowledge about a similarly-named person. A surname is not an employer.`, + `3. If any part of the request has no supporting record, answer that part with exactly "${NO_BASIS}" and continue with the parts you can support. A requested format NEVER obliges you to invent an item. Returning three of five requested items, each cited, is a correct and complete answer.`, + "4. Do not describe a person's role, employer, seniority, or history unless a record field states it. Do not infer an organisation from a name.", + "5. You have not browsed the web this turn. If asked to research someone, say you cannot and report only what the records hold.", + "6. If you are correcting an earlier answer, the correction is subject to every rule above — cite the record, or say the record does not exist.", + ]; + + if (gaps.length > 0) { + rules.push( + "", + `Unrecorded in THIS turn's records — you have no value for any of these and must not state one: ${gaps.join(", ")}`, + ); + } + + return rules.join("\n"); +} + +/** + * The subset of the contract that needs no fact ids — for an assistant whose + * context is still prose (Cat) rather than typed records. + * + * Weaker than `buildContract` by construction: without ids there is nothing to + * cite, so rule 1 cannot exist and the verifier runs in entity-attribution + * mode. What survives is the part that stopped the worst failure — never state + * an attribute for someone in the user's data that their record does not carry, + * and never imply research you did not perform. + * + * This is a stepping stone, not the destination. It exists so a live product + * gets the protection now, without a same-day rewrite of its whole context + * layer; the destination is typed records here too. + */ +export function buildAssistantRules(opts: { subjectNoun: string }): string { + return [ + "## Grounding rules — these override formatting instructions", + "", + `1. Everything you state about the user's own ${opts.subjectNoun} must come from the context above. Do not add an organisation, role, employer, history, or relationship that the context does not state.`, + "2. Do not infer an affiliation from a name. A word inside someone's name is not their employer or their city.", + "3. You have not browsed the web in this turn. If asked to research a person or company, say you cannot, and report only what the context holds.", + `4. If part of the request has no support in the context, answer that part with exactly "${NO_BASIS}" and continue with the parts you can support. A requested format never obliges you to invent an item.`, + "5. General knowledge (how Bitcoin, Lightning, or a payment method works) is fine to use and is not covered by rules 1–2. The restriction is on facts about THIS user and the people and organisations in their data.", + "6. A correction is a claim too. If you are correcting yourself, it must be supported by the context or stated as unknown.", + ].join("\n"); +} + +/** + * A deterministic answer computed by the app, not the model. + * + * Some questions are not judgment calls at all. "Which goals are stuck at 0% + * for 30+ days", "what is due in the next 3 days", "which habit is at risk" + * are SQL predicates with exact answers, and asking a language model to derive + * them from injected prose is strictly worse than computing them: it can only + * introduce error. The model's job is to PHRASE the result, not to derive it. + * + * `answer` is empty when the query ran and found nothing — which is itself a + * real, citable answer ("nothing is due"), and crucially different from the + * query never having run. + */ +export type Directive = { + /** What was asked, in the app's words: "goals stuck 30+ days". */ + question: string; + /** Computed result lines. Empty array = ran, found nothing. */ + answer: string[]; + /** How it was computed, shown to the model so it can be honest about method. */ + method: string; +}; + +/** + * Citation handle for a computed answer, parallel to a Fact's [F1]. + * + * Directives used to be uncitable, and the contract demands a citation for + * every claim — so a model reporting a computed result had nothing legal to + * point at and wrote "[no record id]" into the user's answer. That is the + * harness leaking its own plumbing onto the screen. Give computed answers real + * ids and the sentence cites [D1] like anything else. + */ +export function directiveId(index: number): string { + return `D${index + 1}`; +} + +/** + * Render computed answers. These are stated as settled, because they are: the + * model must not re-derive, second-guess, or "improve" them, and an empty + * result must be reported as an empty result rather than backfilled from the + * fact set. + */ +export function renderDirectives(directives: Directive[]): string { + if (directives.length === 0) return ""; + const blocks = directives.map((d, i) => { + const body = + d.answer.length > 0 + ? d.answer.map((a) => ` - ${a}`).join("\n") + : " (none — the query ran and matched nothing)"; + return ` [${directiveId(i)}] ${d.question} [${d.method}]\n${body}`; + }); + return [ + "## Computed answers — already resolved, do not re-derive", + "These were computed directly from the database for this turn. They are exact.", + "Report them as given and cite their id, exactly as you would a record.", + "Where the result is empty, say so plainly — do not substitute a plausible item from the records.", + "", + ...blocks, + ].join("\n"); +} + +/** + * Assemble the full grounded context: contract, computed answers, then records. + * + * Order is deliberate and load-bearing. The contract comes FIRST so it frames + * everything read afterwards, and the records come LAST so they sit closest to + * the user's question — the position small models weight most heavily. + */ +export function buildGroundedContext(input: { + facts: Fact[]; + directives?: Directive[]; + renderedFacts: string; +}): string { + return [ + buildContract(input.facts, input.directives ?? []), + renderDirectives(input.directives ?? []), + input.facts.length > 0 + ? ["## Records", "", input.renderedFacts].join("\n") + : "## Records\n\n(none retrieved)", + ] + .filter(Boolean) + .join("\n\n---\n\n"); +} diff --git a/src/grounding/facts.ts b/src/grounding/facts.ts new file mode 100644 index 0000000..c47e883 --- /dev/null +++ b/src/grounding/facts.ts @@ -0,0 +1,172 @@ +/** + * Facts — the unit of grounded context. MIRRORED MODULE (see core/README.md). + * + * The problem this solves, concretely. Loki was asked who to contact and + * answered "Ilya Druzhnikov (UZH)". The stored record is: + * + * { displayName: "Ilya Druzhnikov", channels: { whatsapp: "+1650…" } } + * + * There is no org field, and the string "UZH" appears nowhere in the operator's + * data — it is the substring inside dr-UZH-nikov. A keyword match produced an + * affiliation out of a surname, and prose context gave the model no way to tell + * that "affiliation" was a field it had never been shown. + * + * The fix is representational, not a prompt instruction. A Fact is a RECORD with + * a DECLARED field set, and every declared field is rendered — including the ones + * with no value, which render as an explicit ``. A model that reads + * + * affiliation: + * + * is being told a specific negative, which is far harder to overwrite than the + * silence of a field that simply wasn't mentioned. Absence becomes evidence. + * + * Every fact also carries a short stable id ([F3]) so the answer can cite spans + * and `verify.ts` can check citations mechanically rather than by vibes. + * + * Pure: no DB, no network, no framework. Apps map their rows into Facts via + * their own adapters (FleetCrown: src/lib/agent/sources; OrangeCat: services/cat/sources). + */ + +/** A field that is declared for a record kind but has no stored value. */ +export const NOT_RECORDED = ""; + +/** + * One grounded record. `fields` must contain an entry for EVERY key in the + * kind's declared field list — `null` where nothing is stored. Builders should + * go through `makeFact`, which enforces that against the registry. + */ +export type Fact = { + /** Short citation handle, assigned by `assignFactIds` (F1, F2, …). */ + id: string; + /** Record kind — must be a key of the FACT_KINDS registry. */ + kind: string; + /** Human label for the record (a name, a title). Never invented. */ + subject: string; + /** Declared field → stored value, or null for "nothing stored". */ + fields: Record; + /** Where this came from, shown to the model: "people table", "goals table". */ + source: string; + /** + * Relevance score when the fact came from similarity search. Absent for facts + * fetched deterministically (a SQL filter) — those are not ranked, they are + * simply true, and the distinction matters to the reader. + */ + similarity?: number; +}; + +/** + * The declared field set per record kind — the SSOT for "what could be known + * about this kind of thing". Adding a field here makes it render as + * `` everywhere it is missing, which is the entire anti-invention + * mechanism: the model can only ever see fields we chose to declare. + * + * Deliberately includes fields we do NOT store (a person's `affiliation`, + * `role`, `employer`). That is not an oversight — those are exactly the + * attributes models invent, so naming them and marking them unrecorded is the + * point. Do not "clean up" this list by deleting the empty ones. + */ +export const FACT_KINDS: Record = { + person: ["name", "affiliation", "role", "how_we_met", "last_interaction", "notes", "channels"], + project: ["name", "status", "stack", "description", "latest_dev_log", "repo"], + goal: ["title", "project", "progress", "target_date", "last_updated"], + habit: ["title", "frequency", "current_streak", "last_checked"], + commitment: ["title", "due", "counterparty", "status"], + event: ["name", "type", "deadline", "url", "status"], + // Humans the operator delegates to, and the work handed to them. Separate + // from `person`/`commitment` because the questions are different: a crew + // member is asked what they are good FOR, an assignment is asked who has it + // and whether they said yes. + crew_member: ["name", "role", "skills", "engagement", "rate", "availability", "open_assignments"], + assignment: ["title", "assignee", "status", "due", "fee", "why"], + document: ["title", "source", "excerpt"], + pending_action: ["title", "type", "reasoning", "proposed_on", "id"], +}; + +/** Field list for a kind; unknown kinds fall back to whatever the fact carries. */ +export function declaredFields(kind: string, fallback: string[] = []): readonly string[] { + return FACT_KINDS[kind] ?? fallback; +} + +/** + * Build a Fact with every declared field present. Values not supplied become + * null (→ ``). Undeclared keys are DROPPED rather than passed + * through: if a field is worth showing the model it is worth declaring in + * FACT_KINDS, otherwise the registry stops describing what the model sees. + */ +export function makeFact(input: { + kind: string; + subject: string; + source: string; + values?: Record; + similarity?: number; +}): Fact { + const keys = declaredFields(input.kind, Object.keys(input.values ?? {})); + const fields: Record = {}; + for (const key of keys) { + const raw = input.values?.[key]; + const trimmed = typeof raw === "string" ? raw.trim() : raw; + fields[key] = trimmed ? String(trimmed) : null; + } + return { + id: "", + kind: input.kind, + subject: input.subject, + source: input.source, + fields, + ...(input.similarity !== undefined ? { similarity: input.similarity } : {}), + }; +} + +/** Stamp sequential citation ids. Call once, after assembling the final set. */ +export function assignFactIds(facts: Fact[]): Fact[] { + return facts.map((f, i) => ({ ...f, id: `F${i + 1}` })); +} + +/** Every citation handle in a fact set — the only legal citations in an answer. */ +export function factIds(facts: Fact[]): Set { + return new Set(facts.map((f) => f.id)); +} + +/** + * Render facts for the model. One block per record, every declared field on its + * own line, unrecorded fields stated explicitly. + * + * [F3] person — Elena Weber SINGA Switzerland (people table) + * name: Elena Weber SINGA Switzerland + * affiliation: + * role: + * channels: whatsapp +41774730093 + * + * The line-per-field shape matters for small models: a flat prose blob invites + * summarising (and summarising is where invention creeps in), whereas a field + * list invites lookup. Observed with 8B models — the same prompt over a blob + * hallucinates roles, over a field list it reports ``. + */ +export function renderFacts(facts: Fact[]): string { + if (facts.length === 0) return ""; + return facts + .map((f) => { + const head = `[${f.id}] ${f.kind} — ${f.subject} (${f.source})`; + const body = Object.entries(f.fields).map( + ([k, v]) => ` ${k}: ${v ?? NOT_RECORDED}`, + ); + return [head, ...body].join("\n"); + }) + .join("\n\n"); +} + +/** + * Which declared fields are unrecorded across the set, as + * `kind.field` keys. The contract block names these explicitly so the rule + * "do not state an affiliation" is anchored to a concrete gap in THIS turn's + * context rather than being a standing abstraction the model may ignore. + */ +export function unrecordedFields(facts: Fact[]): string[] { + const gaps = new Set(); + for (const f of facts) { + for (const [k, v] of Object.entries(f.fields)) { + if (v === null) gaps.add(`${f.kind}.${k}`); + } + } + return [...gaps].sort(); +} diff --git a/src/grounding/index.ts b/src/grounding/index.ts new file mode 100644 index 0000000..cd1f11d --- /dev/null +++ b/src/grounding/index.ts @@ -0,0 +1,50 @@ +/** + * The grounding harness — imported, no longer mirrored. + * + * These three modules were born in FleetCrown (`src/lib/agent/core/`) and + * lived as a byte-identical mirror in OrangeCat, guarded by a SHA-256 drift + * check, because both assistants had the same failure: a model asked to fill + * a rigid answer format against thin context invents the missing parts, and + * the invention is indistinguishable from truth because both arrive as + * confident prose. + * + * The mirror's own README called the duplication "deliberate and temporary" + * and named this extraction as the exit. This is that exit: both apps now + * import `ai-kit/grounding`, and the drift check retires — two + * silently-diverging definitions of "what counts as grounded" are no longer + * possible, because there is only one. + * + * The constraint that made the code mirrorable is the constraint that makes + * it packageable, and it still holds: pure TypeScript, no DB, no network, no + * framework, no imports outside this directory. Anything that knows where + * data lives belongs in the app adapter that maps rows to `Fact`s, not here. + */ +export { + NOT_RECORDED, + FACT_KINDS, + declaredFields, + makeFact, + assignFactIds, + factIds, + renderFacts, + unrecordedFields, + type Fact, +} from "./facts.js"; + +export { + NO_BASIS, + buildContract, + buildAssistantRules, + directiveId, + renderDirectives, + buildGroundedContext, + type Directive, +} from "./contract.js"; + +export { + verifyAnswer, + buildRepairPrompt, + type Violation, + type VerifyResult, + type VerifyMode, +} from "./verify.js"; diff --git a/src/grounding/verify.ts b/src/grounding/verify.ts new file mode 100644 index 0000000..e9f3c0a --- /dev/null +++ b/src/grounding/verify.ts @@ -0,0 +1,339 @@ +/** + * Groundedness verifier — MIRRORED MODULE (see core/README.md). + * + * Runs on the generated answer and reports claims the fact set does not support. + * Deliberately deterministic: no second model call, no embedding round-trip, no + * added cost or latency. That is a requirement, not a shortcut — this must run + * on every turn including the free-tier ones, and a verifier that costs a + * frontier call is one that gets disabled exactly where it is needed most. + * + * The insight that makes a cheap check work: fabrication is overwhelmingly + * NOMINAL. Models invent organisations, titles, people, file paths, phone + * numbers and dates — tokens that are mechanically recognisable and that must, + * if genuine, have appeared in the retrieved records or in what the user said. + * Grammar and hedging are hard to check; proper nouns and digits are easy. + * + * Scored against the real failure this was built from, every fabricated claim + * is caught by the proper-noun or numeric rule: + * + * "Ilya Druzhnikov (UZH)" → UZH: novel acronym + * "Accelerator & Bridge Program Manager" → novel proper-noun run + * "University of Liechtenstein", "START Summit" → novel proper-noun runs + * "/opt/fleetcrown/runner/.env" → novel path + * + * while the true parts ("Elena Weber SINGA Switzerland", "+41774730093") appear + * verbatim in the records and pass clean. + */ +import { NOT_RECORDED, type Fact } from "./facts.js"; + +export type Violation = { + kind: "unknown-citation" | "novel-proper-noun" | "novel-number" | "novel-path" | "uncited-claim"; + /** The offending text. */ + text: string; + /** Why it is a problem, phrased for a repair prompt the model will read. */ + detail: string; +}; + +export type VerifyResult = { + ok: boolean; + violations: Violation[]; +}; + +/** + * Words that are capitalised for reasons other than being a proper noun, or + * that are part of this system's own vocabulary. Kept deliberately small — + * every entry is a hole in the check, so add only what demonstrably causes + * false positives, never to silence a true one. + */ +const COMMON = new Set( + [ + // Sentence/structural + "the", "a", "an", "and", "or", "but", "if", "then", "so", "because", "not", + "this", "that", "these", "those", "it", "its", "your", "you", "i", "we", + "there", "here", "what", "which", "who", "when", "where", "why", "how", + "no", "yes", "none", "nothing", "today", "tomorrow", "yesterday", "now", + "next", "last", "first", "one", "two", "three", "primary", "focus", "task", + "tasks", "outreach", "note", "notes", "summary", "status", "update", + // Days / months — real words, never evidence of a fabricated entity + "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", + "january", "february", "march", "april", "may", "june", "july", "august", + "september", "october", "november", "december", + // This system's own nouns + "loki", "cat", "fleetcrown", "orangecat", "not", "recorded", + ].map((w) => w.toLowerCase()), +); + +/** Normalise for containment tests: casefold, collapse punctuation and space. */ +function norm(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9+]+/g, " ").replace(/\s+/g, " ").trim(); +} + +/** + * Everything the model was legitimately given this turn: record values, record + * subjects, and the user's own message (a name the user typed is fair to + * repeat). This is the corpus a claim must be traceable to. + */ +function buildEvidence(facts: Fact[], userMessage: string, extra: string[]): string { + const parts: string[] = [userMessage, ...extra]; + for (const f of facts) { + parts.push(f.subject, f.kind, f.source); + for (const v of Object.values(f.fields)) if (v) parts.push(v); + } + return norm(parts.join(" ")); +} + +/** + * Lowercase words that legitimately sit INSIDE a proper name and must not break + * it up: "University of Zurich", "Bank für Handel", "Institute for the Study of + * Complexity". Without these, the run splits at the connector and the check + * only ever sees the harmless halves ("University", "Zurich") while the actual + * fabricated entity slips through unnamed. + */ +const NAME_CONNECTORS = new Set(["of", "the", "for", "and", "de", "der", "des", "van", "von", "du", "da", "di", "für", "el", "al"]); + +/** + * Named-entity candidates: ALL-CAPS acronyms, capitalised words, and the + * multi-word runs they form (connectors allowed strictly between two + * capitalised tokens, never at an edge). + * + * Both the run AND its individual tokens are emitted, deliberately. The run + * catches composite inventions ("University of Zurich") that no single token + * reveals; the individual tokens catch an invented acronym sitting next to a + * real name ("Druzhnikov UZH"), where reporting only the run would name the + * real person in the violation and produce a repair prompt that deletes the + * true claim along with the false one. + * + * Sentence-initial single words are skipped — otherwise "Rotate the key" flags + * "Rotate". That costs a little recall at sentence starts and removes the + * dominant source of false positives; a fabricated name at a sentence start is + * still caught by its remaining tokens. + */ +function properNounRuns(text: string): string[] { + const out: string[] = []; + // Strip fenced and inline code — quoted identifiers are usually the user's + // own or a literal under discussion, not a claim about the world. + const prose = text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " "); + + for (const sentence of prose.split(/(?<=[.!?:\n])\s+/)) { + const tokens = sentence.match(/[A-Za-z][A-Za-z0-9&.'’-]*/g) ?? []; + let run: string[] = []; + + const flush = () => { + // Trim trailing connectors so "University of" never stands as a run. + while (run.length > 0 && NAME_CONNECTORS.has((run[run.length - 1] ?? "").toLowerCase())) run.pop(); + if (run.length > 1) out.push(run.join(" ")); + run = []; + }; + + tokens.forEach((tok, i) => { + const bare = tok.replace(/[.'’-]+$/, ""); + const isAcronym = /^[A-Z]{2,}$/.test(bare); + const isCapitalised = /^[A-Z][a-z]/.test(bare); + const isConnector = NAME_CONNECTORS.has(bare.toLowerCase()); + + if (isAcronym || (isCapitalised && i > 0)) { + run.push(bare); + out.push(bare); // individually checkable + return; + } + // A connector only continues a run that has already started. + if (isConnector && run.length > 0) { + run.push(bare); + return; + } + flush(); + }); + flush(); + } + return out; +} + +/** Digit groups worth checking: phone numbers, years, percentages, counts ≥ 2 digits. */ +function numericClaims(text: string): string[] { + const prose = text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " "); + return (prose.match(/\+?\d[\d\s().-]{3,}\d|\b\d{2,}%?\b/g) ?? []).map((s) => s.trim()); +} + +/** + * File and path references — a favourite fabrication, and an unusually + * damaging one because naming a file implies the model READ it. + * + * Covers absolute paths (`/opt/fleetcrown/runner/.env`), relative paths + * (`data/contact-resolver.json`), and bare filenames with a data/config + * extension. The relative form matters: when challenged on the UZH claim, the + * model "corrected" itself by asserting what `data/contact-resolver.json` + * contained — a file it was never given. That reads as citing a source, which + * is precisely why an unverified correction is more corrosive than the + * original error: it spends the credibility the user was trying to restore. + */ +function pathClaims(text: string): string[] { + const patterns = [ + /(?:^|[\s("'`])(\/[A-Za-z0-9_.\-/]{4,})/g, // absolute + /(?:^|[\s("'`])([A-Za-z0-9_.-]+\/[A-Za-z0-9_.\-/]*[A-Za-z0-9_-]\.[a-z]{2,5})/g, // relative w/ extension + /(?:^|[\s("'`])([A-Za-z0-9_-]+\.(?:json|env|ya?ml|sql|toml|ini|conf|log))\b/g, // bare config filename + ]; + const out = new Set(); + for (const re of patterns) { + for (const m of text.matchAll(re)) if (m[1]) out.add(m[1]); + } + return [...out]; +} + +/** + * How strictly to treat unattested names — the one real difference between the + * two assistants that use this harness. + * + * `closed-world` (Loki): the assistant's entire job is reporting the operator's + * records, so ANY unattested proper noun is a fabrication. One operator, one + * data set, no legitimate outside knowledge in scope. + * + * `entity-attribution` (Cat): the assistant also answers general questions — + * how Lightning works, which payment rails exist in Switzerland — where naming + * Twint or Bitcoin is correct and required. Flagging those would make Cat + * useless. So the check narrows to what actually goes wrong: attributes + * attached to one of the USER'S OWN records. A sentence naming a known subject + * is checked; a sentence of general explanation is not. + * + * The narrower mode is genuinely weaker, and that is a real trade, not a + * loophole: Cat can still invent a fact about the wider world. It can no longer + * invent an employer for someone in your contacts, which is the failure that + * actually destroys trust in a personal assistant. + */ +export type VerifyMode = "closed-world" | "entity-attribution"; + +/** Does this sentence talk about one of the user's own records? */ +function mentionsSubject(sentence: string, subjects: string[]): boolean { + const s = norm(sentence); + return subjects.some((sub) => { + const n = norm(sub); + return n.length > 2 && s.includes(n); + }); +} + +/** + * Verify an answer against the facts it was supposed to come from. + * + * `extraEvidence` lets a caller admit sources outside the fact set — computed + * directive output, a tool result the model legitimately saw this turn. + * Anything not in facts, the user's message, or extraEvidence is unsupported + * by construction. + * + * `subjects` (entity-attribution mode) names the user's own records, so the + * check can tell "your contact Elena works at X" from "Lightning is instant". + */ +export function verifyAnswer(input: { + answer: string; + facts: Fact[]; + userMessage: string; + extraEvidence?: string[]; + mode?: VerifyMode; + subjects?: string[]; + /** + * Extra legal citation handles beyond the fact ids — the [D1] series for + * computed answers. Without these a model that correctly cites a computed + * result gets flagged for citing something "that does not exist", which + * would train the repair pass to delete true statements. + */ + extraCitationIds?: string[]; +}): VerifyResult { + const { answer, facts, userMessage } = input; + const mode: VerifyMode = input.mode ?? "closed-world"; + const subjects = input.subjects ?? facts.map((f) => f.subject); + const evidence = buildEvidence(facts, userMessage, input.extraEvidence ?? []); + const legalIds = new Set([ + ...facts.map((f) => f.id.toUpperCase()), + ...(input.extraCitationIds ?? []).map((id) => id.toUpperCase()), + ]); + const violations: Violation[] = []; + + /** + * In entity-attribution mode, only sentences about the user's own records are + * subject to the name check. Built once so the per-token loop stays cheap. + */ + const attributionScope = + mode === "entity-attribution" + ? answer + .split(/(?<=[.!?:\n])\s+/) + .filter((s) => mentionsSubject(s, subjects)) + .join(" ") + : answer; + + // 1. Citations must resolve. A citation to a record that does not exist is + // the strongest possible signal of fabrication — it invents its own proof. + for (const cite of answer.match(/\[[FD]\d+\]/g) ?? []) { + const id = cite.slice(1, -1).toUpperCase(); + if (!legalIds.has(id)) { + violations.push({ + kind: "unknown-citation", + text: cite, + detail: `${cite} is not a record in this turn's context. Cite only ids that were provided, or say there is no record.`, + }); + } + } + + // 2. Named entities must be traceable. This is the anti-"UZH" rule. + const seen = new Set(); + for (const run of properNounRuns(attributionScope)) { + const n = norm(run); + if (!n || seen.has(n)) continue; + seen.add(n); + // Single common words are noise; multi-word runs always checked. + const words = n.split(" "); + if (words.length === 1 && (COMMON.has(words[0] ?? "") || (words[0] ?? "").length < 2)) continue; + if (words.every((w) => COMMON.has(w))) continue; + if (evidence.includes(n)) continue; + // A multi-word run whose every word is individually attested is fine — + // it is a rephrasing, not a new entity. + if (words.length > 1 && words.every((w) => COMMON.has(w) || evidence.includes(w))) continue; + violations.push({ + kind: "novel-proper-noun", + text: run, + detail: `"${run}" does not appear in any record or in the operator's message. If it is an organisation, role, or place you associated with someone, the relevant field is ${NOT_RECORDED} — remove the claim.`, + }); + } + + // 3. Numbers must be traceable — invented phone numbers and dates read as + // authoritative precisely because they are specific. + for (const num of numericClaims(answer)) { + const n = norm(num); + if (!n || n.length < 2) continue; + if (evidence.includes(n)) continue; + // Compare digits-only too: "+41 77 473 00 93" vs stored "+41774730093". + const digits = num.replace(/\D/g, ""); + if (digits.length >= 4 && evidence.replace(/\D/g, "").includes(digits)) continue; + if (digits.length < 4) continue; // small counts ("3 tasks") are rhetorical + violations.push({ + kind: "novel-number", + text: num, + detail: `The number "${num}" is not in any record. Do not state contact details, dates, or metrics that were not provided.`, + }); + } + + // 4. Paths — "update the key in /opt/fleetcrown/runner/.env" was invented + // wholesale, and its specificity is what made it convincing. + for (const p of pathClaims(answer)) { + if (evidence.includes(norm(p))) continue; + violations.push({ + kind: "novel-path", + text: p, + detail: `The path "${p}" is not in any record. Do not state file locations you were not given.`, + }); + } + + return { ok: violations.length === 0, violations }; +} + +/** + * Turn violations into a repair instruction. One cheap retry with this appended + * fixes most turns, because the model is not being asked to know more — only to + * delete claims it cannot support. + */ +export function buildRepairPrompt(violations: Violation[], noBasisPhrase: string): string { + return [ + "Your previous answer contained claims not supported by the records. Rewrite it.", + "", + ...violations.map((v) => `- ${v.detail}`), + "", + `Remove every unsupported claim. Where removing one empties a requested item, write "${noBasisPhrase}" for that item instead of substituting something else. Keep everything that was supported, unchanged.`, + ].join("\n"); +} diff --git a/src/registry.ts b/src/registry.ts new file mode 100644 index 0000000..368268d --- /dev/null +++ b/src/registry.ts @@ -0,0 +1,213 @@ +/** + * The model REGISTRY — one SSOT for every model id an app may call. + * + * This module exists because the fleet paid for its absence twice, in two + * different currencies: + * + * OUTAGE — on 2026-08-18 Groq removed `llama-3.3-70b-versatile` and one app + * kept asking for it for eight days. A rot checker already existed, but it + * probed only the chains it knew about; the id that died was pinned + * elsewhere. A checker that does not enumerate its subjects cannot report + * the one it never knew about. The registry IS the enumeration: a model id + * is callable only if it appears here, and the catalog check walks exactly + * this list. + * + * MONEY — three apps silently billed real money on fallback, because the + * only thing separating the free variant from the paid one was a `:free` + * suffix on the id string. A billing boundary that lives in a naming + * convention is one typo away from a paid call. Here it is a FIELD, and the + * validator refuses an entry whose flag contradicts its own cost or suffix — + * so the contradiction is a build failure, not an invoice. + * + * What deliberately does NOT live here: which model to PREFER (that is the + * chain's job), UI presentation (labels, badges — app concern), and anything + * that knows where data lives. Same boundary as the rest of this package: + * meaning in core, adapters in the app. + * + * ── Vendor vs author ───────────────────────────────────────────────────────── + * A registry row is a CALLABLE id at a VENDOR — the place a request goes — + * because that is the unit that rots, meters, and bills. The AUTHOR (who + * trained it) is metadata. The two were conflated in one app's registry + * ("provider: Anthropic" on a row served by OpenRouter), which made "who do we + * pay" unanswerable by query. Here they are separate fields. + */ + +/** + * How a model answered a live tool-call probe. "unprobed" is a real value, not + * a default to ignore: of nine free models probed for the default chain, FIVE + * answered only via a text protocol — not guessable from name, size, or docs. + * A loop that needs tools should refuse "none" and treat "unprobed" as a + * to-do, never as "probably native". + */ +export type ToolProtocol = "native" | "text" | "none" | "unprobed"; + +export type ModelTier = "free" | "economy" | "standard" | "premium"; + +export type ModelCapability = + | "text" + | "vision" + | "function_calling" + | "json_mode" + | "streaming" + | "transcribe"; + +export type ModelEntry = { + /** The id sent on the wire — exactly as the vendor expects it. */ + id: string; + /** Where the call goes (groq, openrouter, together, ollama, …). */ + vendor: string; + /** Who trained it (Anthropic, Meta, Moonshot, …) — metadata, never routing. */ + author?: string; + /** Display name for pickers. Optional: an engine-only entry needs none. */ + name?: string; + /** + * THE billing boundary. Required, no default: making the author write + * `paid: false` is the whole point — a forgotten field must fail the build, + * not silently ride a naming convention. + */ + paid: boolean; + /** USD per 1M tokens. Free entries may omit (treated as 0). */ + inputCostPer1M?: number; + outputCostPer1M?: number; + contextWindow?: number; + maxOutputTokens?: number; + tier?: ModelTier; + capabilities?: ModelCapability[]; + /** Verdict of a live tool-call probe. Absent = "unprobed". */ + toolProtocol?: ToolProtocol; + /** + * Whether the model accepts a non-default `temperature`. Absent = true. + * Current Anthropic frontier models reject non-default sampling params, so + * callers must omit the param for entries that say false. + */ + supportsTemperature?: boolean; + /** + * What breaks when this id stops existing — the text a rot report shows. + * Borrowed from the eight-day outage: the fastest diagnosis is the registry + * row saying which feature just died. + */ + usedFor?: string; + /** Which endpoint shape this id is called on. Default "chat". */ + kind?: "chat" | "transcribe"; +}; + +export type Registry = { + entries: readonly ModelEntry[]; + /** Lookup by wire id (optionally scoped to a vendor when ids collide). */ + find(id: string, vendor?: string): ModelEntry | undefined; + /** + * The entry, or a THROW naming what depends on it. "A model id is callable + * only if it appears here" is only true if the miss is loud. + */ + require(id: string, vendor?: string): ModelEntry; + /** Every wire id at one vendor — the enumeration a catalog check walks. */ + idsForVendor(vendor: string): string[]; + vendors(): string[]; + /** Entries the free tier may serve. The platform-key guard filters on THIS. */ + freeEntries(): ModelEntry[]; + /** Entries only reachable through someone's money (credits or BYOK). */ + paidEntries(): ModelEntry[]; +}; + +/** A `:free`-suffixed id claiming to be paid, or a "free" entry with a price — + * each one is the 2026 billing incident waiting to recur. */ +function validateEntry(e: ModelEntry): string | null { + if (!e.id.trim()) return "entry has an empty id"; + if (!e.vendor.trim()) return `"${e.id}": empty vendor`; + const cost = (e.inputCostPer1M ?? 0) + (e.outputCostPer1M ?? 0); + if (!e.paid && cost > 0) { + return `"${e.id}": declared free but carries a cost (${cost}/1M) — the flag or the price is lying`; + } + if (e.paid && e.id.endsWith(":free")) { + return `"${e.id}": declared paid but the id says :free — the flag or the id is lying`; + } + return null; +} + +/** + * Build a registry from entries. Throws on the first contradiction — a + * registry that loads is a registry whose billing boundary can be trusted. + */ +export function defineRegistry(entries: ModelEntry[]): Registry { + const seen = new Set(); + for (const e of entries) { + const problem = validateEntry(e); + if (problem) throw new Error(`ai-kit registry: ${problem}`); + const key = `${e.vendor}:${e.id}`; + if (seen.has(key)) { + throw new Error( + `ai-kit registry: duplicate entry ${key} — two rows for one callable id is two sources of truth`, + ); + } + seen.add(key); + } + const frozen: readonly ModelEntry[] = Object.freeze(entries.map((e) => ({ ...e }))); + + const find = (id: string, vendor?: string): ModelEntry | undefined => + frozen.find((e) => e.id === id && (vendor === undefined || e.vendor === vendor)); + + return { + entries: frozen, + find, + require(id: string, vendor?: string): ModelEntry { + const hit = find(id, vendor); + if (!hit) { + const scope = vendor ? ` at ${vendor}` : ""; + throw new Error( + `ai-kit registry: "${id}"${scope} is not registered — a model id is callable only if it appears in the registry (add it with its paid flag, or stop calling it)`, + ); + } + return hit; + }, + idsForVendor: (vendor: string) => + frozen.filter((e) => e.vendor === vendor).map((e) => e.id), + vendors: () => [...new Set(frozen.map((e) => e.vendor))], + freeEntries: () => frozen.filter((e) => !e.paid), + paidEntries: () => frozen.filter((e) => e.paid), + }; +} + +/** + * The platform-key guard: the ids from `requested` that a platform-funded + * call may serve. Registered-and-free passes; paid is dropped; an UNKNOWN id + * is dropped too — an id nobody registered has an unknown price, and "unknown" + * spends someone's money only when a person decides it does. + * + * Returns the dropped ids alongside, because a silently narrowed chain reads + * as "covered everything" when it didn't. + */ +export function freeOnly( + registry: Registry, + requested: string[], +): { allowed: string[]; dropped: { id: string; why: "paid" | "unregistered" }[] } { + const allowed: string[] = []; + const dropped: { id: string; why: "paid" | "unregistered" }[] = []; + for (const id of requested) { + const entry = registry.find(id); + if (!entry) dropped.push({ id, why: "unregistered" }); + else if (entry.paid) dropped.push({ id, why: "paid" }); + else allowed.push(id); + } + return { allowed, dropped }; +} + +/** + * A tool-driving chain may only contain models that can drive a tool loop. + * "unprobed" entries are reported, not silently trusted — the probe table is + * one `npm run probe:models` away, and a chain built on guesses loses turns + * exactly on the models most likely to serve free traffic. + */ +export function toolCapable( + registry: Registry, + requested: string[], +): { usable: string[]; refused: { id: string; protocol: ToolProtocol }[] } { + const usable: string[] = []; + const refused: { id: string; protocol: ToolProtocol }[] = []; + for (const id of requested) { + const entry = registry.find(id); + const protocol: ToolProtocol = entry?.toolProtocol ?? "unprobed"; + if (protocol === "native" || protocol === "text") usable.push(id); + else refused.push({ id, protocol }); + } + return { usable, refused }; +} diff --git a/test/grounding.test.js b/test/grounding.test.js new file mode 100644 index 0000000..b313596 --- /dev/null +++ b/test/grounding.test.js @@ -0,0 +1,72 @@ +/** + * The grounding harness in its new home, pinned against the incident that + * built it: a contact reported as "Ilya Druzhnikov (UZH)" where UZH existed + * nowhere in the operator's data — it is the substring inside dr-UZH-nikov, + * surfaced by a keyword match and narrated as an affiliation. + * + * These are behavior pins, not a port of the apps' suites: the apps keep + * their own policy tests (when to repair, when a repair must be refused). + * What must hold HERE is that the check itself still catches the canonical + * fabrication and still renders absence as an explicit negative — because + * from this version on, this copy is the only definition of "grounded" the + * fleet has. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + makeFact, + assignFactIds, + renderFacts, + verifyAnswer, + NOT_RECORDED, +} from 'ai-kit/grounding'; + +const elena = () => + assignFactIds([ + makeFact({ + kind: 'person', + subject: 'Elena Weber', + source: 'people', + // Only DECLARED fields survive makeFact — affiliation and channels are + // in person's declared set; an undeclared key would be dropped, which is + // itself part of the design (nothing reaches the model unregistered). + values: { name: 'Elena Weber', affiliation: 'SINGA Switzerland', channels: '+41774730093' }, + }), + ]); + +test('absence renders as an explicit negative, not as silence', () => { + const rendered = renderFacts(elena()); + assert.ok(rendered.includes(NOT_RECORDED), 'undeclared fields must render as '); + assert.ok(rendered.includes('SINGA Switzerland')); +}); + +test('the canonical fabrication is caught: a novel proper noun with no source', () => { + const facts = elena(); + const { ok, violations } = verifyAnswer({ + answer: 'Your contact is Ilya Druzhnikov at the University of Liechtenstein.', + facts, + userMessage: 'who should I contact?', + }); + assert.equal(ok, false); + assert.ok(violations.some((v) => v.kind === 'novel-proper-noun')); +}); + +test('the true answer passes clean — names and numbers attested by the records', () => { + const facts = elena(); + const { ok, violations } = verifyAnswer({ + answer: 'Elena Weber (SINGA Switzerland) — +41774730093.', + facts, + userMessage: 'who should I contact?', + }); + assert.equal(ok, true, JSON.stringify(violations)); +}); + +test('what the user themselves said is never a fabrication', () => { + const { ok } = verifyAnswer({ + answer: 'Noted — Bahnhofstrasse 12 is saved as the meeting point.', + facts: [], + userMessage: 'we meet at Bahnhofstrasse 12', + }); + assert.equal(ok, true); +}); diff --git a/test/registry.test.js b/test/registry.test.js new file mode 100644 index 0000000..4d46a5a --- /dev/null +++ b/test/registry.test.js @@ -0,0 +1,91 @@ +/** + * The registry's two jobs, each pinned to the incident that created it: + * + * ENUMERATION — "a model id is callable only if it appears here." An id that + * no probe enumerates is an id whose death nobody reports (eight days of + * `model_not_found`, 2026-08-18). + * + * THE BILLING BOUNDARY — paid/free is a FIELD the validator defends, never a + * `:free` suffix convention (three apps silently billed on fallback because + * the suffix was the whole difference). + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { defineRegistry, freeOnly, toolCapable } from 'ai-kit/registry'; + +const ENTRIES = [ + { id: 'openai/gpt-oss-20b', vendor: 'groq', paid: false, toolProtocol: 'native', usedFor: 'default chat' }, + { id: 'nvidia/nemotron-3-super-120b-a12b:free', vendor: 'openrouter', paid: false, toolProtocol: 'text' }, + { id: 'moonshotai/kimi-k2', vendor: 'openrouter', author: 'Moonshot', paid: true, inputCostPer1M: 0.6, outputCostPer1M: 2.5 }, + { id: 'anthropic/claude-fable-5', vendor: 'openrouter', author: 'Anthropic', paid: true, inputCostPer1M: 15, outputCostPer1M: 75, supportsTemperature: false }, + { id: 'whisper-large-v3', vendor: 'groq', paid: false, kind: 'transcribe' }, +]; + +test('require() throws loudly for an unregistered id — the enumeration rule', () => { + const reg = defineRegistry(ENTRIES); + assert.equal(reg.require('openai/gpt-oss-20b').vendor, 'groq'); + assert.throws(() => reg.require('llama-3.3-70b-versatile'), /not registered/); +}); + +test('a free entry carrying a cost refuses to load — the flag or the price is lying', () => { + assert.throws( + () => defineRegistry([{ id: 'x', vendor: 'v', paid: false, inputCostPer1M: 3 }]), + /declared free but carries a cost/, + ); +}); + +test('a paid entry with a :free id refuses to load — the flag or the id is lying', () => { + assert.throws( + () => defineRegistry([{ id: 'model:free', vendor: 'v', paid: true }]), + /declared paid but the id says :free/, + ); +}); + +test('duplicate (vendor, id) refuses to load — one callable id, one row', () => { + assert.throws( + () => defineRegistry([ + { id: 'a', vendor: 'v', paid: false }, + { id: 'a', vendor: 'v', paid: false }, + ]), + /duplicate entry/, + ); +}); + +test('freeOnly drops paid AND unregistered ids, and says which and why', () => { + const reg = defineRegistry(ENTRIES); + const { allowed, dropped } = freeOnly(reg, [ + 'openai/gpt-oss-20b', + 'anthropic/claude-fable-5', + 'model-nobody-registered', + ]); + assert.deepEqual(allowed, ['openai/gpt-oss-20b']); + assert.deepEqual(dropped, [ + { id: 'anthropic/claude-fable-5', why: 'paid' }, + { id: 'model-nobody-registered', why: 'unregistered' }, + ]); +}); + +test('toolCapable accepts native AND text protocols, refuses none/unprobed', () => { + const reg = defineRegistry([ + ...ENTRIES, + { id: 'probed-toolless', vendor: 'v', paid: false, toolProtocol: 'none' }, + ]); + const { usable, refused } = toolCapable(reg, [ + 'openai/gpt-oss-20b', // native + 'nvidia/nemotron-3-super-120b-a12b:free', // text — 5 of 9 probed free models only speak this + 'probed-toolless', // probed, cannot + 'moonshotai/kimi-k2', // never probed + ]); + assert.deepEqual(usable, ['openai/gpt-oss-20b', 'nvidia/nemotron-3-super-120b-a12b:free']); + assert.deepEqual(refused, [ + { id: 'probed-toolless', protocol: 'none' }, + { id: 'moonshotai/kimi-k2', protocol: 'unprobed' }, + ]); +}); + +test('idsForVendor is the enumeration a catalog check walks', () => { + const reg = defineRegistry(ENTRIES); + assert.deepEqual(reg.idsForVendor('groq'), ['openai/gpt-oss-20b', 'whisper-large-v3']); + assert.deepEqual(reg.vendors().sort(), ['groq', 'openrouter']); +});