Keep a local LLM honest. Instead of letting a model rewrite your application state (and hallucinate it into an illegal shape), the model only proposes typed deltas. A deterministic engine validates every delta against your rules and applies only the legal ones. The model proposes; the engine decides.
Zero dependencies. Works with any OpenAI-compatible endpoint (local or hosted). Built for small, fast, local models, which is exactly where hallucination and malformed output hurt most.
Applied:
✓ t2 → done
✓ ana → t1
Rejected (engine refused — anti-hallucination):
✗ ASSIGN — unknown person zoe # model invented an id
✗ ASSIGN — cannot reassign a done task # broke a domain rule
✗ SET_STATUS — invalid status shipped # value not in the enum
✗ NUKE_EVERYTHING — unknown op # not a real operation
Give an LLM write access to your state and it will, sooner or later, reference an entity that doesn't exist, set a field to a nonsense value, or claim an outcome it shouldn't decide. Telling it "please don't" in the prompt is not a guarantee.
This flips the contract:
- The engine owns the truth. State lives in your code; the model never mutates it directly.
- The model is handed only valid identifiers (your
context) and may reference nothing else. - The model emits typed deltas via a forced tool call:
{ op, ...args }. - Every delta is validated against your
validate(state, args)rules beforeapply. Illegal deltas are rejected with a reason, never silently applied. - Numbers/outcomes are computed by your code, not asserted by the model.
The result: the worst a hallucinating model can do is get rejected. Your state can never enter an illegal shape.
git clone <this repo> && cd grounding-engine
node examples/taskboard.js # offline: feeds a mixed (legal + illegal) proposal
node examples/taskboard.js --live # calls a local OpenAI-compatible modelFor --live, point it at your server:
AL_LLM_URL=http://localhost:8088/v1 AL_LLM_MODEL=<your-model-id> node examples/taskboard.js --liveNode 18+ (uses built-in fetch). No npm install needed.
import { GroundingEngine, createLLM } from "grounding-engine";
const state = { people: { ana: {} }, tasks: { t1: { status: "todo", assignee: null } } };
const operations = {
ASSIGN: {
description: "Assign a task to a person",
params: { task: { type: "string" }, person: { type: "string" } },
validate: (s, a) =>
!s.tasks[a.task] ? { ok: false, reason: "unknown task" }
: !s.people[a.person] ? { ok: false, reason: "unknown person" }
: { ok: true },
apply: (s, a) => { s.tasks[a.task].assignee = a.person; return `${a.person} → ${a.task}`; },
},
};
// the ONLY facts/ids the model is allowed to reference:
const context = (s) => ({ people: Object.keys(s.people), tasks: Object.keys(s.tasks) });
const engine = new GroundingEngine({ state, operations, context, llm: createLLM(), domain: "task board" });
const { narrative, applied, rejected } = await engine.propose("Put Ana on t1.");You don't need a model to use the core. Feed deltas straight in (great for tests):
const { applied, rejected } = engine.applyDeltas([
{ op: "ASSIGN", task: "t1", person: "ana" }, // applied
{ op: "ASSIGN", task: "t1", person: "zoe" }, // rejected: unknown person
]);new GroundingEngine({ state, operations, context, llm?, domain? })engine.applyDeltas(deltas)→{ applied, rejected }: the deterministic validate+apply core.await engine.propose(instruction)→{ narrative, applied, rejected }: full LLM loop.engine.tool()/engine.systemPrompt(): the generated tool schema and prompt, if you want to drive the LLM yourself.createLLM({ baseUrl, model, apiKey, noThink }): OpenAI-compatible client.noThinksendsreasoning_effort:"none"(skips a model's thinking pass when supported, ignored otherwise).
operations map shape:
OP_NAME: {
description, // shown to the model
params: { field: { type, enum? } }, // JSON-schema-ish, merged into the tool
validate(state, args) -> { ok } | { ok: false, reason },
apply(state, args) -> string|void, // mutate state; return an optional log line
}
- Forced tool call + a union params object keeps output structured even when the model can't do strict per-op schemas.
- Defensive JSON parsing recovers from truncated/oversized tool arguments (brace-balanced fallback), with one retry and a safe empty fallback, so a bad generation degrades to "nothing applied", never to corruption.
MIT © eauchs