Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions dist-cjs/grounding/contract.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.NO_BASIS = void 0;
exports.buildContract = buildContract;
exports.buildAssistantRules = buildAssistantRules;
exports.directiveId = directiveId;
exports.renderDirectives = renderDirectives;
exports.buildGroundedContext = buildGroundedContext;
/**
* 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.
*/
const facts_js_1 = require("./facts.js");
/** The exact string the model must emit when a slot cannot be filled. */
exports.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.
*/
function buildContract(facts, directives = []) {
const ids = [
...facts.map((f) => `[${f.id}]`),
...directives.map((_, i) => `[${directiveId(i)}]`),
].join(" ");
const gaps = (0, facts_js_1.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 "${exports.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 \`${facts_js_1.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 "${exports.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.
*/
function buildAssistantRules(opts) {
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 "${exports.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");
}
/**
* 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.
*/
function directiveId(index) {
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.
*/
function renderDirectives(directives) {
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.
*/
function buildGroundedContext(input) {
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");
}
143 changes: 143 additions & 0 deletions dist-cjs/grounding/facts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"use strict";
/**
* 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 `<not recorded>`. A model that reads
*
* affiliation: <not recorded>
*
* 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).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.FACT_KINDS = exports.NOT_RECORDED = void 0;
exports.declaredFields = declaredFields;
exports.makeFact = makeFact;
exports.assignFactIds = assignFactIds;
exports.factIds = factIds;
exports.renderFacts = renderFacts;
exports.unrecordedFields = unrecordedFields;
/** A field that is declared for a record kind but has no stored value. */
exports.NOT_RECORDED = "<not recorded>";
/**
* 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
* `<not recorded>` 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.
*/
exports.FACT_KINDS = {
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. */
function declaredFields(kind, fallback = []) {
return exports.FACT_KINDS[kind] ?? fallback;
}
/**
* Build a Fact with every declared field present. Values not supplied become
* null (→ `<not recorded>`). 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.
*/
function makeFact(input) {
const keys = declaredFields(input.kind, Object.keys(input.values ?? {}));
const fields = {};
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. */
function assignFactIds(facts) {
return facts.map((f, i) => ({ ...f, id: `F${i + 1}` }));
}
/** Every citation handle in a fact set — the only legal citations in an answer. */
function factIds(facts) {
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: <not recorded>
* role: <not recorded>
* 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 `<not recorded>`.
*/
function renderFacts(facts) {
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 ?? exports.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.
*/
function unrecordedFields(facts) {
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();
}
43 changes: 43 additions & 0 deletions dist-cjs/grounding/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildRepairPrompt = exports.verifyAnswer = exports.buildGroundedContext = exports.renderDirectives = exports.directiveId = exports.buildAssistantRules = exports.buildContract = exports.NO_BASIS = exports.unrecordedFields = exports.renderFacts = exports.factIds = exports.assignFactIds = exports.makeFact = exports.declaredFields = exports.FACT_KINDS = exports.NOT_RECORDED = void 0;
/**
* 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.
*/
var facts_js_1 = require("./facts.js");
Object.defineProperty(exports, "NOT_RECORDED", { enumerable: true, get: function () { return facts_js_1.NOT_RECORDED; } });
Object.defineProperty(exports, "FACT_KINDS", { enumerable: true, get: function () { return facts_js_1.FACT_KINDS; } });
Object.defineProperty(exports, "declaredFields", { enumerable: true, get: function () { return facts_js_1.declaredFields; } });
Object.defineProperty(exports, "makeFact", { enumerable: true, get: function () { return facts_js_1.makeFact; } });
Object.defineProperty(exports, "assignFactIds", { enumerable: true, get: function () { return facts_js_1.assignFactIds; } });
Object.defineProperty(exports, "factIds", { enumerable: true, get: function () { return facts_js_1.factIds; } });
Object.defineProperty(exports, "renderFacts", { enumerable: true, get: function () { return facts_js_1.renderFacts; } });
Object.defineProperty(exports, "unrecordedFields", { enumerable: true, get: function () { return facts_js_1.unrecordedFields; } });
var contract_js_1 = require("./contract.js");
Object.defineProperty(exports, "NO_BASIS", { enumerable: true, get: function () { return contract_js_1.NO_BASIS; } });
Object.defineProperty(exports, "buildContract", { enumerable: true, get: function () { return contract_js_1.buildContract; } });
Object.defineProperty(exports, "buildAssistantRules", { enumerable: true, get: function () { return contract_js_1.buildAssistantRules; } });
Object.defineProperty(exports, "directiveId", { enumerable: true, get: function () { return contract_js_1.directiveId; } });
Object.defineProperty(exports, "renderDirectives", { enumerable: true, get: function () { return contract_js_1.renderDirectives; } });
Object.defineProperty(exports, "buildGroundedContext", { enumerable: true, get: function () { return contract_js_1.buildGroundedContext; } });
var verify_js_1 = require("./verify.js");
Object.defineProperty(exports, "verifyAnswer", { enumerable: true, get: function () { return verify_js_1.verifyAnswer; } });
Object.defineProperty(exports, "buildRepairPrompt", { enumerable: true, get: function () { return verify_js_1.buildRepairPrompt; } });
Loading