feat(skills): add fact-checking mode to grounded-citations

Extends the citation ledger with evidence-backed fact-checking:

- New `quote` subcommand attaches verbatim supporting quotes to a
  source; the quote is rejected unless it appears verbatim
  (whitespace/case-insensitive) in the fetched page text, so a
  paraphrase or misremembered figure cannot masquerade as evidence.
- `verify --evidence` fails a draft whose cited sources carry no
  attached quote.
- `render --style evidence` prints each source's quotes beneath its
  URL, showing the claim -> source -> exact-text chain.
- `[unverified]` marker declares model-knowledge claims; counts toward
  --min-coverage so provenance is declared for every sentence without
  forcing fake citations.
- SKILL.md: new Fact-Checking Mode section + pitfalls; version 1.1.0.
- 10 new tests (40 total), all proven live by sabotage runs.

Covers the fact-checking/evidence-transparency half of #28289.
This commit is contained in:
Teknium 2026-08-02 14:36:47 -07:00
parent 8d755d0748
commit 4660673a3c
4 changed files with 367 additions and 14 deletions

View File

@ -1,7 +1,7 @@
---
name: grounded-citations
description: "Ground answers and documents in cited, verifiable sources."
version: 1.0.0
version: 1.1.0
author: Hermes Agent + Teknium
license: MIT
platforms: [linux, macos, windows]
@ -19,6 +19,11 @@ Every claim taken from an outside source gets an inline numbered citation and a
so the numbers and URLs come from retrieval, never from memory — the model only
ever emits small integers it was handed.
For high-stakes work the same ledger doubles as a fact-checking chain: verbatim
quotes are attached to each source (rejected unless they literally appear in
the fetched page text), claims from model knowledge are flagged `[unverified]`,
and `verify --evidence` fails any draft whose cited sources carry no evidence.
This skill covers answers in chat, written documents (markdown, PDF, docx,
slides), and research reports. It does not cover academic BibTeX pipelines —
for conference papers use the `research-paper-writing` skill, which this skill
@ -72,10 +77,11 @@ id within a ledger, so ids stay stable across many search/extract rounds.
| Register a source, get its id | `sources.py add <url> [--title T]` |
| Register several at once | `sources.py add <url1> <url2> ...` |
| Register from JSON tool output | `sources.py ingest results.json` |
| Attach verbatim evidence to a source | `sources.py quote <id> --text "exact wording" --from page.txt` |
| Show ledger | `sources.py list [--json]` |
| Render the Sources block | `sources.py render [--style markdown\|plain\|footnotes\|bibtex] [--only 1,3]` |
| Render the Sources block | `sources.py render [--style markdown\|plain\|footnotes\|bibtex\|evidence] [--only 1,3]` |
| Render only what a draft cites | `sources.py render --cited-in draft.md` |
| Check a draft's citations | `sources.py verify draft.md [--strict] [--min-coverage 0.6]` |
| Check a draft's citations | `sources.py verify draft.md [--strict] [--min-coverage 0.6] [--evidence]` |
## Procedure
@ -119,6 +125,53 @@ sources, cite inline, end with the rendered `Sources:` list. For a short answer
you may render the block from `sources.py render --only <ids>` instead of
writing to a file.
## Fact-Checking Mode
For work where the reader must be able to check the chain — medical, legal,
financial, safety, disputed claims, or when the user asks for fact-checking —
upgrade from citations to evidence:
**Attach a verbatim quote per source.** After extracting a page, save its
text to a file and attach the sentence(s) that carry each claim:
```bash
python3 "$S" quote 1 --text "Ice is about 9% less dense than liquid water." --from page1.txt
```
The quote is rejected unless it appears verbatim in the evidence text
(whitespace- and case-insensitive), so a paraphrase or misremembered figure
cannot masquerade as evidence. Copy-paste from the fetched text; never retype.
**Flag model-knowledge claims with `[unverified]`.** A load-bearing claim
you could not source gets an explicit marker instead of a citation:
```
The refactor likely predates the 2.0 release.[unverified]
```
`verify --min-coverage` counts `[unverified]` sentences as covered — the goal
is declared provenance for every claim, not a citation on every sentence.
If a key claim can be checked, check it; `[unverified]` is for what genuinely
cannot be, and a fact-check deliverable dominated by `[unverified]` markers
should say so in its summary.
**Cross-check disputed facts against a second independent source.** When two
sources disagree, cite both readings with their own ids and quotes, and say
which you weight and why. One source is reporting; two independent sources are
corroboration.
④ **Verify with the evidence gate and render the evidence block:**
```bash
python3 "$S" verify report.md --evidence --min-coverage 0.5
python3 "$S" render --style evidence --cited-in report.md
```
`--evidence` fails the draft if any cited source has no attached quote. The
`evidence` render style prints each source's quotes beneath its URL, so the
deliverable shows claim → source → exact supporting text with nothing taken on
faith.
## Pitfalls
- **Registering after writing.** The ledger must be populated from tool output,
@ -139,6 +192,14 @@ writing to a file.
- **Parallel subagents.** Each subagent has its own working directory; point
them all at one ledger with `--ledger` (or `HERMES_CITATION_LEDGER`) if their
outputs get merged, otherwise their ids will collide.
- **Quoting from a snippet instead of the page.** Evidence quotes must come
from the extracted page text, not a search-result description — `web_extract`
first, save the text, then `quote --from` that file.
- **Paraphrasing into `quote --text`.** The verbatim check will reject it; the
fix is to find the actual sentence, not to reword until something matches.
- **Using `[unverified]` as an escape hatch.** It marks the rare claim that
genuinely cannot be sourced; if most sentences carry it, the task needed more
retrieval, not more markers.
## Verification

View File

@ -11,10 +11,18 @@ Subcommands
reset start a clean ledger
add URL [URL ...] register source(s), print their ids
ingest FILE|- register every url found in JSON tool output
quote ID --text T --from FILE|- attach verbatim supporting evidence to a source
list show the ledger
render render a Sources block
verify DRAFT check a draft's citations against the ledger
Fact-checking is evidence-backed citation: ``quote`` only accepts text that
literally appears in the fetched page text you point it at, ``verify
--evidence`` requires every cited source to carry at least one such quote, and
``render --style evidence`` prints the quotes under each source so the reader
can check the chain themselves. Claims from model knowledge are declared with
an ``[unverified]`` marker rather than silently blended in.
Ledger path resolution (first wins):
--ledger PATH
$HERMES_CITATION_LEDGER
@ -45,6 +53,8 @@ _SOURCES_HEADER_RE = re.compile(r"^\s*(?:#{1,6}\s*)?(?:\*\*)?sources:?(?:\*\*)?\
_SOURCE_LINE_RE = re.compile(r"^\s*\[(\d{1,4})\]\s*[-:]?\s*(\S+)")
_URL_IN_TEXT_RE = re.compile(r"https?://[^\s\"'<>)\]}]+")
_FENCE_RE = re.compile(r"^\s*(?:```|~~~)")
# Explicit declaration that a claim comes from model knowledge, not a source.
_UNVERIFIED_RE = re.compile(r"\[unverified\]", re.IGNORECASE)
# ---------------------------------------------------------------------------
@ -214,6 +224,49 @@ def urls_from_json(payload: Any) -> list[tuple[str, str]]:
return found
# ---------------------------------------------------------------------------
# Evidence quotes (fact-checking)
# ---------------------------------------------------------------------------
def _normalize_ws(text: str) -> str:
"""Collapse all whitespace runs to single spaces for verbatim matching."""
return " ".join((text or "").split())
def quote_in_evidence(quote: str, evidence: str) -> bool:
"""True when ``quote`` appears verbatim (whitespace-insensitively,
case-insensitively) in the fetched ``evidence`` text."""
q = _normalize_ws(quote).casefold()
return bool(q) and q in _normalize_ws(evidence).casefold()
def attach_quote(path: Path, source_id: int, quote: str, evidence: str) -> dict[str, Any]:
"""Attach a verbatim quote to a ledger entry after checking it against
the evidence text. Raises SystemExit on unknown id or non-verbatim text
a quote the page does not contain is exactly the fabrication this guards
against."""
quote = (quote or "").strip()
if len(_normalize_ws(quote).split()) < 3:
raise SystemExit("error: quote too short — use at least 3 words of verbatim text")
if not quote_in_evidence(quote, evidence):
raise SystemExit(
"error: quote not found verbatim in the evidence text — "
"copy the exact wording from the fetched page, do not paraphrase"
)
with _LedgerLock(path):
data = load_ledger(path)
entry = next((s for s in data["sources"] if s["id"] == source_id), None)
if entry is None:
raise SystemExit(f"error: no source [{source_id}] in the ledger")
quotes = entry.setdefault("quotes", [])
norm = _normalize_ws(quote).casefold()
if not any(_normalize_ws(q.get("text", "")).casefold() == norm for q in quotes):
quotes.append({"text": quote, "added": time.strftime("%Y-%m-%d")})
save_ledger(path, data)
return entry
def render_sources(
sources: list[dict[str, Any]],
style: str = "markdown",
@ -247,6 +300,9 @@ def render_sources(
title = s.get("title")
suffix = f"{title}" if title else ""
lines.append(f"[{s['id']}] {s['url']}{suffix}")
if style == "evidence":
for q in s.get("quotes", []):
lines.append(f' > "{q.get("text", "")}"')
return "\n".join(lines)
@ -309,6 +365,7 @@ def verify_draft(
sources: list[dict[str, Any]],
strict: bool = False,
min_coverage: float | None = None,
require_evidence: bool = False,
) -> tuple[int, list[str], list[str]]:
"""Return (exit_code, errors, warnings)."""
text = draft_path.read_text(encoding="utf-8")
@ -365,22 +422,36 @@ def verify_draft(
sentences = _sentences(prose)
cited_sentences = [s for s in sentences if _CITE_RE.search(s)]
coverage = (len(cited_sentences) / len(sentences)) if sentences else 0.0
unverified_sentences = [s for s in sentences if _UNVERIFIED_RE.search(s)]
covered = [s for s in sentences if _CITE_RE.search(s) or _UNVERIFIED_RE.search(s)]
coverage = (len(covered) / len(sentences)) if sentences else 0.0
if min_coverage is not None and sentences and coverage < min_coverage:
errors.append(
f"citation coverage {coverage:.0%} is below the required {min_coverage:.0%} "
f"({len(cited_sentences)}/{len(sentences)} sentences cited)"
f"({len(covered)}/{len(sentences)} sentences cited or marked [unverified])"
)
if require_evidence:
unevidenced = sorted(
i for i in cited_set if i in by_id and not by_id[i].get("quotes")
)
if unevidenced:
errors.append(
"cited sources carry no verbatim evidence quote (run `quote` with the "
"fetched page text): " + ", ".join(f"[{i}]" for i in unevidenced)
)
over_cited = [s for s in sentences if len(_CITE_RE.findall(s)) > 3]
if over_cited:
warnings.append(f"{len(over_cited)} sentence(s) carry more than 3 citations")
code = 1 if errors else (1 if (strict and warnings) else 0)
quoted = sum(1 for s in sources if s.get("quotes"))
stats = (
f"{len(sentences)} prose sentence(s), {len(cited_sentences)} cited "
f"({coverage:.0%}), {len(cited_set)} distinct source(s) cited, "
f"{len(by_id)} in ledger"
f"{len(sentences)} prose sentence(s), {len(cited_sentences)} cited, "
f"{len(unverified_sentences)} marked [unverified] ({coverage:.0%} covered), "
f"{len(cited_set)} distinct source(s) cited, "
f"{len(by_id)} in ledger ({quoted} with evidence quotes)"
)
warnings.insert(0, f"stats: {stats}")
return code, errors, warnings
@ -424,12 +495,22 @@ def main(argv: list[str] | None = None) -> int:
p_ing = sub.add_parser("ingest", help="register every url in JSON tool output")
p_ing.add_argument("file", help="JSON file, or - for stdin")
p_q = sub.add_parser("quote", help="attach verbatim supporting evidence to a source")
p_q.add_argument("id", type=int, help="ledger id of the source the quote supports")
p_q.add_argument("--text", required=True, help="the exact quote, copied from the page")
p_q.add_argument(
"--from",
dest="evidence",
required=True,
help="file with the fetched page text (or - for stdin) the quote must appear in",
)
p_list = sub.add_parser("list", help="show the ledger")
p_list.add_argument("--json", action="store_true")
p_render = sub.add_parser("render", help="render a Sources block")
p_render.add_argument(
"--style", default="markdown", choices=["markdown", "plain", "footnotes", "bibtex"]
"--style", default="markdown", choices=["markdown", "plain", "footnotes", "bibtex", "evidence"]
)
p_render.add_argument("--only", help="ids to include, e.g. 1,3,5-7")
p_render.add_argument("--cited-in", help="include only ids cited in this draft file")
@ -438,6 +519,11 @@ def main(argv: list[str] | None = None) -> int:
p_ver.add_argument("draft")
p_ver.add_argument("--strict", action="store_true", help="treat warnings as failures")
p_ver.add_argument("--min-coverage", type=float, help="required cited-sentence share, e.g. 0.5")
p_ver.add_argument(
"--evidence",
action="store_true",
help="require every cited source to carry at least one verbatim quote",
)
args = parser.parse_args(argv)
path = resolve_ledger_path(args.ledger)
@ -474,6 +560,16 @@ def main(argv: list[str] | None = None) -> int:
print(f"[{entry['id']}] {entry['url']}")
return 0
if args.cmd == "quote":
raw = (
sys.stdin.read()
if args.evidence == "-"
else Path(args.evidence).read_text(encoding="utf-8")
)
entry = attach_quote(path, args.id, args.text, raw)
print(f"[{entry['id']}] evidence attached ({len(entry.get('quotes', []))} quote(s))")
return 0
data = load_ledger(path)
sources = sorted(data["sources"], key=lambda s: s["id"])
@ -485,7 +581,9 @@ def main(argv: list[str] | None = None) -> int:
else:
for s in sources:
title = f" {s['title']}" if s.get("title") else ""
print(f"[{s['id']}] {s['url']}{title}")
nq = len(s.get("quotes", []))
mark = f" ({nq} quote{'s' if nq != 1 else ''})" if nq else ""
print(f"[{s['id']}] {s['url']}{title}{mark}")
return 0
if args.cmd == "render":
@ -508,7 +606,11 @@ def main(argv: list[str] | None = None) -> int:
print(f"error: no such draft: {draft_path}", file=sys.stderr)
return 2
code, errors, warnings = verify_draft(
draft_path, sources, strict=args.strict, min_coverage=args.min_coverage
draft_path,
sources,
strict=args.strict,
min_coverage=args.min_coverage,
require_evidence=args.evidence,
)
for w in warnings:
print(f"warn: {w}")

View File

@ -337,3 +337,132 @@ def test_corrupt_ledger_raises_actionable_error(sources_mod, tmp_path: Path) ->
with pytest.raises(SystemExit) as exc:
sources_mod.load_ledger(bad)
assert "reset" in str(exc.value)
# ---------------------------------------------------------------------------
# Fact-checking: evidence quotes and [unverified] markers
# ---------------------------------------------------------------------------
_PAGE = (
"Water expands when it freezes.\n"
"Ice is about 9% less dense than liquid water,\n"
"which is why icebergs float.\n"
)
def test_quote_verbatim_match_is_whitespace_and_case_insensitive(sources_mod, ledger: Path) -> None:
sources_mod.add_sources(ledger, ["https://a.example"])
entry = sources_mod.attach_quote(
ledger, 1, "ice is about 9% less dense than liquid water,", _PAGE
)
assert len(entry["quotes"]) == 1
def test_quote_rejects_paraphrase(sources_mod, ledger: Path) -> None:
sources_mod.add_sources(ledger, ["https://a.example"])
with pytest.raises(SystemExit) as exc:
sources_mod.attach_quote(ledger, 1, "Frozen water is roughly 9% lighter", _PAGE)
assert "not found verbatim" in str(exc.value)
def test_quote_rejects_unknown_id_and_short_text(sources_mod, ledger: Path) -> None:
sources_mod.add_sources(ledger, ["https://a.example"])
with pytest.raises(SystemExit) as exc:
sources_mod.attach_quote(ledger, 7, "which is why icebergs float.", _PAGE)
assert "no source [7]" in str(exc.value)
with pytest.raises(SystemExit) as exc:
sources_mod.attach_quote(ledger, 1, "icebergs float.", _PAGE)
assert "too short" in str(exc.value)
def test_quote_is_idempotent(sources_mod, ledger: Path) -> None:
sources_mod.add_sources(ledger, ["https://a.example"])
sources_mod.attach_quote(ledger, 1, "Water expands when it freezes.", _PAGE)
entry = sources_mod.attach_quote(ledger, 1, "water expands when it freezes.", _PAGE)
assert len(entry["quotes"]) == 1
def test_verify_evidence_gate_requires_quotes(sources_mod, ledger: Path, tmp_path: Path) -> None:
_seed(sources_mod, ledger)
text = (
"A claim supported by the first source.[1]\n\n"
"Sources:\n[1] https://a.example\n"
)
code, errors, _ = _verify(sources_mod, ledger, tmp_path, text, require_evidence=True)
assert code == 1
assert any("no verbatim evidence quote" in e for e in errors)
sources_mod.attach_quote(ledger, 1, "Water expands when it freezes.", _PAGE)
code, errors, _ = _verify(sources_mod, ledger, tmp_path, text, require_evidence=True)
assert (code, errors) == (0, [])
def test_evidence_gate_only_applies_to_cited_sources(sources_mod, ledger: Path, tmp_path: Path) -> None:
_seed(sources_mod, ledger)
sources_mod.attach_quote(ledger, 1, "Water expands when it freezes.", _PAGE)
# [2] and [3] have no quotes but are not cited — the gate must not fail on them.
text = "A claim supported by the first source.[1]\n\nSources:\n[1] https://a.example\n"
code, errors, _ = _verify(sources_mod, ledger, tmp_path, text, require_evidence=True)
assert (code, errors) == (0, [])
def test_unverified_marker_counts_toward_coverage(sources_mod, ledger: Path, tmp_path: Path) -> None:
_seed(sources_mod, ledger)
text = (
"A cited claim about the subject matter.[1]\n"
"A model-knowledge claim declared as such.[unverified]\n\n"
"Sources:\n[1] https://a.example\n"
)
code, errors, _ = _verify(sources_mod, ledger, tmp_path, text, min_coverage=0.9)
assert (code, errors) == (0, [])
def test_unverified_marker_does_not_hide_uncited_sentences(sources_mod, ledger: Path, tmp_path: Path) -> None:
_seed(sources_mod, ledger)
text = (
"A cited claim about the subject matter.[1]\n"
"An uncited, unmarked claim about the subject.\n"
"Another uncited, unmarked claim about the subject.\n\n"
"Sources:\n[1] https://a.example\n"
)
code, errors, _ = _verify(sources_mod, ledger, tmp_path, text, min_coverage=0.9)
assert code == 1
assert any("coverage" in e for e in errors)
def test_render_evidence_style_includes_quotes(sources_mod, ledger: Path) -> None:
_seed(sources_mod, ledger)
sources_mod.attach_quote(ledger, 1, "Water expands when it freezes.", _PAGE)
sources = json.loads(ledger.read_text(encoding="utf-8"))["sources"]
block = sources_mod.render_sources(sources, style="evidence", only={1})
assert "[1] https://a.example" in block
assert '> "Water expands when it freezes."' in block
plain = sources_mod.render_sources(sources, style="markdown", only={1})
assert "Water expands" not in plain
def test_cli_quote_and_evidence_verify_roundtrip(sources_mod, tmp_path: Path, capsys) -> None:
ledger = tmp_path / "ev.json"
page = tmp_path / "page.txt"
page.write_text(_PAGE, encoding="utf-8")
args = ["--ledger", str(ledger)]
assert sources_mod.main(args + ["add", "https://a.example"]) == 0
capsys.readouterr()
assert (
sources_mod.main(
args + ["quote", "1", "--text", "Water expands when it freezes.", "--from", str(page)]
)
== 0
)
assert "evidence attached" in capsys.readouterr().out
draft = tmp_path / "d.md"
draft.write_text(
"A claim resting on the source page.[1]\n\nSources:\n[1] https://a.example\n",
encoding="utf-8",
)
assert sources_mod.main(args + ["verify", str(draft), "--evidence"]) == 0
capsys.readouterr()
assert sources_mod.main(args + ["render", "--style", "evidence"]) == 0
assert '> "Water expands when it freezes."' in capsys.readouterr().out

View File

@ -16,7 +16,7 @@ Ground answers and documents in cited, verifiable sources.
|---|---|
| Source | Bundled (installed by default) |
| Path | `skills/research/grounded-citations` |
| Version | `1.0.0` |
| Version | `1.1.0` |
| Author | Hermes Agent + Teknium |
| License | MIT |
| Platforms | linux, macos, windows |
@ -36,6 +36,11 @@ Every claim taken from an outside source gets an inline numbered citation and a
so the numbers and URLs come from retrieval, never from memory — the model only
ever emits small integers it was handed.
For high-stakes work the same ledger doubles as a fact-checking chain: verbatim
quotes are attached to each source (rejected unless they literally appear in
the fetched page text), claims from model knowledge are flagged `[unverified]`,
and `verify --evidence` fails any draft whose cited sources carry no evidence.
This skill covers answers in chat, written documents (markdown, PDF, docx,
slides), and research reports. It does not cover academic BibTeX pipelines —
for conference papers use the `research-paper-writing` skill, which this skill
@ -89,10 +94,11 @@ id within a ledger, so ids stay stable across many search/extract rounds.
| Register a source, get its id | `sources.py add <url> [--title T]` |
| Register several at once | `sources.py add <url1> <url2> ...` |
| Register from JSON tool output | `sources.py ingest results.json` |
| Attach verbatim evidence to a source | `sources.py quote <id> --text "exact wording" --from page.txt` |
| Show ledger | `sources.py list [--json]` |
| Render the Sources block | `sources.py render [--style markdown\|plain\|footnotes\|bibtex] [--only 1,3]` |
| Render the Sources block | `sources.py render [--style markdown\|plain\|footnotes\|bibtex\|evidence] [--only 1,3]` |
| Render only what a draft cites | `sources.py render --cited-in draft.md` |
| Check a draft's citations | `sources.py verify draft.md [--strict] [--min-coverage 0.6]` |
| Check a draft's citations | `sources.py verify draft.md [--strict] [--min-coverage 0.6] [--evidence]` |
## Procedure
@ -136,6 +142,53 @@ sources, cite inline, end with the rendered `Sources:` list. For a short answer
you may render the block from `sources.py render --only <ids>` instead of
writing to a file.
## Fact-Checking Mode
For work where the reader must be able to check the chain — medical, legal,
financial, safety, disputed claims, or when the user asks for fact-checking —
upgrade from citations to evidence:
**Attach a verbatim quote per source.** After extracting a page, save its
text to a file and attach the sentence(s) that carry each claim:
```bash
python3 "$S" quote 1 --text "Ice is about 9% less dense than liquid water." --from page1.txt
```
The quote is rejected unless it appears verbatim in the evidence text
(whitespace- and case-insensitive), so a paraphrase or misremembered figure
cannot masquerade as evidence. Copy-paste from the fetched text; never retype.
**Flag model-knowledge claims with `[unverified]`.** A load-bearing claim
you could not source gets an explicit marker instead of a citation:
```
The refactor likely predates the 2.0 release.[unverified]
```
`verify --min-coverage` counts `[unverified]` sentences as covered — the goal
is declared provenance for every claim, not a citation on every sentence.
If a key claim can be checked, check it; `[unverified]` is for what genuinely
cannot be, and a fact-check deliverable dominated by `[unverified]` markers
should say so in its summary.
**Cross-check disputed facts against a second independent source.** When two
sources disagree, cite both readings with their own ids and quotes, and say
which you weight and why. One source is reporting; two independent sources are
corroboration.
④ **Verify with the evidence gate and render the evidence block:**
```bash
python3 "$S" verify report.md --evidence --min-coverage 0.5
python3 "$S" render --style evidence --cited-in report.md
```
`--evidence` fails the draft if any cited source has no attached quote. The
`evidence` render style prints each source's quotes beneath its URL, so the
deliverable shows claim → source → exact supporting text with nothing taken on
faith.
## Pitfalls
- **Registering after writing.** The ledger must be populated from tool output,
@ -156,6 +209,14 @@ writing to a file.
- **Parallel subagents.** Each subagent has its own working directory; point
them all at one ledger with `--ledger` (or `HERMES_CITATION_LEDGER`) if their
outputs get merged, otherwise their ids will collide.
- **Quoting from a snippet instead of the page.** Evidence quotes must come
from the extracted page text, not a search-result description — `web_extract`
first, save the text, then `quote --from` that file.
- **Paraphrasing into `quote --text`.** The verbatim check will reject it; the
fix is to find the actual sentence, not to reword until something matches.
- **Using `[unverified]` as an escape hatch.** It marks the rare claim that
genuinely cannot be sourced; if most sentences carry it, the task needed more
retrieval, not more markers.
## Verification