Skip to main content

Command Palette

Search for a command to run...

A Deterministic Gate for Risky Changes in AI-Generated Text

Catch changed dates, amounts, units, negations, and protected terms before a draft is released.

Updated
6 min readView as Markdown
M
Indie game developer and web publisher building free browser-based games and web tools for international audiences. I create games using HTML5 and Cocos Creator, covering physics puzzles, bubble shooters, sorting games, and two-player local multiplayer — all playable instantly in any browser with no download or login required. My projects: PhyFun — physics and casual browser games SortFun — logic and sorting puzzle games 2 Player Fun — local multiplayer browser games RandTap — free online random tools I write about indie game development, HTML5 game engines, browser game SEO, and the business side of running a solo web publishing portfolio.

This article was written with AI assistance. I reviewed the code, examples, and claims before publication. I build Grow AI Skills, the project linked near the end.

AI writing systems are good at producing fluent revisions. Fluency, however, does not tell us whether a revision preserved the facts and release constraints that matter.

A draft can look cleaner while quietly changing USD 24,000 to USD 42,000, moving a date by one day, dropping the word not, or altering a protected project name. These changes are small at the token level and potentially large at the decision level.

This tutorial builds a deterministic review gate for those changes. The gate does not decide which version is true. It turns a short list of high-risk differences into a ledger that a human can trace back to an approved source.

Start with a bounded contract

Assume this is the source of record:

Project Cedar must not ship before September 18, 2026.
The budget cap is USD 24,000.
Mina Okafor must approve the release.

The AI draft says:

Project Cedar may ship before September 19, 2026.
The budget cap is USD 42,000.
Mina Okafor may review the release.

A useful gate can make five narrow checks:

  1. Did a protected name appear or disappear?
  2. Did a money token change?
  3. Did a date token change?
  4. Did a measured unit change?
  5. Did a negation appear or disappear?

That is intentionally smaller than “fact-check the draft.” It is a contract we can test.

Extract tokens by category

The first step is a small set of explicit patterns:

const patterns = {
  Money: /(?:[$€£]\s?\d[\d,]*(?:\.\d+)?|\b(?:USD|EUR|GBP)\s?\d[\d,]*(?:\.\d+)?\b|\b\d[\d,]*(?:\.\d+)?\s?(?:USD|EUR|GBP)\b)/gi,
  Date: /(?:\b\d{4}-\d{2}-\d{2}\b|\b(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s+\d{1,2}(?:,)?\s+\d{4}\b)/gi,
  Unit: /\b\d+(?:\.\d+)?\s?(?:%|kg|g|km|m|cm|mm|hours?|days?|minutes?|seconds?)\b/gi,
  Negation: /\b(?:not|no|never|without|cannot|can't|won't|do not|does not|did not|isn't|aren't|must not)\b/gi,
};

These patterns are not universal parsers. They are review rules for a known content workflow. Add currencies, date formats, or units only when the source material requires them.

Normalize matches before comparison so casing and repeated whitespace do not create noise:

const normalize = (value) =>
  value.toLowerCase().replace(/\s+/g, " ").trim();

function countMatches(text, pattern) {
  const counts = new Map();

  for (const match of text.match(pattern) || []) {
    const key = normalize(match);
    const entry = counts.get(key) || { label: match, count: 0 };
    entry.count += 1;
    counts.set(key, entry);
  }

  return counts;
}

Counting matters. A draft that retains one instance of a date but removes the second instance should not pass just because the token still exists somewhere.

Build one row for each changed token

Compare the counted maps and return only mismatches:

function compareCategory(source, draft, category, pattern) {
  const before = countMatches(source, pattern);
  const after = countMatches(draft, pattern);
  const keys = new Set([...before.keys(), ...after.keys()]);
  const rows = [];

  for (const key of keys) {
    const a = before.get(key);
    const b = after.get(key);

    if ((a?.count || 0) === (b?.count || 0)) continue;

    rows.push({
      risk:
        category === "Negation" ||
        category === "Money" ||
        category === "Date"
          ? "High"
          : "Review",
      category,
      source: a ? `${a.label} × ${a.count}` : "Not in source",
      draft: b ? `${b.label} × ${b.count}` : "Missing from draft",
      action:
        a && !b
          ? "Confirm whether removal is allowed."
          : "Trace the added or changed value to an approved source.",
    });
  }

  return rows;
}

For the budget example, the ledger will contain one row for the removed USD 24,000 token and another for the added USD 42,000 token. Keeping both rows is useful because each asks a different review question.

Treat protected terms separately

Names, product labels, policy titles, and identifiers often do not fit a generic pattern. Pass them as an explicit list:

function compareProtected(source, draft, terms) {
  return terms.flatMap((term) => {
    const before = normalize(source).includes(normalize(term));
    const after = normalize(draft).includes(normalize(term));

    if (before === after) return [];

    return [{
      risk: "High",
      category: "Protected term",
      source: before ? term : "Not in source",
      draft: after ? term : "Missing from draft",
      action: "Verify the exact spelling and approved replacement.",
    }];
  });
}

The list should come from the job, not from a global pile of keywords. A contract for one release might protect a project codename and approver. A separate contract might protect a medicine name, legal entity, or model number.

Combine the checks into one deterministic gate

function buildLedger(source, draft, protectedTerms) {
  return [
    ...compareProtected(source, draft, protectedTerms),
    ...Object.entries(patterns).flatMap(([category, pattern]) =>
      compareCategory(source, draft, category, pattern)
    ),
  ];
}

Call it with the source, the proposed draft, and the terms that the workflow says must remain stable:

const rows = buildLedger(sourceText, draftText, [
  "Project Cedar",
  "Mina Okafor",
]);

if (rows.length > 0) {
  console.table(rows);
  // Block release and route the rows to a named reviewer.
}

The important behavior is not the table formatting. It is that a non-empty ledger prevents an automatic release and produces a concrete review queue.

Test damaging changes, not just the happy path

A gate needs negative tests. At minimum, keep fixtures for:

  • an unsupported value added to the draft;
  • a required unknown or limitation removed from the draft;
  • a release state changed from “needs approval” to “approved”;
  • a protected name deleted or altered;
  • a negation removed from an instruction.

For this example, assert that the changed date, changed money, and missing negation all produce rows. Then assert that identical inputs produce an empty ledger.

const unchanged = buildLedger(sourceText, sourceText, [
  "Project Cedar",
  "Mina Okafor",
]);

console.assert(unchanged.length === 0);
console.assert(rows.some((row) => row.category === "Money"));
console.assert(rows.some((row) => row.category === "Date"));
console.assert(rows.some((row) => row.category === "Negation"));

The negative fixtures protect the gate itself. Without them, a refactor can make the interface look healthy while silently weakening a check.

Put the ledger in a human workflow

A practical release flow can stay simple:

source packet + AI draft
          ↓
deterministic comparison
          ↓
empty ledger? ── no ──> named reviewer resolves each row
     │                         │
    yes                        └──> run the comparison again
     ↓
other required checks
     ↓
human release decision

Keep three states separate:

  • Detected: the rule found a token-level difference.
  • Resolved: a reviewer traced the difference and recorded the decision.
  • Approved: the authorized person accepted the complete release, including checks outside this gate.

Detection is not resolution, and a clean ledger is not approval.

Know what this approach cannot prove

This checker will miss paraphrases that contain none of the tracked tokens. It does not know whether a source is current, complete, licensed, or correct. Regular expressions can also over-match or under-match unfamiliar formats.

Those are reasons to keep the claim narrow. The gate proves only that its configured checks ran against the supplied texts and returned the recorded result.

If you want to try the workflow without uploading text, I maintain a browser-local AI Output Change Checker. It covers protected terms, names, dates, amounts, units, and negations, with CSV and JSON export for a review record.

The tool is still a review aid. A zero-result comparison does not prove factual accuracy or authorize release. Human review remains the final boundary.