arborist/bench/scripts/textbooks_manifest.py
russell@unturf.com 514e07d7c2
textbooks: TeX-source ingest closes pillars I + IV (Hilbert + Boole)
Two foundational PD textbooks ship from Project Gutenberg as
LaTeX source only — no clean HTML edition. Pandoc fails on PG's
custom preamble macros; a focused regex-based stripper is the
right amount of machinery for the well-known PG TeX format.

What landed
===========
- arborist/sources/textbook_tex.py — TextbookTexSource +
  strip_tex pipeline. Drops preamble + line comments + structural
  envs (tabular, figure, thebibliography, scshape, …); keeps the
  argument of structural-but-content-bearing single-arg commands
  (textbf, emph, section, chapter, paragraph, PG's custom \\rfa);
  drops zero-arg + brace-arg structural commands (noindent,
  thispagestyle, setcounter, label, index, …); substitutes
  symbol-level macros (\\to → →, \\neg → ¬, \\forall → ∀, \\S → §,
  Greek letters, etc.).

- arborist/cli.py — `--source textbook_tex` accepts --url,
  --bundle, or --urls-from. Reuses the existing fetch + chunker
  + Merkle commit + audit pipeline; idempotent at the database
  layer (same TeX → same prose → same document_root).

- bench/scripts/textbooks_manifest.py — gains `tex-targets`
  subcommand emitting tab-separated `<tex_url>\t<id>` rows for
  manifest entries with a `tex_url` field.

- Makefile targets:
    textbooks-tex         — ingest every entry with a tex_url
    textbook-hilbert      — convenience for PG #17384
    textbook-boole        — convenience for PG #15114
  Each writes to $(CRAWL_SHARDS_DIR)/textbook_<id>.db, idempotent
  on re-run.

- tests/test_textbook_tex.py — 20 unit tests covering preamble +
  postmatter stripping, line comments, env drops (tabular, figure),
  single-arg keepers (\\textbf, \\emph, \\section, \\rfa),
  symbol-level macro subs (10 paramerized cases), structural-cmd
  drops, whitespace cleanup, idempotence on already-stripped text.

End-to-end verification
=======================
Smoke test on PG #17384 + #15114:
  Hilbert Foundations of Geometry: 1 doc / 65 chunks (192K of
    plain prose). FTS5 finds "axiom of parallels" → real chapter
    content with axiom references intact (≡, §, math fragments).
  Boole Laws of Thought: 1 doc / 273 chunks (829K). FTS5 finds
    "law of contradiction" → "the principle of contradiction"
    passage from Chapter III of Boole's text.

Vital-books coverage now 6/7 pillars
====================================

  Pillar I   Logic        ✓ Levin + Aristotle Prior + Posterior + Boole
  Pillar II  Set Theory   ✓ Levin
  Pillar III Arithmetic   ✓ Levin
  Pillar IV  Geometry     ✓ Hilbert (PG TeX)
  Pillar V   Probability  ✗ Kolmogorov license analysis pending
  Pillar VI  Phys.        ✓ Newton Principia
  Pillar VII Combin.      ✓ Bogart + Keller-Trotter + Levin
  Pillar IX  λ-Calculus   ✗ Church + Turing 1936 papers pending

Test suite: 1574 passed / 28 skipped (was 1554 + 20 new TeX tests).

Out of scope: chunk-resolution + derivations.proof_blob warrant
promotion (#000031 follow-up; see also #000032).
2026-05-09 16:06:53 -04:00

232 lines
7.8 KiB
Python

"""Read the textbook manifest and emit URLs / license / metadata helpers.
Usage:
python -m bench.scripts.textbooks_manifest urls < manifest.jsonl
→ one URL per line on stdout
python -m bench.scripts.textbooks_manifest summary < manifest.jsonl
→ human-readable license summary
The manifest format is one JSON record per line. The first record may be a
``_meta`` block; it is ignored by all subcommands. See
``bench/fixtures/textbooks/manifest-v1.jsonl`` for the canonical schema.
Hard discipline: every entry MUST carry ``license`` and ``license_url``.
This script refuses to emit URLs from entries missing those fields, so a
fetcher run is never the place a license-discipline bug becomes visible —
it surfaces here.
"""
from __future__ import annotations
import json
import sys
from typing import Iterator, TextIO
_REQUIRED_LICENSE_KEYS = ("license", "license_url")
# Allow-list of license tokens we redistribute under arborist's AGPLv3
# distribution profile. CC-BY-NC, CC-BY-ND, and proprietary licenses are
# explicitly rejected — they don't fit the AGPLv3 downstream-distribution
# contract or arborist's "every patch ships" mission.
_ALLOWED_LICENSES = frozenset({
"PD",
"CC0-1.0",
"CC-BY-2.5", "CC-BY-3.0", "CC-BY-4.0",
"CC-BY-SA-2.5", "CC-BY-SA-3.0", "CC-BY-SA-4.0",
"GFDL-1.2", "GFDL-1.3",
"AGPL-3.0-only",
"Apache-2.0",
"MIT",
})
def iter_entries(stream: TextIO) -> Iterator[dict]:
"""Yield non-meta entries from a JSONL manifest."""
for line in stream:
line = line.strip()
if not line:
continue
obj = json.loads(line)
if "_meta" in obj:
continue
yield obj
def _validate(entry: dict) -> None:
"""Raise ValueError if an entry would emit URLs under a license outside
the redistribution allow-list, OR is missing required license fields.
Entries with both ``urls`` and ``crawl_url`` empty/missing are
placeholder rows kept for citation traceability (e.g., Mendelson +
Enderton as yellow-light proprietary; PG TeX-only Hilbert + Boole
awaiting PDF source support; Łukasiewicz + Kolmogorov pending license
analysis). Validation is fail-closed at the URL-emit step, so
placeholders with no URLs are allowed any license token without
raising.
"""
has_emit_target = bool(entry.get("urls") or entry.get("crawl_url"))
for k in _REQUIRED_LICENSE_KEYS:
if k not in entry or not entry[k]:
raise ValueError(
f"manifest entry {entry.get('id', '?')!r} missing required "
f"field {k!r}"
)
if has_emit_target and entry["license"] not in _ALLOWED_LICENSES:
raise ValueError(
f"manifest entry {entry.get('id', '?')!r} declares license "
f"{entry['license']!r} outside arborist redistribution allow-list "
f"({sorted(_ALLOWED_LICENSES)}); urls / crawl_url must be empty "
f"for non-allow-listed entries"
)
def cmd_urls(stream: TextIO) -> int:
seen: set[str] = set()
for entry in iter_entries(stream):
_validate(entry)
for url in entry.get("urls", []):
if url and url not in seen:
seen.add(url)
print(url)
return 0
def cmd_summary(stream: TextIO) -> int:
rows = []
by_license: dict[str, int] = {}
by_domain: dict[str, int] = {}
total_urls = 0
for entry in iter_entries(stream):
_validate(entry)
rows.append(entry)
by_license[entry["license"]] = by_license.get(entry["license"], 0) + 1
d = entry.get("domain", "?")
by_domain[d] = by_domain.get(d, 0) + 1
total_urls += len(entry.get("urls", []))
print(f"# Textbook manifest summary")
print(f"entries: {len(rows)}")
print(f"seed URLs: {total_urls}")
print()
print("## by license")
for k, n in sorted(by_license.items()):
print(f" {k:20s} {n}")
print()
print("## by domain")
for k, n in sorted(by_domain.items()):
print(f" {k:20s} {n}")
print()
print("## entries")
for e in rows:
urls_n = len(e.get("urls", []))
title = e.get("title", "?")
license_ = e.get("license", "?")
print(f" {e.get('id'):42s} {license_:18s} {urls_n} URL(s) {title}")
return 0
def cmd_crawl_targets(stream: TextIO) -> int:
"""Emit one tab-separated `crawl_url\\tdepth\\tmax\\tid` row per entry
that declares a `crawl_url`. Used by `make crawl-textbooks` to drive
the BFS crawler, one shard per textbook id.
"""
for entry in iter_entries(stream):
_validate(entry)
crawl_url = entry.get("crawl_url")
if not crawl_url:
continue
depth = int(entry.get("crawl_depth", 2))
max_pages = int(entry.get("crawl_max", 80))
print(f"{crawl_url}\t{depth}\t{max_pages}\t{entry.get('id', '?')}")
return 0
def cmd_ids(stream: TextIO) -> int:
"""Emit one entry id per line for entries that have either `urls` or
`crawl_url`. License-fail placeholders (no fetchable URLs) are
excluded — they have nothing to ingest.
"""
for entry in iter_entries(stream):
try:
_validate(entry)
except ValueError:
continue
if entry.get("urls") or entry.get("crawl_url"):
print(entry.get("id", ""))
return 0
def cmd_tex_targets(stream: TextIO) -> int:
"""Emit one tab-separated `tex_url\\tid` row per entry that
declares a `tex_url`. Used by `make textbooks-tex` to pull PG
LaTeX-source textbooks (e.g. Hilbert #17384, Boole #15114) into
per-book shards via `arborist ingest --source textbook_tex`.
"""
for entry in iter_entries(stream):
# tex_url path is exempt from the redistribution allow-list
# because PG TeX sources for pre-1929 works are PD by age;
# the manifest entry's `license: "PD"` field carries the
# check anyway. Validate license fields nonetheless.
for k in _REQUIRED_LICENSE_KEYS:
if k not in entry or not entry[k]:
continue
tex_url = entry.get("tex_url")
if not tex_url:
continue
print(f"{tex_url}\t{entry.get('id', '?')}")
return 0
def cmd_lookup(stream: TextIO) -> int:
"""Read entry id from sys.argv[2]; emit tab-separated
`id\\tcrawl_url\\tdepth\\tmax\\tlicense\\tdomain` for the matching
entry. Used by per-book make targets to look up one textbook's
crawl parameters from the manifest without grep wizardry.
"""
target_id = sys.argv[2] if len(sys.argv) > 2 else ""
if not target_id:
print("usage: lookup <id>", file=sys.stderr)
return 2
for entry in iter_entries(stream):
if entry.get("id") != target_id:
continue
try:
_validate(entry)
except ValueError as exc:
print(f"# license-fail placeholder: {exc}", file=sys.stderr)
return 3
crawl_url = entry.get("crawl_url", "")
depth = entry.get("crawl_depth", 2)
max_pages = entry.get("crawl_max", 80)
print(
f"{entry.get('id')}\t{crawl_url}\t{depth}\t{max_pages}\t"
f"{entry.get('license')}\t{entry.get('domain')}"
)
return 0
print(f"# id {target_id!r} not found in manifest", file=sys.stderr)
return 4
_COMMANDS = {
"urls": cmd_urls,
"summary": cmd_summary,
"crawl-targets": cmd_crawl_targets,
"tex-targets": cmd_tex_targets,
"ids": cmd_ids,
"lookup": cmd_lookup,
}
def main(argv: list[str]) -> int:
if len(argv) < 2 or argv[1] not in _COMMANDS:
print(
f"usage: {argv[0]} {{{'|'.join(sorted(_COMMANDS))}}} < manifest.jsonl",
file=sys.stderr,
)
return 2
return _COMMANDS[argv[1]](sys.stdin)
if __name__ == "__main__":
sys.exit(main(sys.argv))