mirror of
https://github.com/Astro-Han/karpathy-llm-wiki.git
synced 2026-09-14 19:09:17 +08:00
432 lines
14 KiB
Python
Executable File
432 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Mechanical evidence check for a Karpathy-style LLM wiki.
|
|
|
|
Report-only; never modifies files. Three sweeps:
|
|
|
|
1. Fidelity — extract candidate literals (specific numbers, ISO dates,
|
|
direct quotes) from each wiki article and verify that each candidate
|
|
appears verbatim in the body of the raw files linked by that
|
|
article's Raw field. Misses are listed as suspects. Derived values,
|
|
product names, and deliberate paraphrases will show up as suspects;
|
|
judging them is the reader's job, not this script's.
|
|
2. Evidence errors — articles that cannot be verified at all: a missing
|
|
Raw field on a non-archive article, Raw links that do not resolve,
|
|
or Raw links that escape raw/ (evidence must live in immutable raw/).
|
|
3. Inventory — raw files that no article's Raw field references,
|
|
excluding files whose ingest was logged as "no material".
|
|
|
|
Coverage boundary (closed candidate set, frozen): candidates are
|
|
- quotes of 15+ characters (double-quoted spans and body blockquotes)
|
|
- ISO dates (YYYY-MM-DD, YYYY-MM)
|
|
- specific numbers: thousands-grouped (10,000), dotted (2.1.80, 3.14),
|
|
suffixed (42K, 99.9%), or 4+ digits (2026)
|
|
Small plain integers ("42", "500") and exotic forms (signs, currencies,
|
|
spelled-out dates) are deliberately not checked; they belong to the
|
|
compile-time locate-before-write rule and to judgment review. New prose
|
|
forms extend this list in the docstring, not the regexes.
|
|
|
|
The exit code carries no information; the report is the interface.
|
|
|
|
Usage: check_evidence.py [project-root] [article.md ...]
|
|
Defaults: project-root is the current directory; all wiki/**/*.md
|
|
articles except index.md and log.md are checked. Article paths may be
|
|
absolute or relative to the project root.
|
|
"""
|
|
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
NUMBER_TOKEN_RE = re.compile(
|
|
r"(?:\d{1,3}(?:,\d{3})+(?:\.\d+)*(?:\s*[KMB%](?![A-Za-z]))?"
|
|
r"|\d+(?:\.\d+)*(?:\s*[KMB%](?![A-Za-z]))?)(?![A-Za-z])"
|
|
)
|
|
SUFFIX_RE = re.compile(r"[KMB%]$")
|
|
DATE_RE = re.compile(r"\d{4}-\d{2}(?:-\d{2})?")
|
|
QUOTE_RES = [re.compile(r'"([^"\n]*)"'), re.compile(r"“([^”\n]*)”")]
|
|
METADATA_RE = re.compile(r"^>\s*(Sources?|Raw|Collected|Published|Updated|Archived):")
|
|
STATUS_LINE_RE = re.compile(r"^>\s*\*\*Status:")
|
|
LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]*)\)")
|
|
INLINE_CODE_RE = re.compile(r"`[^`\n]*`")
|
|
RAW_LINK_RE = re.compile(r"\(([^)]+\.md)[^)]*\)")
|
|
NO_MATERIAL_HEADING_RE = re.compile(
|
|
r"^## \[[^\]]*\]\s*ingest\s*\|\s*no material:\s*(\S+)", re.IGNORECASE
|
|
)
|
|
ARCHIVED_RE = re.compile(r"^>\s*Archived:")
|
|
FENCE_OPEN_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
|
|
FENCE_CLOSE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})[ \t]*$")
|
|
WS_RE = re.compile(r"\s+")
|
|
|
|
SKIP_FILES = {"index.md", "log.md"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Document:
|
|
title: str | None
|
|
header: tuple[str, ...]
|
|
body: tuple[str, ...]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Candidate:
|
|
kind: str
|
|
value: str
|
|
|
|
|
|
def normalize(text: str) -> str:
|
|
return WS_RE.sub(" ", text).strip()
|
|
|
|
|
|
def fence_opener(line: str) -> tuple[str, int] | None:
|
|
m = FENCE_OPEN_RE.match(line)
|
|
if not m:
|
|
return None
|
|
marker, info = m.groups()
|
|
if marker[0] == "`" and "`" in info:
|
|
return None
|
|
return marker[0], len(marker)
|
|
|
|
|
|
def is_fence_closer(line: str, char: str, length: int) -> bool:
|
|
m = FENCE_CLOSE_RE.match(line)
|
|
return bool(m and m.group(1)[0] == char and len(m.group(1)) >= length)
|
|
|
|
|
|
def parse_document(text: str) -> Document:
|
|
"""Return the visible title, metadata header, and body.
|
|
|
|
The metadata header is only the contiguous blockquote immediately
|
|
after the first H1 outside a fence. A fence is a body boundary; its
|
|
removal must not promote a later blockquote into the header.
|
|
"""
|
|
title = None
|
|
header = []
|
|
preamble = []
|
|
body = []
|
|
state = "before_title"
|
|
fence_char = None
|
|
fence_len = 0
|
|
|
|
for line in text.splitlines():
|
|
if fence_char:
|
|
if is_fence_closer(line, fence_char, fence_len):
|
|
fence_char = None
|
|
continue
|
|
opener = fence_opener(line)
|
|
if opener:
|
|
fence_char, fence_len = opener
|
|
if state == "after_title":
|
|
state = "body"
|
|
continue
|
|
if state == "before_title":
|
|
if line.startswith("# "):
|
|
title = line
|
|
state = "after_title"
|
|
else:
|
|
preamble.append(line)
|
|
elif state == "after_title":
|
|
if not line.strip():
|
|
continue
|
|
if line.strip().startswith(">"):
|
|
header.append(line)
|
|
state = "header"
|
|
else:
|
|
body.append(line)
|
|
state = "body"
|
|
elif state == "header":
|
|
if line.strip().startswith(">"):
|
|
header.append(line)
|
|
else:
|
|
body.append(line)
|
|
state = "body"
|
|
else:
|
|
body.append(line)
|
|
|
|
return Document(title, tuple(header), tuple(preamble + body))
|
|
|
|
|
|
def strip_fences(text: str) -> str:
|
|
"""Remove Standard Markdown fenced code blocks."""
|
|
out = []
|
|
fence_char = None
|
|
fence_len = 0
|
|
for line in text.splitlines():
|
|
if fence_char:
|
|
if is_fence_closer(line, fence_char, fence_len):
|
|
fence_char = None
|
|
continue
|
|
opener = fence_opener(line)
|
|
if opener:
|
|
fence_char, fence_len = opener
|
|
continue
|
|
out.append(line)
|
|
return "\n".join(out)
|
|
|
|
|
|
def strip_noise(text: str) -> str:
|
|
text = INLINE_CODE_RE.sub(" ", text)
|
|
text = LINK_RE.sub(r"\1", text)
|
|
return text
|
|
|
|
|
|
def keep_number(token: str) -> bool:
|
|
token = token.strip()
|
|
if SUFFIX_RE.search(token) or "," in token or "." in token:
|
|
return True
|
|
return len(token) >= 4
|
|
|
|
|
|
def extract_numeric_date_candidates(line: str) -> list[Candidate]:
|
|
line = strip_noise(line)
|
|
date_matches = list(DATE_RE.finditer(line))
|
|
candidates = [Candidate("date", m.group(0)) for m in date_matches]
|
|
number_text = list(line)
|
|
for match in date_matches:
|
|
number_text[match.start() : match.end()] = " " * (match.end() - match.start())
|
|
candidates.extend(
|
|
Candidate("number", m.group(0))
|
|
for m in NUMBER_TOKEN_RE.finditer("".join(number_text))
|
|
if keep_number(m.group(0))
|
|
)
|
|
return candidates
|
|
|
|
|
|
def extract_candidates(text: str) -> list[Candidate]:
|
|
document = parse_document(text)
|
|
lines = ([document.title] if document.title else []) + [
|
|
line for line in document.header if not METADATA_RE.match(line.strip())
|
|
] + list(document.body)
|
|
candidates: list[Candidate] = []
|
|
skip_status_block = False
|
|
blockquote: list[str] = []
|
|
paragraph: list[str] = []
|
|
|
|
def flush_blockquote():
|
|
if blockquote:
|
|
joined = normalize(" ".join(blockquote))
|
|
if len(joined) >= 15:
|
|
candidates.append(Candidate("quote", joined))
|
|
blockquote.clear()
|
|
|
|
def flush_paragraph():
|
|
if paragraph:
|
|
joined = normalize(" ".join(paragraph))
|
|
for quote_re in QUOTE_RES:
|
|
candidates.extend(
|
|
Candidate("quote", m.group(1))
|
|
for m in quote_re.finditer(joined)
|
|
if len(m.group(1).strip()) >= 15
|
|
)
|
|
paragraph.clear()
|
|
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if STATUS_LINE_RE.match(stripped):
|
|
flush_blockquote()
|
|
flush_paragraph()
|
|
skip_status_block = True
|
|
continue
|
|
if skip_status_block:
|
|
if stripped.startswith(">"):
|
|
continue
|
|
skip_status_block = False
|
|
if stripped.startswith(">"):
|
|
flush_paragraph()
|
|
content = strip_noise(stripped.lstrip(">").strip())
|
|
blockquote.append(content)
|
|
candidates.extend(extract_numeric_date_candidates(content))
|
|
continue
|
|
flush_blockquote()
|
|
if not stripped:
|
|
flush_paragraph()
|
|
continue
|
|
line = strip_noise(line)
|
|
candidates.extend(extract_numeric_date_candidates(line))
|
|
paragraph.append(line)
|
|
flush_blockquote()
|
|
flush_paragraph()
|
|
seen = set()
|
|
unique = []
|
|
for candidate in candidates:
|
|
value = candidate.value.strip().strip(".,;:()[]")
|
|
candidate = Candidate(candidate.kind, value)
|
|
if value and candidate not in seen:
|
|
seen.add(candidate)
|
|
unique.append(candidate)
|
|
return unique
|
|
|
|
|
|
def raw_links_of(article_text: str) -> list[str]:
|
|
"""Raw links come only from the metadata header; identical lines in
|
|
the body or in code fences are content, not fields."""
|
|
links = []
|
|
for line in parse_document(article_text).header:
|
|
if re.match(r"^>\s*Raw:", line.strip()):
|
|
links.extend(RAW_LINK_RE.findall(line))
|
|
return links
|
|
|
|
|
|
def contains(haystack: str, candidate: Candidate) -> bool:
|
|
if candidate.kind == "quote":
|
|
return candidate.value in haystack
|
|
# Values must stand on their own, while sentence punctuation remains
|
|
# valid. A month may not pass as the prefix of a full ISO date.
|
|
right = r"(?!-\d{2})" if candidate.kind == "date" and len(candidate.value) == 7 else ""
|
|
pattern = (
|
|
r"(?<![\d.,])" + re.escape(candidate.value) + right + r"(?![A-Za-z0-9]|[.,]\d|%)"
|
|
)
|
|
return re.search(pattern, haystack) is not None
|
|
|
|
|
|
def source_content(path: Path) -> str:
|
|
"""Raw file body with the metadata header removed. Collection
|
|
metadata (Source/Collected/Published) is bookkeeping, not evidence;
|
|
letting it match candidates would false-pass dates and years."""
|
|
document = parse_document(path.read_text(encoding="utf-8"))
|
|
return normalize("\n".join(document.body))
|
|
|
|
|
|
def check_article(article: Path, root: Path) -> tuple[list[str], list[str]]:
|
|
"""Return (fidelity suspects, evidence errors) for one article."""
|
|
text = article.read_text(encoding="utf-8")
|
|
links = raw_links_of(text)
|
|
if not links:
|
|
if any(ARCHIVED_RE.match(line.strip()) for line in parse_document(text).header):
|
|
return [], []
|
|
return [], ["article has no Raw field"]
|
|
raw_root = (root / "raw").resolve()
|
|
raws = []
|
|
errors = []
|
|
for link in links:
|
|
target = (article.parent / link).resolve()
|
|
if not target.is_relative_to(raw_root):
|
|
errors.append(f"Raw link escapes raw/: {link}")
|
|
elif not target.is_file():
|
|
errors.append(f"unresolvable Raw link: {link}")
|
|
else:
|
|
raws.append(source_content(target))
|
|
misses = []
|
|
if raws:
|
|
for candidate in extract_candidates(text):
|
|
candidate = Candidate(candidate.kind, normalize(candidate.value))
|
|
if not any(contains(raw, candidate) for raw in raws):
|
|
misses.append(candidate.value)
|
|
return misses, errors
|
|
|
|
|
|
def iter_articles(wiki_dir: Path):
|
|
for path in sorted(wiki_dir.rglob("*.md")):
|
|
if path.relative_to(wiki_dir).as_posix() not in SKIP_FILES:
|
|
yield path
|
|
|
|
|
|
def no_material_paths(log_file: Path) -> set[str]:
|
|
if not log_file.is_file():
|
|
return set()
|
|
paths = set()
|
|
text = strip_fences(log_file.read_text(encoding="utf-8"))
|
|
for line in text.splitlines():
|
|
m = NO_MATERIAL_HEADING_RE.match(line)
|
|
if m:
|
|
paths.add(m.group(1).strip("`,;."))
|
|
return paths
|
|
|
|
|
|
def referenced_raws(root: Path) -> set[Path]:
|
|
referenced = set()
|
|
for article in iter_articles(root / "wiki"):
|
|
for link in raw_links_of(article.read_text(encoding="utf-8")):
|
|
target = (article.parent / link).resolve()
|
|
referenced.add(target)
|
|
return referenced
|
|
|
|
|
|
def unreferenced_raws(root: Path) -> list[str]:
|
|
raw_dir = root / "raw"
|
|
if not raw_dir.is_dir():
|
|
return []
|
|
referenced = referenced_raws(root)
|
|
disposed = no_material_paths(root / "wiki" / "log.md")
|
|
missing = []
|
|
for path in sorted(raw_dir.rglob("*.md")):
|
|
if path.resolve() not in referenced and path.relative_to(root).as_posix() not in disposed:
|
|
missing.append(path.relative_to(root).as_posix())
|
|
return missing
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
root = Path(argv[1]).resolve() if len(argv) > 1 else Path.cwd()
|
|
wiki_dir = root / "wiki"
|
|
if not wiki_dir.is_dir():
|
|
print(f"no wiki/ directory under {root}")
|
|
return 1
|
|
|
|
articles = []
|
|
for arg in argv[2:]:
|
|
path = Path(arg)
|
|
if not path.is_absolute():
|
|
path = root / path
|
|
try:
|
|
if path.resolve().relative_to(wiki_dir).as_posix() in SKIP_FILES:
|
|
print(f"warning: {arg} is an index/log file, skipping", file=sys.stderr)
|
|
continue
|
|
except ValueError:
|
|
pass
|
|
if not path.is_file():
|
|
print(f"warning: article not found: {arg}", file=sys.stderr)
|
|
continue
|
|
articles.append(path)
|
|
if len(argv) <= 2:
|
|
articles = list(iter_articles(wiki_dir))
|
|
|
|
results = {}
|
|
for article in articles:
|
|
results[article] = check_article(article, root)
|
|
|
|
def label(article: Path) -> Path:
|
|
try:
|
|
return article.resolve().relative_to(root)
|
|
except ValueError:
|
|
return article
|
|
|
|
print("# Evidence check\n")
|
|
print("## Fidelity suspects")
|
|
suspect_count = 0
|
|
for article, (misses, _) in results.items():
|
|
if misses:
|
|
print(f"\n{label(article)}")
|
|
for miss in misses:
|
|
print(f"- {miss}")
|
|
suspect_count += 1
|
|
if suspect_count == 0:
|
|
print("\n(none)")
|
|
|
|
print("\n## Evidence errors")
|
|
error_count = 0
|
|
for article, (_, errors) in results.items():
|
|
if errors:
|
|
print(f"\n{label(article)}")
|
|
for error in errors:
|
|
print(f"- {error}")
|
|
error_count += 1
|
|
if error_count == 0:
|
|
print("(none)")
|
|
|
|
print("\n## Unreferenced raw files")
|
|
orphans = unreferenced_raws(root)
|
|
for path in orphans:
|
|
print(f"- {path}")
|
|
if not orphans:
|
|
print("(none)")
|
|
|
|
print(
|
|
f"\n## Summary\n{suspect_count} fidelity suspect(s), "
|
|
f"{error_count} evidence error(s), {len(orphans)} unreferenced raw file(s)"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|