2026-07-09-openwiki-claim-support-lint-plan.md
docs/superpowers/plans/2026-07-09-openwiki-claim-support-lint-plan.md
Claim-Level Citation-Support Lint Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax.
Goal: Add an opt-in, LLM-assisted lint check (tesserae lint --verify-claims) that samples up to N cited claims from synthesis wiki pages and asks the configured LLM whether the cited source node's text actually supports the claim (supported / partial / unsupported), reporting the results as ordinary lint findings. Today llm_synthesis._validate_response only checks that cited node ids exist among the prompt inputs — nothing verifies semantic support. Research on the openwiki/Karpathy pattern (arXiv 2605.18490 §5.4, verified 6-0) showed holistic groundedness rubrics and per-claim citation support can disagree in direction on the same pages (wiki 40.2% supported vs RAG 18.9% conditional on being cited), so per-claim support is the discriminating quality signal and must be reported alongside existing checks.
Architecture: One new private check method WikiLinter._check_claim_support in tesserae/lint.py, gated behind a new keyword-only verify_claims: bool = False on WikiLinter.run() / ProjectWiki.lint() / the tesserae lint CLI. The check reads synthesis pages (wiki/syntheses/*.md), resolves each page's frontmatter inputs: ids to graph nodes, extracts paragraph-level claims that carry [<node name>] or [<node id>] citation markers, samples deterministically by content hash (no RNG), and issues one batched complete_json call via the existing tesserae.llm_json.build_default_json_client(). Verdicts become findings: CLAIM_UNSUPPORTED (warning), CLAIM_PARTIAL (info), plus one CLAIM_SUPPORT_SUMMARY (info) with counts, or CLAIM_SUPPORT_SKIPPED (info) when no LLM/candidates are available. The compile tail-lint (project.py compile() → self.lint(severity_floor="warning")) never passes the flag, so the check is structurally incapable of blocking or perturbing a compile.
Tech Stack: Python 3 stdlib (hashlib, re, json) + existing tesserae.llm_json client plumbing. No new dependencies. Tests in tests/test_lint.py with a stub JSON client (no network).
Research anchor (do not re-read the research)
- Per-claim, conditional-on-cited support is the metric: "does the cited source text support this sentence — supported / partial / unsupported".
- Holistic rubric scores can look fine while individual citations fail; report both. This check is the per-claim half; the existing structural checks (ghost inputs, stale citations) stay as-is.
- Even good wikis land many "partial" verdicts (53.1% in the paper) — hence
CLAIM_PARTIALis info, not warning.
Global Constraints
- Off by default. No env var auto-enable. Only
verify_claims=True(CLI--verify-claims) runs it.WikiLinter.run()must not importllm_jsonunless the flag is set (lazy import inside the check) — the module docstring's "stdlib only" promise holds for the default path. - Never a compile blocker.
ProjectWiki.compile()'s tail lint call is not modified — it cannot opt in. No change tocompile --strictsemantics. - Capped + deterministic sampling. Default cap 20 claims (
--claim-cap). Candidates are sorted bysha256(page_relpath + "\x00" + node_id + "\x00" + claim_text)hex digest and the firstcapare taken. Same artifacts → same sample, byte-for-byte, no RNG, no wall clock. - One LLM call. All sampled claims go in a single
complete_json(system=..., user=..., schema_name="claim_support_v1")batch. Any failure (clientNone, unparsable JSON, wrong shape) degrades to a singleCLAIM_SUPPORT_SKIPPEDinfo finding — never an exception out ofrun(). - Byte-idempotence: every disk write accounted for. The check writes nothing new. The only writes in a lint run remain
lint-report.md/lint-report.json, whichrun()already rewrites every invocation and which are not compile artifacts (compile idempotence checks covergraph.json,wiki/,site/— untouched here). Noauto_fixable=Trueon any CLAIM_* finding, so--fix-trivialcan never rewrite pages from this check. Finding messages are built only from deterministic inputs (fixed-width snippet truncation, sorted ordering via the existingLintFinding.sort_key); the verdicts may vary run-to-run because the LLM is nondeterministic — acceptable for an opt-in report, and exactly why this check must never gate compile. - Reuse, don't duplicate:
_claim_text(node)(already inlint.py) for source-node evidence text;_split_frontmatterforinputs:;llm_json.build_default_json_clientfor provider detection (returnsNonewhen no CLI/API backend is available — that is the availability check, do not invent another). - One commit per task; conventional messages.
File Structure
- Modify
tesserae/lint.py—_check_claim_support+ helpers (_iter_claim_candidates,_sample_claims,_judge_claims); extendWikiLinter.run()signature; update module docstring ("stdlib only" → "stdlib only by default;verify_claims=Trueopts into one LLM call"). - Modify
tesserae/project.py— pass-through kwargs onProjectWiki.lint(). - Modify
tesserae/cli.py—--verify-claims/--claim-capon_build_lint_parser, forwarded in_handle_lint. - Modify
tests/test_lint.py— all new tests live here (existing_scaffold,_write_synthesis,_nodehelpers are reused).
Not touched: tesserae/mcp_server.py (lint_report keeps calling wiki.lint() with defaults — MCP callers never pay LLM cost), tesserae/llm_synthesis.py, compile paths, wiki/site writers.
Task 1: Candidate extraction + deterministic sampling (pure, no LLM)
Files: Modify tesserae/lint.py; Test tests/test_lint.py
Interfaces:
@dataclass(frozen=True)
class _ClaimCandidate:
page_relpath: str # "wiki/syntheses/<slug>.md" (posix, relative to wiki_root)
node_id: str # resolved graph node id
claim_text: str # the paragraph containing the citation, stripped
def _iter_claim_candidates(
wiki_root: Path, nodes_by_id: Dict[str, dict]
) -> List[_ClaimCandidate]: ...
def _sample_claims(candidates: List[_ClaimCandidate], cap: int) -> List[_ClaimCandidate]: ...
Extraction rules (_iter_claim_candidates):
- Iterate
sorted((wiki_root / "wiki" / "syntheses").glob("*.md"))(same pattern as_check_synthesis_ghost_inputs; return[]if the dir is missing). - Parse frontmatter with the existing
_split_frontmatter; takeinputs(list of node ids). Skip ids not innodes_by_id(ghost inputs are another check's job). - Build the page's citation marker set: for each resolved input node, both the raw id and the node
name(skip names containing]or newlines). This covers LLM bodies post-rewrite_citations([Display Name]), pre-rewrite/heuristic bodies ([Type:slug:hash]), and name collisions deterministically: if two input nodes share a name, map the name to the sorted-first node id. - Split the body into paragraphs on blank lines (
re.split(r"\n\s*\n", body)); skip paragraphs that start with#(headings),|(tables), or ``` (fences), and paragraphs shorter than 40 chars after stripping. - For each paragraph, for each marker
min the page's marker set: iff"[{m}]"occurs in the paragraph not immediately followed by((regexre.escape(f"[{m}]") + r"(?!\()"— excludes markdown links), yield one_ClaimCandidate(page_relpath, node_id, paragraph). Dedupe on(page_relpath, node_id, paragraph).
Sampling (_sample_claims): sort candidates by hashlib.sha256(f"{c.page_relpath}\x00{c.node_id}\x00{c.claim_text}".encode("utf-8")).hexdigest(), tie-break by the tuple itself, take [:cap]. Pure function.
- [ ] Step 1: Write the failing tests (append to
tests/test_lint.py)
from tesserae.lint import _ClaimCandidate, _iter_claim_candidates, _sample_claims
def _claim_graph() -> dict:
return {"nodes": [
_node("Paper:alpha:aaaa1111", "Paper", "Alpha Attention",
metadata={"title_quality": "verified"}),
_node("Concept:beta:bbbb2222", "Concept", "Beta Routing"),
], "edges": []}
def _claim_body() -> str:
return (
"## Overview\n\n"
"Alpha introduced sparse attention over long contexts and reported "
"a 2x speedup on retrieval tasks [Alpha Attention].\n\n"
"Beta routing extends this with learned gating "
"[Concept:beta:bbbb2222].\n\n"
"See [Alpha Attention](papers/alpha.md) for details.\n"
)
def test_claim_candidates_extracted_from_synthesis_pages(tmp_path: Path) -> None:
project = _scaffold(tmp_path, graph=_claim_graph())
_write_synthesis(project, "daily-2026-07-01",
["Paper:alpha:aaaa1111", "Concept:beta:bbbb2222"],
body=_claim_body())
nodes = {n["id"]: n for n in _claim_graph()["nodes"]}
got = _iter_claim_candidates(project / ".tesserae", nodes)
pairs = {(c.node_id, c.claim_text[:20]) for c in got}
# display-name marker resolved to the Paper id; raw-id marker resolved too
assert ("Paper:alpha:aaaa1111", "Alpha introduced spa") in pairs
assert any(c.node_id == "Concept:beta:bbbb2222" for c in got)
# markdown link [Alpha Attention](...) is NOT a citation
assert not any("See [Alpha Attention]" in c.claim_text for c in got)
# heading paragraph skipped
assert not any(c.claim_text.startswith("##") for c in got)
def test_claim_sampling_is_deterministic_and_capped(tmp_path: Path) -> None:
cands = [
_ClaimCandidate("wiki/syntheses/a.md", f"Concept:x{i}:c{i:04d}",
f"Claim text number {i} with enough length to matter.")
for i in range(50)
]
first = _sample_claims(list(cands), cap=20)
second = _sample_claims(list(reversed(cands)), cap=20)
assert first == second # input order irrelevant
assert len(first) == 20 # capped
- [ ] Step 2: Implement
_ClaimCandidate,_iter_claim_candidates,_sample_claimsintesserae/lint.py(module-level, alongside the other helpers). Run:.venv/bin/python -m pytest tests/test_lint.py -k claim -x
Task 2: LLM judgment + findings, wired into WikiLinter.run
Files: Modify tesserae/lint.py; Test tests/test_lint.py
Interfaces:
# WikiLinter.run gains keyword-only params (defaults preserve all callers):
def run(self, *, fix_trivial: bool = False, severity_floor: str = "info",
verify_claims: bool = False, claim_cap: int = 20,
llm_client: Optional[object] = None) -> LintReport: ...
def _check_claim_support(
self, nodes_by_id: Dict[str, dict], *, cap: int, llm_client: Optional[object]
) -> Iterable[LintFinding]: ...
Behavior of _check_claim_support (called from run() only when verify_claims is true, appended after _check_stale_build_history):
- Collect + sample candidates (Task 1 helpers). If none → yield one
CLAIM_SUPPORT_SKIPPEDinfo finding, message"claim support: no cited claims found in wiki/syntheses — nothing to verify."; return. - Resolve the client: use
llm_clientif provided, else lazyfrom tesserae.llm_json import build_default_json_clientand call it. IfNone→ oneCLAIM_SUPPORT_SKIPPEDinfo finding, message"claim support: no LLM backend available (claude/codex CLI or ANTHROPIC_API_KEY); skipped."; return. - Build ONE batched prompt. System prompt (module constant
_CLAIM_SUPPORT_SYSTEM, byte-stable): judge whether SOURCE text supports CLAIM; answer per item exactly one ofsupported/partial/unsupported; judge only from the given source text; respond as JSON{"verdicts": ["supported", ...]}with one entry per item, same order. User prompt:json.dumps(withsort_keys=True,ensure_ascii=False) of{"items": [{"index": i, "claim": c.claim_text, "source": _claim_text(nodes_by_id[c.node_id])} ...]}over the sampled list in sampled order. Truncate eachclaimto 600 chars and eachsourceto 1200 chars (fixed constants — deterministic prompt bytes for fixed artifacts). - Call
client.complete_json(system=_CLAIM_SUPPORT_SYSTEM, user=user, schema_name="claim_support_v1")insidetry/except Exception→ on exception orNone, yield oneCLAIM_SUPPORT_SKIPPEDinfo finding ("claim support: LLM call failed or returned unparsable output; skipped.") and return. - Parse tolerantly: accept
{"verdicts": [...]}or a bare list; normalize each entrystr(v).strip().lower(); anything not in{"supported","partial","unsupported"}(including a length mismatch's missing tail) counts asunverifiable. - Emit findings, letting the existing sort in
run()order them: - per
unsupported:LintFinding(severity="warning", code="CLAIM_UNSUPPORTED", message=f"Cited source does not support the claim: {snippet!r}", node_id=<cited id>, path=str(self.wiki_root / c.page_relpath), suggested_fix="Re-run synthesis for this page, or correct/remove the citation.")wheresnippet = c.claim_text[:160]. - per
partial: same shape,severity="info",code="CLAIM_PARTIAL", messagef"Cited source only partially supports the claim: {snippet!r}". supported→ no per-claim finding.- always one
LintFinding(severity="info", code="CLAIM_SUPPORT_SUMMARY", message=f"claim support: sampled {n} claims across {p} pages — {s} supported, {pa} partial, {u} unsupported, {x} unverifiable.").
run() changes: add the three kwargs; add if verify_claims: findings.extend(self._check_claim_support(nodes_by_id, cap=claim_cap, llm_client=llm_client)) before the fix_trivial block. Nothing else moves. Update the module docstring line "Stdlib only — no LLM, no network." to note the opt-in exception.
- [ ] Step 1: Write the failing tests
class _StubJsonClient:
def __init__(self, payload):
self.payload = payload
self.calls: list[dict] = []
def complete_json(self, **kwargs):
self.calls.append(kwargs)
return self.payload
def complete_text(self, **kwargs):
return None
class _ExplodingClient:
def complete_json(self, **kwargs):
raise AssertionError("LLM must not be called")
complete_text = complete_json
def _claim_project(tmp_path: Path) -> Path:
project = _scaffold(tmp_path, graph=_claim_graph())
_write_synthesis(project, "daily-2026-07-01",
["Paper:alpha:aaaa1111", "Concept:beta:bbbb2222"],
body=_claim_body())
return project
def test_claim_support_off_by_default(tmp_path: Path) -> None:
project = _claim_project(tmp_path)
report = WikiLinter(project).run(llm_client=_ExplodingClient())
assert not [f for f in report.findings if f.code.startswith("CLAIM_")]
def test_claim_support_unsupported_is_warning_partial_is_info(tmp_path: Path) -> None:
project = _claim_project(tmp_path)
stub = _StubJsonClient({"verdicts": ["unsupported", "partial"]})
report = WikiLinter(project).run(verify_claims=True, llm_client=stub)
codes = report.by_code
assert codes.get("CLAIM_UNSUPPORTED") == 1
assert codes.get("CLAIM_PARTIAL") == 1
assert codes.get("CLAIM_SUPPORT_SUMMARY") == 1
warn = [f for f in report.findings if f.code == "CLAIM_UNSUPPORTED"][0]
assert warn.severity == "warning" and not warn.auto_fixable
assert len(stub.calls) == 1 # ONE batched call
# summary counts are embedded in the message
summary = [f for f in report.findings if f.code == "CLAIM_SUPPORT_SUMMARY"][0]
assert "1 unsupported" in summary.message and "1 partial" in summary.message
def test_claim_support_skipped_without_llm(tmp_path: Path, monkeypatch) -> None:
project = _claim_project(tmp_path)
import tesserae.llm_json as llm_json
monkeypatch.setattr(llm_json, "build_default_json_client", lambda **kw: None)
report = WikiLinter(project).run(verify_claims=True)
assert report.by_code.get("CLAIM_SUPPORT_SKIPPED") == 1
assert report.by_severity.get("warning", 0) == 0
def test_claim_support_bad_llm_output_degrades_to_skipped(tmp_path: Path) -> None:
project = _claim_project(tmp_path)
stub = _StubJsonClient({"nonsense": True})
report = WikiLinter(project).run(verify_claims=True, llm_client=stub)
# wrong shape → all entries unverifiable; still a summary, never a crash
assert report.by_code.get("CLAIM_SUPPORT_SUMMARY") == 1
assert report.by_code.get("CLAIM_UNSUPPORTED") is None
def test_claim_support_prompt_bytes_are_stable(tmp_path: Path) -> None:
project = _claim_project(tmp_path)
a, b = _StubJsonClient({"verdicts": []}), _StubJsonClient({"verdicts": []})
WikiLinter(project).run(verify_claims=True, llm_client=a)
WikiLinter(project).run(verify_claims=True, llm_client=b)
assert a.calls == b.calls # identical system+user bytes across runs
- [ ] Step 2: Implement
_CLAIM_SUPPORT_SYSTEM,_check_claim_support, and therun()kwargs. Run:.venv/bin/python -m pytest tests/test_lint.py -x(the full existing suite must stay green — default-path behavior is unchanged).
Task 3: CLI + ProjectWiki.lint pass-through; compile stays untouched
Files: Modify tesserae/project.py, tesserae/cli.py; Test tests/test_lint.py
Interfaces:
# tesserae/project.py (line ~1517)
def lint(self, fix_trivial: bool = False, severity_floor: str = "info",
*, verify_claims: bool = False, claim_cap: int = 20,
llm_client: Optional[object] = None) -> LintReport:
return WikiLinter(self.project_root).run(
fix_trivial=fix_trivial, severity_floor=severity_floor,
verify_claims=verify_claims, claim_cap=claim_cap, llm_client=llm_client)
CLI (tesserae/cli.py):
_build_lint_parser: addparser.add_argument("--verify-claims", dest="verify_claims", action="store_true", help="Opt-in: sample cited claims from synthesis pages and LLM-verify the cited node supports each (supported/partial/unsupported). Needs an LLM backend; costs one batched call.")andparser.add_argument("--claim-cap", dest="claim_cap", type=int, default=20, help="Max claims to sample for --verify-claims (default: 20).")_handle_lint: forwardverify_claims=args.verify_claims, claim_cap=args.claim_capintowiki.lint(...). Exit-code mapping is untouched: an opted-inCLAIM_UNSUPPORTEDwarning behaves like any other warning under the chosen--severityfloor.- Do not touch the compile tail-lint (
project.pycompile()→self.lint(severity_floor="warning")) norcompile --stricthandling incli.py(~line 1284) — compile can never opt in, so CLAIM_* findings can never appear inresult["lint"]counts. - Do not touch
mcp_server.pylint_report— it inherits the safe defaults. - [ ] Step 1: Write the failing tests
def test_cli_lint_verify_claims_flag_forwards(tmp_path: Path, monkeypatch) -> None:
project = _scaffold(tmp_path)
seen = {}
def fake_lint(self, fix_trivial=False, severity_floor="info", **kw):
seen.update(kw); return LintReport()
monkeypatch.setattr(ProjectWiki, "lint", fake_lint)
rc = cli_main(["lint", "--project", str(project),
"--verify-claims", "--claim-cap", "5"])
assert rc == 0
assert seen["verify_claims"] is True and seen["claim_cap"] == 5
def test_compile_tail_lint_never_verifies_claims(tmp_path: Path, monkeypatch) -> None:
# Reuse the arrangement of test_compile_runs_lint_at_tail; additionally
# patch WikiLinter._check_claim_support to raise if ever invoked.
from tesserae.lint import WikiLinter as WL
monkeypatch.setattr(WL, "_check_claim_support",
lambda self, *a, **k: (_ for _ in ()).throw(AssertionError("compile must not verify claims")))
# ... same scaffold+compile as test_compile_runs_lint_at_tail; assert compile succeeds
(For the second test, copy the compile setup already used by test_compile_runs_lint_at_tail in this file.)
- [ ] Step 2: Implement the pass-through and flags. Run:
.venv/bin/python -m pytest tests/test_lint.py tests/test_cli_commands.py -x
Verification
.venv/bin/python -m pytest tests/test_lint.py tests/test_cli_commands.py— all green (note: use.venv/bin/python, system python3.9 fails collection).- Manual smoke (needs a compiled project + claude/codex CLI):
tesserae lint --verify-claims --claim-cap 5 --severity error— expect aCLAIM_SUPPORT_SUMMARYinfo line inlint-report.mdand exit 0 unless errors exist. - Byte-idempotence audit:
tesserae compiletwice →graph.json,wiki/,site/byte-identical (unchanged by this feature;test_compile_twice_lint_report_is_byte_stablestill passes since compile never opts in).
Explicitly out of scope (YAGNI)
- Verifying entity/concept wiki pages or compiled context bundles (no stable on-disk claim+citation format there today).
- An auto-fix for unsupported claims (would rewrite LLM prose — nondeterministic write into wiki/).
- MCP/
compile --strictexposure, config-file knobs, per-page caps, holistic rubric scoring. - Caching verdicts across runs (cap of 20 keeps cost trivial; add only if usage demands).