GNL
Docs menu
Core · Free@gnldev/durable

Semantic duplicate-candidate gate

This layer finds past side-effect work that LOOKS similar in meaning ("create product ABC" said two different ways — where hash-based dedup is blind) and, ONLY when the deterministic field comparison also matches (tool name, identity fields, amount fields), puts the first result next to an approval question.

What it's for / when to use it#

It never silently skips, blocks or tells the model "already done" on your behalf; the final word is always deterministic field equality plus a human. It is best-effort and fail-open: if your embedder is unreachable or no candidate clears the bar, behavior is today's behavior — no regression, and no guarantee either.

The decision hierarchy: deterministic > human gate > probabilistic. This layer is the third class, serving the first two as a candidate finder. It does not replace the hash/claim/confirm/critical layers; it runs beneath them, and because its only exit is the approval question it refuses to start where no approvals channel exists — action: 'suspend' and scope: 'thread' are config-time requirements.

We measured where these rungs earn their keep, and it was not where we first assumed. A competent tool model canonicalises for you: "one more of that television" comes back as TV-42, byte-identical to the earlier call, so the exact-hash layer takes it and the semantic rungs never get their turn. This layer earns its keep where the model passes text through as written — support-ticket subjects, customer and company names, free-text descriptions.

Double opt-in#

Both declarations are required; with either one absent the layer is entirely inert: the run-level limits.sideEffectDuplicates.semantic block, and the tool-level semanticIdentity declaration. A tool that does not declare its identity never passes through this layer at all — and leaving keys empty is a config-time throw, because a gate that looks installed and does nothing is precisely the false confidence it exists to prevent.

The chain: who finds the candidate, who decides#

The order is fixed, and each rung takes over the residue the previous one left. None of them can conclude anything on its own:

constexact-hash

Exact hash/marker speaks first. A repeat arriving with the same bytes is settled there; this layer only gets its turn once the hash has missed.

consttoolName + thread

Candidates come only from the SAME tool and the SAME conversation — records are keyed by tool name and thread.

constminSimilarity

The best topK records whose cosine similarity clears minSimilarity (default 0.6) become candidates. The threshold is deliberately generous: a miss on this side is the safe direction — it costs an unasked question.

constidentity / amount / discriminator

The deterministic phase decides: are the normalized identity fields equal, do the amount fields differ, do the discriminator fields (cancel/direction flags) differ. Negation and magnitude are settled here, never by the vector.

construles

The rule ladder (optional, rules): a deterministic, free and journaled rung for candidates whose identity fields are NOT equal. Normalizers may raise a question, separators may drop the candidate — both are algorithms, not lists.

constjudge

The judge (optional, judge): it sees only the gray residue and answers one question — "do these two records name the same real-world thing?". A 'same' answer buys exactly one thing: a human is asked.

const'suspend'

One exit: suspend — the question goes to a human; approval creates the second job for real, denial does not. Every other outcome is today's behavior and leaves a journaled counter or record.

Setup / import#

The layer lives in @gnldev/durable; you own the embed closure, the provider and the bill:

import { createGnl, gnlTool, type RunLimits } from '@gnldev/durable';

Step-by-step usage#

Start with the run block and the tool declaration. embedModelId is a required stamp: vectors from different models are apples and oranges, and a record whose stamp differs is excluded from the comparison rather than silently compared.

double opt-in: the run block + the tool declaration
// Double opt-in: the run-level block AND the tool-level declaration — either absent, layer inert.
const limits: RunLimits = {
  sideEffectDuplicates: {
    action: 'suspend', scope: 'thread',            // required — config-time throw otherwise
    semantic: {
      embed: myEmbed,                              // (texts: string[]) => Promise<number[][]>
      embedModelId: 'text-embedding-3-small',      // required stamp — mixed-model cosine is meaningless
      minSimilarity: 0.6,                          // candidate threshold (recall side; misses are safe)
    },
  },
};

const createProduct = gnlTool(tool({ /* … */ }), {
  sideEffect: true,
  semanticIdentity: {
    keys: ['sku'],                                 // the business identity — REQUIRED, non-empty
    amountFields: ['price'],                       // identity-equal + amounts differ → its own question
    discriminatorFields: ['cancel'],               // negation gate: differ → deterministically not a duplicate
    describe: (args: any) => `create product ${args.sku}`, // the PII boundary: ONLY this reaches the embedder
  },
});

For the critical profile the recommended setup is a local embedder: it removes the API bill, the rate limit and the PII question in one move. Stamp the quantization too — a re-quantized model produces different vectors. The same closure also serves @gnldev/memory's semantic recall.

local, in-process embeddings — nothing leaves the machine
// npm i @huggingface/transformers   (~150-400MB RAM at runtime, ~5-20ms per short sentence on CPU)
import { pipeline } from '@huggingface/transformers';

const extractor = await pipeline('feature-extraction', 'Xenova/multilingual-e5-small', { dtype: 'q8' });
const myEmbed = async (texts: string[]) => {
  // e5 family quirk: inputs want a "query: " prefix — bake it into the adapter, never into callers.
  const out = await extractor(texts.map((s) => `query: ${s}`), { pooling: 'mean', normalize: true });
  return out.tolist();
};
// embedModelId: 'local:multilingual-e5-small@q8'  ← stamp the QUANTIZATION too: a re-quantized model
// produces different vectors, and the stamp is what keeps old records out of the comparison.

Records live in YOUR journal under the thread prefix (~3KB each; no vector database, no index) and die with the thread in the same purgeThread sweep as everything else. Nothing is written for failed or suspended work. Cost model: ~1 embed call per guarded side-effect call on the happy path.

The judge's certificate — an unexamined judge is refused#

The certificate is not ceremony. On identical fixtures with the identical prompt we measured it: one model answered 43% of the paraphrase pairs correctly and another 100%. A third, from a family unrelated to the one that wrote the fixtures, scored 93% — so the spread is about the model, not about whose phrasing it recognises.

That is why a judge you have not measured is a layer that looks installed and is not. A missing, weak (recall below 0.70 or false alarms above 0.05), model-mismatched or prompt-version-mismatched certificate is a config-time throw — the sibling of v1's empty-keys throw. Swapping the model or upgrading past a prompt-version bump invalidates it and the exam must be re-sat.

semantic: {
  embed: myEmbed, embedModelId: 'local:multilingual-e5-small@q8',
  rules: true,                                  // the deterministic ladder — the whole configuration
  judge: {
    // TRANSPORT ONLY: the framework renders the prompt, you own the model and the bill.
    complete: async ({ system, user }) => (await myModel(system, user)).text,
    judgeModelId: 'your-judge-model',
    qualification: cert,                        // from @gnldev/semantic-qualify — REQUIRED
    maxCallsPerRun: 10,                         // journal-backed slots; survive resume
    timeoutMs: 8000,
  },
},
npx gnl-semantic-qualify --judge ./my-judge.mjs --model your-judge-model   # → gnl-judge-cert.json

The bench evaluates blind: opaque ids, shuffled order, labels never sent to the model, and the bars read from the same exported constants the runtime enforces. Point it at your own fixtures with --fixtures; the published set stops being held out the moment it is published. And the certificate stamps EXAM performance, not field accuracy.

Count first, judge second. Studio's semantic card reports scan.grayCalls: the number of CALLS that produced a gray residue — the price quote for what a judge would cost you. On measured traffic the judge spoke 12 times (across 126 tool-calling turns) and none of them produced a question; the gray band was genuinely made of different work. Run with rules on and judge off for a while, read the counter, then decide.

Honest limits#

All five are deliberate boundaries, and none of them was left to be discovered later:

constscope: 'thread'

The scope is the thread: this layer answers "was this done EARLIER IN THIS CONVERSATION". A paraphrase across two conversations is not surfaced. When the identity is equal after normalization that case is covered by XID — a separate, deterministic layer.

constfail-open

Fail-open: if the embedder is unreachable, if no candidate clears the bar, or if the stamp does not match, behavior is today's behavior. The layer is best-effort and does not stand in for the hash/claim/confirm/critical layers.

constTOCTOU

Concurrent paraphrase twins cannot see each other: different hashes, same identity, in flight together — both run. This layer's promise is repeats SEPARATED IN TIME; concurrency belongs to the hash/lock layers below it.

constsemanticIdentity.keys

The quality of the declaration is the quality of the protection. A live example: two orders bound for different warehouses looked identical to the gate because createOrder had no warehouse field in its schema. The gate can only see what the tool call CARRIES — that is a schema fix before it is a semanticIdentity fix.

constdescribe()

Mixed-language term pairs are a measured blind spot: "Karanlık mod" and "Dark mode" score below the candidate threshold, and a record the recall gate never surfaces is seen by neither the rules nor the judge — the miss is silent and final. The remedy is to normalize identity fields that can carry the same term in more than one language to ONE canonical language inside describe(); the framework deliberately ships no dictionary.

API reference#

typeSemanticDupConfig

The run-level block: embed (a closure taking texts and returning vectors), embedModelId (required stamp), minSimilarity (candidate threshold), topK, rules, judge.

typeSemanticIdentity

The tool-level declaration: keys (business-identity fields — required, non-empty), describe (the canonical sentence and the PII boundary), amountFields (magnitude gate), discriminatorFields (negation gate).

typeSemanticJudgeConfig

Judge configuration: complete (TRANSPORT ONLY — the framework renders the prompt, you own the model), judgeModelId, qualification, maxCallsPerRun (default 10, journal-backed slots), timeoutMs.

typeJudgeCert

The exam result produced by the @gnldev/semantic-qualify bench, pasted into config as data. If the model id or the prompt version does not match, the run does not start.

typeRunLimits.sideEffectDuplicates.semantic

The semantic block on the run limits. action 'suspend' and scope 'thread' are required; any other value would turn the layer into a silent or blocking decider.

The model-notification ban
The model is never told that the work ran before while it is DECIDING — and neither is the judge. If it were, it could skip the call on its own and you would have silent, unauditable dedup. The judge sees only the two canonical sentences plus the tool name: never the raw args, the amounts or the discriminators.
What the vector does not do
The score only finds candidates; negation and magnitude are settled by the structured fields. The rule ladder carries no guarantee language, the judge neither decides nor approves, and "a qualified judge" never equals "reliable protection" — the live precision@suspend number is the only evidence that the questions were worth asking. No synthetic measurement here is a field-accuracy promise.
Related
See duplicate-guard for the ladder this layer runs beneath, exactly-once-tools for the per-run guarantee it builds on, and human-in-loop-approvals for the Approvals flow that suspend routes into.