2026-10-20 TUE UTC 12:42 PM TPE 8:42 PM Auto-updates hourly 中文 EN
LIVE WIRE
← Back to the site changelog Site Changelog

Original commit record

Harden article evidence binding Add deterministic claim-to-quote checks and source-bound entity evidence with backward-compatible evidence v2.

SHA
745e354fc76e71cf79d1e35d9e7e5f51b147725b
Commit date
Author
nnneeeooo-nnneeeooo
Committer
web-flow

Changed files (5)

  • .github/workflows/hourly.yml modified · +3 / −0
  • pipeline/CONTRACTS.md modified · +18 / −2
  • pipeline/manual_draft.py modified · +12 / −0
  • pipeline/write.py modified · +278 / −13
  • tests/test_evidence_binding.py added · +155 / −0

Full diff

@@ -11,6 +11,8 @@ on:\n       - "pipeline/adsb.py"\n       - "pipeline/flightwatch.py"\n       - "pipeline/flightnews.py"\n+      - "pipeline/write.py"\n+      - "pipeline/manual_draft.py"\n       - "pipeline/airport_codes.py"\n       - "config/airport_iata_aliases.json"\n       - "tests/**"\n@@ -48,6 +50,7 @@ jobs:\n           python tests/test_usage.py\n           python tests/test_fulltext.py\n           python tests/test_glossary.py\n+          python tests/test_evidence_binding.py\n           python tests/test_briefing.py\n           python tests/test_images.py\n           python tests/test_manufacturer_sources.py\n@@ -160,15 +160,31 @@ absent when nothing new that hour)\n                                             // (subset of write.RISK_FLAGS; non-empty\n                                             // only on human-approved articles)\n       "eventStatus": "under_investigation", // OPTIONAL write.EVENT_STATUSES value\n-      "entities": {"airlines": ["..."]}     // OPTIONAL source-grounded entities\n-                                            // (subset of write.ENTITY_KEYS arrays)\n+      "entities": {"airlines": ["KLM"]},    // OPTIONAL verified source wording\n+      "entityEvidence": [                   // OPTIONAL on legacy articles; REQUIRED\n+        {"entityType": "airlines",           // for every non-empty entity on v2 drafts\n+         "value": "KLM",\n+         "sourceQuote": "... KLM ...",\n+         "sourceUrl": "https://..."}\n+      ],\n+      "evidenceVersion": 2                  // OPTIONAL; absent on legacy articles\n     }\n   ]\n }\n ```\n \n ## data/review.json and data/review-archive.json\n \n+Evidence v2 is deliberately additive and backward-compatible. Existing\n+articles without `entityEvidence` or `evidenceVersion` remain readable.\n+New drafts must bind every structured entity value to a verbatim current-source\n+quote. The writer also deterministically checks high-risk atoms inside each\n+fact claim (explicit dates/times, counts and technical identifiers) against\n+that fact's own `sourceQuote`; facts may not borrow those details from another\n+fact. The original evidence row is retained so future deterministic\n+alias/canonical-name layers can normalize search/display values without\n+discarding source provenance.\n+\n The former human-review mechanism is retired. `data/review.json` is read only\n as a one-time migration source: rows no older than 14 days are re-checked by\n the current deterministic source, quote, binding, glossary and fabrication\n@@ -55,13 +55,15 @@\n     DRAFT_SCHEMA,\n     SYSTEM_PROMPT,\n     _evidence_binding_problem,\n+    claim_evidence_problem,\n     build_article,\n     build_flash,\n     build_incident,\n     fabrication_problem,\n     glossary_problem,\n     group_prompt,\n     validate_draft,\n+    verify_entity_evidence,\n     verify_facts,\n )\n import usage as usage_ledger  # noqa: E402\n@@ -1025,6 +1027,16 @@ def generate(job_id: str, payload: dict) -> dict:\n                 candidate["facts"] = facts\n                 problem = (\n                     _evidence_binding_problem(candidate, facts)\n+                    or claim_evidence_problem(facts)\n+                )\n+                if not problem:\n+                    clean_entities, entity_evidence, problem = \\n+                        verify_entity_evidence(candidate, group, provider.label)\n+                    if not problem:\n+                        candidate["entities"] = clean_entities\n+                        candidate["entityEvidence"] = entity_evidence\n+                problem = (\n+                    problem\n                     or glossary_problem(candidate, group)\n                     or fabrication_problem(candidate, group)\n                 )\n@@ -234,6 +234,21 @@ def simplified_chinese_error(draft: dict) -> str | None:\n     "additionalProperties": False,\n }\n \n+# Evidence for structured metadata is intentionally separate from facts.\n+# value must use wording present in sourceQuote. Future evidence versions may\n+# add deterministic alias/canonical-name resolution without weakening this.\n+_ENTITY_EVIDENCE_SCHEMA = {\n+    "type": "object",\n+    "properties": {\n+        "entityType": {"type": "string", "enum": list(ENTITY_KEYS)},\n+        "value": {"type": "string"},\n+        "sourceQuote": {"type": "string"},\n+        "sourceUrl": {"type": "string"},\n+    },\n+    "required": ["entityType", "value", "sourceQuote", "sourceUrl"],\n+    "additionalProperties": False,\n+}\n+\n DRAFT_SCHEMA = {\n     "type": "object",\n     "properties": {\n@@ -260,6 +275,11 @@ def simplified_chinese_error(draft: dict) -> str | None:\n         "summarySupportedBy": {"type": "array",\n                                "items": {"type": "string"}},\n         "entities": _ENTITIES_SCHEMA,\n+        "entityEvidence": {\n+            "type": "array",\n+            "items": _ENTITY_EVIDENCE_SCHEMA,\n+            "maxItems": 80,\n+        },\n         "eventStatus": {"type": "string", "enum": list(EVENT_STATUSES)},\n         "riskFlags": {\n             "type": "array",\n@@ -269,8 +289,8 @@ def simplified_chinese_error(draft: dict) -> str | None:\n     },\n     "required": ["status", "decisionReason", "cat", "zh", "en", "flash",\n                  "incident", "facts", "headlineSupportedBy",\n-                 "summarySupportedBy", "entities", "eventStatus",\n-                 "riskFlags", "requiresHumanReview"],\n+                 "summarySupportedBy", "entities", "entityEvidence",\n+                 "eventStatus", "riskFlags", "requiresHumanReview"],\n     "additionalProperties": False,\n }\n \n@@ -328,6 +348,18 @@ def simplified_chinese_error(draft: dict) -> str | None:\n   substring from ONE current source field or ONE selected archive fact. Aim\n   under 200 characters and never quote a whole paragraph. Every load-bearing\n   title and summary claim must bind to a fact.\n+- A fact claim may contain ONLY details supported by its OWN sourceQuote.\n+  If one sentence needs evidence from two separate excerpts, split it into\n+  two facts or choose one contiguous quote that supports the entire claim.\n+  Never borrow a date, number, aircraft/engine code, flight/registration,\n+  route, status or other detail from another fact.\n+- entityEvidence is mandatory for every non-empty value in entities. Use one\n+  evidence row per entity value. entityType is the entities key; value MUST\n+  use wording that appears verbatim in its own current SOURCE sourceQuote\n+  apart from case and whitespace. sourceQuote must itself be a verbatim\n+  contiguous SOURCE substring. Do not translate, canonicalize or infer an\n+  entity alias here; unsupported entities must be omitted. This conservative\n+  contract can later be expanded by deterministic alias tables.\n - Current facts: evidenceScope="source", archiveEventId=null,\n   archiveContext=false, and sourceUrl is the current item URL.\n - Historical facts: evidenceScope="archive", archiveContext=true,\n@@ -1131,6 +1163,18 @@ def validate_draft(draft):\n     entities = draft.get("entities")\n     if entities is not None and not isinstance(entities, dict):\n         return "entities must be an object"\n+    entity_evidence = draft.get("entityEvidence")\n+    if entity_evidence is not None:\n+        if not isinstance(entity_evidence, list):\n+            return "entityEvidence must be an array"\n+        for row in entity_evidence:\n+            if not isinstance(row, dict):\n+                return "entityEvidence entries must be objects"\n+            if row.get("entityType") not in ENTITY_KEYS:\n+                return "bad entityEvidence.entityType"\n+            for key in ("value", "sourceQuote", "sourceUrl"):\n+                if not isinstance(row.get(key), str):\n+                    return f"entityEvidence.{key} must be a string"\n     incident = draft.get("incident")\n     if incident is not None:\n         if not isinstance(incident, dict):\n@@ -1445,6 +1489,14 @@ def _validated_draft(provider, group: dict, tries: int, ai_calls=None,\n             if facts:\n                 evidence_started = time.perf_counter()\n                 problem = _evidence_binding_problem(candidate, facts)\n+                if problem is None:\n+                    problem = claim_evidence_problem(facts)\n+                if problem is None:\n+                    clean_entities, entity_evidence, problem = \\n+                        verify_entity_evidence(candidate, group, provider.label)\n+                    if problem is None:\n+                        candidate["entities"] = clean_entities\n+                        candidate["entityEvidence"] = entity_evidence\n                 if run_trace is not None:\n                     run_trace.add_duration("evidenceBinding", evidence_started)\n                 problem_stage = "evidenceBinding"\n@@ -1494,18 +1546,47 @@ def _validated_draft(provider, group: dict, tries: int, ai_calls=None,\n \n _squash = squash_text  # shared with common.item_has_material\n \n+_MONTH_NAMES = {\n+    1: ("january", "jan"), 2: ("february", "feb"),\n+    3: ("march", "mar"), 4: ("april", "apr"), 5: ("may",),\n+    6: ("june", "jun"), 7: ("july", "jul"), 8: ("august", "aug"),\n+    9: ("september", "sep", "sept"), 10: ("october", "oct"),\n+    11: ("november", "nov"), 12: ("december", "dec"),\n+}\n+_MONTH_TO_NUM = {\n+    name: number for number, names in _MONTH_NAMES.items() for name in names\n+}\n+_DATE_PATTERNS = (\n+    re.compile(r"(?P<y>20\d{2})[-/.](?P<m>0?[1-9]|1[0-2])[-/.](?P<d>0?[1-9]|[12]\d|3[01])"),\n+    re.compile(r"(?P<y>20\d{2})年(?P<m>0?[1-9]|1[0-2])月(?P<d>0?[1-9]|[12]\d|3[01])日"),\n+    re.compile(r"(?P<d>0?[1-9]|[12]\d|3[01])\s+(?P<mon>January|February|March|April|May|June|July|August|September|October|November|December)\s+(?P<y>20\d{2})", re.I),\n+    re.compile(r"(?P<mon>January|February|March|April|May|June|July|August|September|October|November|December)\s+(?P<d>0?[1-9]|[12]\d|3[01])(?:st|nd|rd|th)?[,]?\s+(?P<y>20\d{2})", re.I),\n+)\n+_TECH_ATOM_RE = re.compile(\n+    r"(?<![A-Za-z0-9])(?=[A-Za-z0-9-]*[A-Za-z])(?=[A-Za-z0-9-]*\d)"\n+    r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+(?![A-Za-z0-9])"\n+    r"|(?<![A-Za-z0-9])\d{2,4}-\d{1,3}[A-Za-z]?(?![A-Za-z0-9])"\n+    r"|(?<![A-Za-z0-9])[A-Za-z]{1,5}\d{2,5}[A-Za-z]{0,5}(?![A-Za-z0-9])"\n+)\n+_TIME_ATOM_RE = re.compile(r"(?<!\d)(?:[01]?\d|2[0-3]):[0-5]\d(?!\d)")\n+_NUMBER_ATOM_RE = re.compile(\n+    r"(?<![A-Za-z0-9.])(?:\d+\.\d+|\d{1,3}(?:,\d{3})+|\d{2,})"\n+    r"(?:\s*[-–]\s*(?:\d+\.\d+|\d{1,3}(?:,\d{3})+|\d{2,}))?"\n+    r"(?![A-Za-z0-9.])"\n+)\n+_SINGLE_COUNT_RE = re.compile(\n+    r"(?<![\d.])(?P<n>[0-9])\s*(?P<u>%|percent|people|persons|crew|aircraft|"\n+    r"jets|flights|routes|cities|countries|days|hours|minutes|人|名|架|班|條|個|次)"\n+    r"(?![A-Za-z])", re.I)\n+_NUMBER_WORDS = {\n+    "0": ("zero",), "1": ("one",), "2": ("two",), "3": ("three",),\n+    "4": ("four",), "5": ("five",), "6": ("six",), "7": ("seven",),\n+    "8": ("eight",), "9": ("nine",),\n+}\n \n-def verify_facts(draft: dict, group: dict, label: str) -> list:\n-    """Keep only facts whose sourceQuote is a verbatim substring of the\n-    material actually shown to the model (whitespace-squashed, case-folded).\n \n-    The quote requirement is enforced HERE, in code, because prompt-level\n-    rules alone cannot be trusted: a fabricated or paraphrased quote is\n-    silently dropped, and a draft with zero surviving quotes is unpublishable.\n-    """\n-    # Keep current fields separate so a quote cannot straddle fields and so\n-    # the surviving fact can be bound to the exact current source URL.\n-    source_fields = []\n+def _current_source_fields(group: dict) -> list[tuple[str, list[str]]]:\n+    fields_by_url = []\n     for item in group.get("items", []):\n         url = str(item.get("url") or "").strip()\n         if not url and item.get("source") == "SKYTICAL 資料庫" \\n@@ -1519,7 +1600,178 @@ def verify_facts(draft: dict, group: dict, label: str) -> list:\n             _squash(_clean_source_text(item.get("fulltext"))\n                     .strip()[:FULLTEXT_PROMPT_CHARS]),\n         ]\n-        source_fields.append((url, fields))\n+        fields_by_url.append((url, fields))\n+    return fields_by_url\n+\n+\n+def _date_rows(text: str):\n+    rows = []\n+    for pattern in _DATE_PATTERNS:\n+        for match in pattern.finditer(str(text or "")):\n+            try:\n+                gd = match.groupdict()\n+                month = int(gd["m"]) if gd.get("m") else _MONTH_TO_NUM[gd["mon"].casefold()]\n+                rows.append(((int(gd["y"]), month, int(gd["d"])), match.span()))\n+            except (KeyError, TypeError, ValueError):\n+                continue\n+    rows.sort(key=lambda row: row[1][0])\n+    return rows\n+\n+\n+def _date_supported(date_value, quote: str) -> bool:\n+    year, month, day = date_value\n+    q = str(quote or "").casefold()\n+    if str(year) not in q:\n+        return False\n+    if not re.search(rf"(?<!\d)0?{day}(?:st|nd|rd|th)?(?!\d)", q):\n+        return False\n+    month_names = _MONTH_NAMES.get(month, ())\n+    return (\n+        any(re.search(rf"\b{re.escape(name)}\b", q) for name in month_names)\n+        or re.search(rf"(?:^|\D)0?{month}(?:\D|$)", q) is not None\n+    )\n+\n+\n+def _normalized_atom(value: str) -> str:\n+    return re.sub(r"[\s,–—]+", "", str(value or "").casefold())\n+\n+\n+def _atom_supported(atom: str, quote: str) -> bool:\n+    target = _normalized_atom(atom)\n+    q_norm = _normalized_atom(quote)\n+    if target and target in q_norm:\n+        return True\n+    if atom in _NUMBER_WORDS:\n+        q = str(quote or "").casefold()\n+        return any(re.search(rf"\b{word}\b", q)\n+                   for word in _NUMBER_WORDS[atom])\n+    return False\n+\n+\n+def claim_evidence_problem(facts: list[dict]) -> str | None:\n+    """Block high-risk details that are absent from a fact's own quote.\n+\n+    This is deliberately deterministic instead of using a second model. It\n+    focuses on the details most damaging to aviation-news accuracy: explicit\n+    dates/times, counts and technical identifiers. The gate can be extended\n+    without changing the persisted evidence contract.\n+    """\n+    for fact in facts:\n+        fact_id = str(fact.get("factId") or "?")\n+        claim = str(fact.get("claim") or "")\n+        quote = str(fact.get("sourceQuote") or "")\n+        masked = list(claim)\n+        for date_value, (start, end) in _date_rows(claim):\n+            if not _date_supported(date_value, quote):\n+                return f"{fact_id} claim date is not supported by its sourceQuote"\n+            for index in range(start, end):\n+                masked[index] = " "\n+        residual = "".join(masked)\n+        atoms = []\n+        atoms.extend(m.group() for m in _TIME_ATOM_RE.finditer(residual))\n+        atoms.extend(m.group() for m in _TECH_ATOM_RE.finditer(residual))\n+        atoms.extend(m.group() for m in _NUMBER_ATOM_RE.finditer(residual))\n+        atoms.extend(m.group("n") for m in _SINGLE_COUNT_RE.finditer(residual))\n+        for atom in dict.fromkeys(atoms):\n+            if not _atom_supported(atom, quote):\n+                return (\n+                    f"{fact_id} claim adds high-risk atom {atom!r} not present "\n+                    "in its own sourceQuote"\n+                )\n+    return None\n+\n+\n+def verify_entity_evidence(draft: dict, group: dict, label: str):\n+    """Require every structured entity to bind to current source evidence.\n+\n+    v1 intentionally requires exact source wording. A future deterministic\n+    alias/canonicalization layer can map verified source values onto normalized\n+    display/search values while preserving these original evidence rows.\n+    """\n+    entities = draft.get("entities") or {}\n+    rows = draft.get("entityEvidence")\n+    if rows is None:\n+        rows = []\n+    if not isinstance(entities, dict) or not isinstance(rows, list):\n+        return {}, [], "entities/entityEvidence shape is invalid"\n+\n+    listed = []\n+    for key in ENTITY_KEYS:\n+        values = entities.get(key) or []\n+        if not isinstance(values, list):\n+            return {}, [], f"entities.{key} must be an array"\n+        for value in values:\n+            value = str(value or "").strip()\n+            if value:\n+                listed.append((key, value))\n+    listed_set = set(listed)\n+    if listed_set and not rows:\n+        return {}, [], "every entity requires entityEvidence"\n+\n+    source_fields = _current_source_fields(group)\n+    verified, bound = [], set()\n+    for row in rows:\n+        if not isinstance(row, dict):\n+            return {}, [], "entityEvidence entry is not an object"\n+        key = str(row.get("entityType") or "")\n+        value = str(row.get("value") or "").strip()\n+        quote = _squash(row.get("sourceQuote"))\n+        if key not in ENTITY_KEYS or not value or len(quote) < 2:\n+            return {}, [], "entityEvidence entry is incomplete"\n+        matched_url = next(\n+            (url for url, fields in source_fields\n+             if any(quote in field for field in fields)),\n+            None,\n+        )\n+        if not matched_url:\n+            return {}, [], (\n+                f"entity evidence quote not found in current source: "\n+                f"{key}={value!r}"\n+            )\n+        if _squash(value).casefold() not in quote.casefold():\n+            return {}, [], (\n+                f"entity value {value!r} is not verbatim in its sourceQuote"\n+            )\n+        pair = (key, value)\n+        if pair not in listed_set:\n+            return {}, [], (\n+                f"entityEvidence references unlisted entity {key}={value!r}"\n+            )\n+        if pair in bound:\n+            continue\n+        bound.add(pair)\n+        verified.append({\n+            "entityType": key,\n+            "value": value,\n+            "sourceQuote": str(row.get("sourceQuote") or ""),\n+            "sourceUrl": matched_url,\n+        })\n+\n+    missing = listed_set - bound\n+    if missing:\n+        key, value = sorted(missing)[0]\n+        return {}, [], f"entity lacks verified evidence: {key}={value!r}"\n+\n+    clean = {}\n+    for key in ENTITY_KEYS:\n+        values = [value for entity_key, value in listed\n+                  if entity_key == key and (entity_key, value) in bound]\n+        if values:\n+            clean[key] = list(dict.fromkeys(values))\n+    return clean, verified, None\n+\n+\n+def verify_facts(draft: dict, group: dict, label: str) -> list:\n+    """Keep only facts whose sourceQuote is a verbatim substring of the\n+    material actually shown to the model (whitespace-squashed, case-folded).\n+\n+    The quote requirement is enforced HERE, in code, because prompt-level\n+    rules alone cannot be trusted: a fabricated or paraphrased quote is\n+    silently dropped, and a draft with zero surviving quotes is unpublishable.\n+    """\n+    # Keep current fields separate so a quote cannot straddle fields and so\n+    # the surviving fact can be bound to the exact current source URL.\n+    source_fields = _current_source_fields(group)\n     verified = []\n     for fact in draft.get("facts", []):\n         quote = _squash(fact.get("sourceQuote"))\n@@ -1756,6 +2008,19 @@ def build_article(draft: dict, group: dict, now, used_ids: set, writer=None,\n                     cleaned[key] = unique\n         if cleaned:\n             article["entities"] = cleaned\n+    entity_evidence = draft.get("entityEvidence")\n+    if isinstance(entity_evidence, list):\n+        verified_rows = [\n+            row for row in entity_evidence\n+            if isinstance(row, dict)\n+            and row.get("entityType") in ENTITY_KEYS\n+            and row.get("value")\n+            and row.get("sourceQuote")\n+            and row.get("sourceUrl")\n+        ]\n+        if verified_rows:\n+            article["entityEvidence"] = verified_rows\n+            article["evidenceVersion"] = 2\n     return article\n \n \n@@ -0,0 +1,155 @@\n+"""Offline regression tests for deterministic evidence binding v2.\n+\n+Run from repository root:\n+    python tests/test_evidence_binding.py\n+"""\n+from __future__ import annotations\n+\n+import os\n+import sys\n+import tempfile\n+from pathlib import Path\n+\n+REPO = Path(__file__).resolve().parent.parent\n+os.environ["AVWIRE_DATA_DIR"] = tempfile.mkdtemp(prefix="skytical-evidence-")\n+sys.path.insert(0, str(REPO / "pipeline"))\n+\n+import write  # noqa: E402\n+\n+CHECKS = 0\n+FAILED = 0\n+\n+\n+def check(name, condition):\n+    global CHECKS, FAILED\n+    CHECKS += 1\n+    print(("PASS " if condition else "FAIL ") + name)\n+    if not condition:\n+        FAILED += 1\n+\n+\n+GROUP = {\n+    "id": "g-evidence-v2",\n+    "primarySource": "Fixture",\n+    "items": [{\n+        "source": "Fixture",\n+        "url": "https://example.test/story",\n+        "title": "KLM plans A321XLR service update",\n+        "summary": (\n+            "On September 23, 2026, KLM said it plans A321XLR service. "\n+            "The FAA identified the aircraft as a Eurocopter AS332 Super Puma. "\n+            "Two crew members were on board and the event occurred at 14:55."\n+        ),\n+        "fulltext": "",\n+    }],\n+}\n+\n+good_facts = [{\n+    "factId": "F1",\n+    "claim": "On September 23, 2026, KLM said it plans A321XLR service.",\n+    "sourceQuote": "On September 23, 2026, KLM said it plans A321XLR service.",\n+}]\n+check(\n+    "claim atoms supported by the same quote pass",\n+    write.claim_evidence_problem(good_facts) is None,\n+)\n+\n+bad_date = [{\n+    "factId": "F1",\n+    "claim": "The agreement was signed in New York on 23 September 2026.",\n+    "sourceQuote": "The deal was finalized in New York during the UN General Assembly this week.",\n+}]\n+check(\n+    "date borrowed from outside a fact quote is rejected",\n+    write.claim_evidence_problem(bad_date) is not None,\n+)\n+\n+bad_model = [{\n+    "factId": "F1",\n+    "claim": "The aircraft was a Eurocopter AS332 Super Puma.",\n+    "sourceQuote": "The helicopter was conducting water drops near Ostrander Lake.",\n+}]\n+check(\n+    "aircraft model borrowed from another fact is rejected",\n+    write.claim_evidence_problem(bad_model) is not None,\n+)\n+\n+good_entities = {\n+    "entities": {\n+        **{key: [] for key in write.ENTITY_KEYS},\n+        "organizations": ["KLM"],\n+        "aircraft_models": ["A321XLR"],\n+        "event_dates": ["September 23, 2026"],\n+    },\n+    "entityEvidence": [\n+        {\n+            "entityType": "organizations",\n+            "value": "KLM",\n+            "sourceQuote": "On September 23, 2026, KLM said it plans A321XLR service.",\n+            "sourceUrl": "https://example.test/story",\n+        },\n+        {\n+            "entityType": "aircraft_models",\n+            "value": "A321XLR",\n+            "sourceQuote": "On September 23, 2026, KLM said it plans A321XLR service.",\n+            "sourceUrl": "https://example.test/story",\n+        },\n+        {\n+            "entityType": "event_dates",\n+            "value": "September 23, 2026",\n+            "sourceQuote": "On September 23, 2026, KLM said it plans A321XLR service.",\n+            "sourceUrl": "https://example.test/story",\n+        },\n+    ],\n+}\n+clean, evidence, problem = write.verify_entity_evidence(\n+    good_entities, GROUP, "fixture")\n+check("source-bound entities verify", problem is None)\n+check("verified entities are retained", clean.get("organizations") == ["KLM"])\n+check("evidence rows are persisted-ready", len(evidence) == 3)\n+\n+missing = {\n+    "entities": {**{key: [] for key in write.ENTITY_KEYS},\n+                 "organizations": ["KLM"]},\n+    "entityEvidence": [],\n+}\n+check(\n+    "entity without evidence is rejected",\n+    write.verify_entity_evidence(missing, GROUP, "fixture")[2] is not None,\n+)\n+\n+invented_alias = {\n+    "entities": {**{key: [] for key in write.ENTITY_KEYS},\n+                 "organizations": ["KLM Royal Dutch Airlines"]},\n+    "entityEvidence": [{\n+        "entityType": "organizations",\n+        "value": "KLM Royal Dutch Airlines",\n+        "sourceQuote": "On September 23, 2026, KLM said it plans A321XLR service.",\n+        "sourceUrl": "https://example.test/story",\n+    }],\n+}\n+check(\n+    "unquoted canonicalized entity alias is rejected",\n+    write.verify_entity_evidence(\n+        invented_alias, GROUP, "fixture")[2] is not None,\n+)\n+\n+wrong_quote = {\n+    "entities": {**{key: [] for key in write.ENTITY_KEYS},\n+                 "organizations": ["KLM"]},\n+    "entityEvidence": [{\n+        "entityType": "organizations",\n+        "value": "KLM",\n+        "sourceQuote": "KLM invented quote that is not in the source.",\n+        "sourceUrl": "https://example.test/story",\n+    }],\n+}\n+check(\n+    "fabricated entity quote is rejected",\n+    write.verify_entity_evidence(wrong_quote, GROUP, "fixture")[2] is not None,\n+)\n+\n+if FAILED:\n+    print(f"{FAILED}/{CHECKS} evidence-binding checks failed")\n+    raise SystemExit(1)\n+print(f"PASS: {CHECKS} evidence-binding checks")

You found a secret theme!

8-bit mode is unlocked. Refresh to add it to your theme selector.