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).
This commit is contained in:
parent
57dafbdc3d
commit
514e07d7c2
7 changed files with 644 additions and 6 deletions
34
Makefile
34
Makefile
|
|
@ -770,6 +770,40 @@ textbook-newton: ## Newton Principia (PD, Wikisource)
|
||||||
textbook-morin: ## Morin Open Data Structures (CC-BY)
|
textbook-morin: ## Morin Open Data Structures (CC-BY)
|
||||||
$(MAKE) textbook ID=morin-open-data-structures
|
$(MAKE) textbook ID=morin-open-data-structures
|
||||||
|
|
||||||
|
# TeX-source textbooks: Project Gutenberg eBooks that ship as
|
||||||
|
# LaTeX source only (no clean HTML edition). The textbook_tex
|
||||||
|
# source strips the LaTeX into plain prose via a focused PG-aware
|
||||||
|
# pipeline. Idempotent at the database layer like every other
|
||||||
|
# ingest path.
|
||||||
|
.PHONY: textbooks-tex textbook-hilbert textbook-boole
|
||||||
|
|
||||||
|
textbooks-tex: bootstrap ## ingest every manifest entry that declares a tex_url (Hilbert + Boole)
|
||||||
|
@mkdir -p $(CRAWL_SHARDS_DIR)
|
||||||
|
@$(PY) -m bench.scripts.textbooks_manifest tex-targets < $(TEXTBOOK_MANIFEST) | \
|
||||||
|
while IFS=$$'\t' read -r url id; do \
|
||||||
|
shard="$(CRAWL_SHARDS_DIR)/textbook_$${id}.db"; \
|
||||||
|
echo ">> $$id (textbook_tex)"; \
|
||||||
|
echo " url: $$url"; \
|
||||||
|
echo " shard: $$shard"; \
|
||||||
|
$(ARBORIST) --db "$$shard" ingest --source textbook_tex --url "$$url"; \
|
||||||
|
done
|
||||||
|
|
||||||
|
textbook-hilbert: ## Hilbert Foundations of Geometry (PD, PG TeX)
|
||||||
|
@row=$$(grep '"hilbert-foundations-geometry-1902"' $(TEXTBOOK_MANIFEST) | head -1); \
|
||||||
|
url=$$(echo "$$row" | $(PY) -c "import json,sys; print(json.loads(sys.stdin.read()).get('tex_url',''))"); \
|
||||||
|
if [ -z "$$url" ]; then echo "no tex_url for Hilbert" >&2; exit 1; fi; \
|
||||||
|
mkdir -p $(CRAWL_SHARDS_DIR); \
|
||||||
|
$(ARBORIST) --db "$(CRAWL_SHARDS_DIR)/textbook_hilbert-foundations-geometry-1902.db" \
|
||||||
|
ingest --source textbook_tex --url "$$url"
|
||||||
|
|
||||||
|
textbook-boole: ## Boole Laws of Thought (PD, PG TeX)
|
||||||
|
@row=$$(grep '"boole-laws-of-thought-1854"' $(TEXTBOOK_MANIFEST) | head -1); \
|
||||||
|
url=$$(echo "$$row" | $(PY) -c "import json,sys; print(json.loads(sys.stdin.read()).get('tex_url',''))"); \
|
||||||
|
if [ -z "$$url" ]; then echo "no tex_url for Boole" >&2; exit 1; fi; \
|
||||||
|
mkdir -p $(CRAWL_SHARDS_DIR); \
|
||||||
|
$(ARBORIST) --db "$(CRAWL_SHARDS_DIR)/textbook_boole-laws-of-thought-1854.db" \
|
||||||
|
ingest --source textbook_tex --url "$$url"
|
||||||
|
|
||||||
test-crawler: bootstrap-crawler ## run only the lifted crawler tests
|
test-crawler: bootstrap-crawler ## run only the lifted crawler tests
|
||||||
$(VENV)/bin/pytest -q tests/crawler
|
$(VENV)/bin/pytest -q tests/crawler
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,29 @@ def _cmd_ingest(args: argparse.Namespace) -> int:
|
||||||
|
|
||||||
cls = GrokExportSource if args.source == "grok_export" else GrokMediaPostsSource
|
cls = GrokExportSource if args.source == "grok_export" else GrokMediaPostsSource
|
||||||
src = cls(path=args.path)
|
src = cls(path=args.path)
|
||||||
|
elif args.source == "textbook_tex":
|
||||||
|
# PG-style LaTeX source ingest (#000031). Each --url or --bundle
|
||||||
|
# points at a TeX URL (e.g., PG eBook /files/N/N-t/N-t.tex).
|
||||||
|
# `--urls-from FILE` for a list. The strip-tex pipeline produces
|
||||||
|
# plain prose suitable for the standard 512-token chunker.
|
||||||
|
urls: list[str] = list(getattr(args, "url", None) or [])
|
||||||
|
if getattr(args, "bundle", None):
|
||||||
|
urls.extend(args.bundle)
|
||||||
|
if args.urls_from:
|
||||||
|
urls.extend(
|
||||||
|
line.strip()
|
||||||
|
for line in Path(args.urls_from).read_text(encoding="utf-8").splitlines()
|
||||||
|
if line.strip() and not line.lstrip().startswith("#")
|
||||||
|
)
|
||||||
|
if not urls:
|
||||||
|
print(
|
||||||
|
"textbook_tex source needs --url, --bundle, or --urls-from",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
from arborist.sources import TextbookTexSource
|
||||||
|
|
||||||
|
src = TextbookTexSource(urls)
|
||||||
elif args.source == "claim_pack":
|
elif args.source == "claim_pack":
|
||||||
# Companion JSON bundles (axiom + theorem packs) — see ticket
|
# Companion JSON bundles (axiom + theorem packs) — see ticket
|
||||||
# #000029. --bundle is repeatable so axiom-bundle and theorem-bundle
|
# #000029. --bundle is repeatable so axiom-bundle and theorem-bundle
|
||||||
|
|
@ -3908,6 +3931,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
"hg_repo",
|
"hg_repo",
|
||||||
"providence",
|
"providence",
|
||||||
"claim_pack",
|
"claim_pack",
|
||||||
|
"textbook_tex",
|
||||||
],
|
],
|
||||||
help="source type",
|
help="source type",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from arborist.sources.claim_pack import ClaimPackSource
|
from arborist.sources.claim_pack import ClaimPackSource
|
||||||
from arborist.sources.grok import GrokExportSource, GrokMediaPostsSource
|
from arborist.sources.grok import GrokExportSource, GrokMediaPostsSource
|
||||||
|
from arborist.sources.textbook_tex import TextbookTexSource
|
||||||
from arborist.sources.vcs import GitRepoSource, MercurialRepoSource
|
from arborist.sources.vcs import GitRepoSource, MercurialRepoSource
|
||||||
from arborist.sources.wikipedia import (
|
from arborist.sources.wikipedia import (
|
||||||
WikipediaCurDump,
|
WikipediaCurDump,
|
||||||
|
|
@ -15,6 +16,7 @@ __all__ = [
|
||||||
"GitRepoSource",
|
"GitRepoSource",
|
||||||
"GrokExportSource",
|
"GrokExportSource",
|
||||||
"GrokMediaPostsSource",
|
"GrokMediaPostsSource",
|
||||||
|
"TextbookTexSource",
|
||||||
"MercurialRepoSource",
|
"MercurialRepoSource",
|
||||||
"WikipediaAbstractDump",
|
"WikipediaAbstractDump",
|
||||||
"WikipediaCurDump",
|
"WikipediaCurDump",
|
||||||
|
|
|
||||||
320
arborist/sources/textbook_tex.py
Normal file
320
arborist/sources/textbook_tex.py
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
"""TeX-source textbook ingest (Project Gutenberg eBook style).
|
||||||
|
|
||||||
|
Targets the canonical Project Gutenberg LaTeX format used for math /
|
||||||
|
science textbooks where no HTML edition exists (e.g., Hilbert
|
||||||
|
*Foundations of Geometry* eBook #17384, Boole *An Investigation of
|
||||||
|
the Laws of Thought* eBook #15114). One Document per TeX source URL;
|
||||||
|
the strip pipeline produces searchable plain text suitable for the
|
||||||
|
existing 512-token chunker + Merkle commitment + audit chain.
|
||||||
|
|
||||||
|
Why this exists
|
||||||
|
---------------
|
||||||
|
The first iteration of textbook surface-ingest (#000031) used
|
||||||
|
``arborist/sources/html_page.py`` against Wikisource / openmathbooks
|
||||||
|
mirrors. Two of the most foundational PD textbooks have no clean
|
||||||
|
HTML edition — Project Gutenberg ships them only as PDF + LaTeX
|
||||||
|
source. Pandoc fails on PG's custom preamble macros (`\\rfa`,
|
||||||
|
`\\thispagestyle`, etc.); a focused regex stripper is the right
|
||||||
|
amount of machinery for the well-known PG TeX format.
|
||||||
|
|
||||||
|
What gets stripped
|
||||||
|
------------------
|
||||||
|
1. Line comments (`%`-prefixed lines and trailing `%` to EOL).
|
||||||
|
2. Preamble (everything before the first ``\\begin{document}``).
|
||||||
|
3. Block environments that yield no useful prose:
|
||||||
|
``tabular``, ``figure``, ``thebibliography``, ``titlepage``,
|
||||||
|
``flushright``, ``flushleft``, ``center``, ``small``, ``footnotesize``,
|
||||||
|
``scshape``, ``itemize``, ``enumerate`` (kept), and other
|
||||||
|
structural-only environments.
|
||||||
|
4. Common single-arg commands stripped to their argument:
|
||||||
|
``\\textbf{X}`` → ``X``; ``\\emph{X}`` → ``X``; ``\\rfa{X}`` →
|
||||||
|
``X``; ``\\section{X}`` → ``X``; ``\\chapter{X}`` → ``X``;
|
||||||
|
``\\paragraph{X}`` → ``X``.
|
||||||
|
5. Zero-arg / structural commands dropped:
|
||||||
|
``\\noindent``, ``\\bigskip``, ``\\smallskip``, ``\\medskip``,
|
||||||
|
``\\par``, ``\\linebreak``, ``\\newpage``, ``\\clearpage``,
|
||||||
|
``\\thispagestyle{...}``, ``\\setcounter{...}{...}``,
|
||||||
|
``\\pageref{...}``, ``\\label{...}``, ``\\index{...}``.
|
||||||
|
6. Math-mode delimiters preserved as-is. The body inside ``$...$``
|
||||||
|
stays raw — FTS5 will index Greek-letter and operator tokens; the
|
||||||
|
wikitext-base canonicalizer can handle math separately if needed.
|
||||||
|
7. Common macro substitutions:
|
||||||
|
``\\S`` → ``§``; ``\\dots``/``\\ldots`` → ``…``; ``\\&`` → ``&``;
|
||||||
|
``\\to`` → ``→``; ``\\neg`` → ``¬``; ``\\lor`` → ``∨``;
|
||||||
|
``\\land`` → ``∧``; ``~`` → ``\xa0`` (non-break space);
|
||||||
|
``\\\\`` → newline.
|
||||||
|
|
||||||
|
What's deliberately not stripped
|
||||||
|
--------------------------------
|
||||||
|
We don't try to render math, build cross-references, or extract
|
||||||
|
figures. The output is plain prose with embedded math source —
|
||||||
|
suitable for retrieval, not for reproduction. The PG PDF is the
|
||||||
|
canonical visual rendering.
|
||||||
|
|
||||||
|
Idempotency
|
||||||
|
-----------
|
||||||
|
Same TeX URL → same response body → same Merkle root → no-op
|
||||||
|
re-ingest. The TeX content is wrapped in a Document with
|
||||||
|
``source_type='textbook_tex'`` so re-runs against the same shard
|
||||||
|
deduplicate via the standard content-addressed insert.
|
||||||
|
|
||||||
|
Source: ticket #000031 §5 (PDF/TeX support).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from arborist.document import Document, Edge
|
||||||
|
from arborist.source import Source
|
||||||
|
|
||||||
|
|
||||||
|
USER_AGENT = "arborist/0.0.1 (+https://unturf.com)"
|
||||||
|
NORMALIZE_VERSION = "tex-strip-v1"
|
||||||
|
|
||||||
|
|
||||||
|
_PREAMBLE_RE = re.compile(r"^.*?\\begin\{document\}", re.DOTALL)
|
||||||
|
_POSTMATTER_RE = re.compile(r"\\end\{document\}.*$", re.DOTALL)
|
||||||
|
_LINE_COMMENT_RE = re.compile(r"(?m)(?<!\\)%[^\n]*")
|
||||||
|
|
||||||
|
# Block environments that produce no useful prose. The body between
|
||||||
|
# \begin{X} ... \end{X} is dropped wholesale.
|
||||||
|
_DROP_ENVS = (
|
||||||
|
"tabular", "tabularx", "tabular*",
|
||||||
|
"figure", "figure*", "wrapfigure",
|
||||||
|
"thebibliography",
|
||||||
|
"titlepage", "abstract",
|
||||||
|
"flushright", "flushleft", "center",
|
||||||
|
"small", "footnotesize", "scriptsize", "tiny",
|
||||||
|
"scshape", "rmfamily", "sffamily", "ttfamily",
|
||||||
|
"verbatim", "lstlisting",
|
||||||
|
)
|
||||||
|
_DROP_ENV_RE = re.compile(
|
||||||
|
r"\\begin\{(" + "|".join(re.escape(e) for e in _DROP_ENVS) + r")\}.*?\\end\{\1\}",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Single-arg commands stripped to their argument (regex captures
|
||||||
|
# argument's TeX, not balanced-brace correct but works on PG's
|
||||||
|
# straightforward usage).
|
||||||
|
_KEEP_ARG_CMDS = (
|
||||||
|
"textbf", "textit", "emph", "underline", "textsc",
|
||||||
|
"rfa", "rfb", # PG custom: roman fixed all-caps
|
||||||
|
"chapter", "chapter*",
|
||||||
|
"section", "section*", "subsection", "subsection*",
|
||||||
|
"subsubsection", "paragraph", "subparagraph",
|
||||||
|
"footnote",
|
||||||
|
"title", "author",
|
||||||
|
"mbox", "fbox",
|
||||||
|
)
|
||||||
|
_KEEP_ARG_RE = re.compile(
|
||||||
|
r"\\(" + "|".join(re.escape(c) for c in _KEEP_ARG_CMDS) + r")\*?\{([^{}]*)\}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Zero-arg + simple-arg commands dropped wholesale.
|
||||||
|
_DROP_CMDS_ZEROARG = (
|
||||||
|
"noindent", "bigskip", "smallskip", "medskip", "vspace", "hspace",
|
||||||
|
"par", "linebreak", "newpage", "clearpage", "newline",
|
||||||
|
"leavevmode", "centering", "raggedright", "raggedleft",
|
||||||
|
"hline", "cline", "rule",
|
||||||
|
"maketitle", "tableofcontents", "listoffigures", "listoftables",
|
||||||
|
)
|
||||||
|
_DROP_ZEROARG_RE = re.compile(
|
||||||
|
r"\\(" + "|".join(re.escape(c) for c in _DROP_CMDS_ZEROARG) + r")\b\*?"
|
||||||
|
)
|
||||||
|
_DROP_BRACE_CMDS = (
|
||||||
|
"thispagestyle", "pagestyle",
|
||||||
|
"setcounter", "addtocounter", "stepcounter",
|
||||||
|
"label", "ref", "pageref", "eqref", "cite",
|
||||||
|
"index", "indexset", "marginpar",
|
||||||
|
"documentclass", "usepackage", "input", "include",
|
||||||
|
"newcommand", "renewcommand", "newenvironment", "renewenvironment",
|
||||||
|
"definecolor", "color", "textcolor",
|
||||||
|
"setlength", "addtolength", "newlength",
|
||||||
|
"geometry", "fancyhead", "fancyfoot",
|
||||||
|
)
|
||||||
|
_DROP_BRACE_RE = re.compile(
|
||||||
|
r"\\(" + "|".join(re.escape(c) for c in _DROP_BRACE_CMDS)
|
||||||
|
+ r")\*?(?:\[[^\]]*\])?\{[^{}]*\}(?:\{[^{}]*\})?"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Macro substitutions — symbol-level.
|
||||||
|
_MACRO_SUBS = [
|
||||||
|
(r"\\S\b", "§"),
|
||||||
|
(r"\\P\b", "¶"),
|
||||||
|
(r"\\copyright\b", "©"),
|
||||||
|
(r"\\dots\b", "…"),
|
||||||
|
(r"\\ldots\b", "…"),
|
||||||
|
(r"\\cdots\b", "…"),
|
||||||
|
(r"\\to\b", "→"),
|
||||||
|
(r"\\rightarrow\b", "→"),
|
||||||
|
(r"\\leftarrow\b", "←"),
|
||||||
|
(r"\\leftrightarrow\b", "↔"),
|
||||||
|
(r"\\Rightarrow\b", "⇒"),
|
||||||
|
(r"\\Leftarrow\b", "⇐"),
|
||||||
|
(r"\\Leftrightarrow\b", "⇔"),
|
||||||
|
(r"\\neg\b", "¬"),
|
||||||
|
(r"\\lnot\b", "¬"),
|
||||||
|
(r"\\lor\b", "∨"),
|
||||||
|
(r"\\vee\b", "∨"),
|
||||||
|
(r"\\land\b", "∧"),
|
||||||
|
(r"\\wedge\b", "∧"),
|
||||||
|
(r"\\forall\b", "∀"),
|
||||||
|
(r"\\exists\b", "∃"),
|
||||||
|
(r"\\equiv\b", "≡"),
|
||||||
|
(r"\\neq\b", "≠"),
|
||||||
|
(r"\\leq\b", "≤"),
|
||||||
|
(r"\\geq\b", "≥"),
|
||||||
|
(r"\\times\b", "×"),
|
||||||
|
(r"\\cdot\b", "·"),
|
||||||
|
(r"\\pm\b", "±"),
|
||||||
|
(r"\\infty\b", "∞"),
|
||||||
|
(r"\\in\b", "∈"),
|
||||||
|
(r"\\notin\b", "∉"),
|
||||||
|
(r"\\subset\b", "⊂"),
|
||||||
|
(r"\\supset\b", "⊃"),
|
||||||
|
(r"\\cup\b", "∪"),
|
||||||
|
(r"\\cap\b", "∩"),
|
||||||
|
(r"\\alpha\b", "α"),
|
||||||
|
(r"\\beta\b", "β"),
|
||||||
|
(r"\\gamma\b", "γ"),
|
||||||
|
(r"\\delta\b", "δ"),
|
||||||
|
(r"\\epsilon\b", "ε"),
|
||||||
|
(r"\\theta\b", "θ"),
|
||||||
|
(r"\\lambda\b", "λ"),
|
||||||
|
(r"\\mu\b", "μ"),
|
||||||
|
(r"\\pi\b", "π"),
|
||||||
|
(r"\\sigma\b", "σ"),
|
||||||
|
(r"\\phi\b", "φ"),
|
||||||
|
(r"\\omega\b", "ω"),
|
||||||
|
(r"\\Sigma\b", "Σ"),
|
||||||
|
(r"\\Theta\b", "Θ"),
|
||||||
|
(r"\\Phi\b", "Φ"),
|
||||||
|
(r"\\Omega\b", "Ω"),
|
||||||
|
(r"\\&", "&"),
|
||||||
|
(r"\\\$", "$"),
|
||||||
|
(r"\\#", "#"),
|
||||||
|
(r"\\_", "_"),
|
||||||
|
(r"\\\\", "\n"),
|
||||||
|
(r"~", "\xa0"),
|
||||||
|
]
|
||||||
|
_MACRO_SUB_PATTERNS = [(re.compile(p), s) for p, s in _MACRO_SUBS]
|
||||||
|
|
||||||
|
# Whitespace cleanup
|
||||||
|
_MULTI_SPACE_RE = re.compile(r"[ \t]{2,}")
|
||||||
|
_MULTI_NEWLINE_RE = re.compile(r"\n{3,}")
|
||||||
|
|
||||||
|
|
||||||
|
def strip_tex(tex: str) -> str:
|
||||||
|
"""Convert a Project Gutenberg LaTeX source body into plain prose.
|
||||||
|
|
||||||
|
Imperfect by design — a focused regex pipeline tuned for the PG
|
||||||
|
canonical format. The output is suitable for retrieval (FTS5
|
||||||
|
indexing, embeddings, semantic search) but NOT for reproduction:
|
||||||
|
layout, figures, exact mathematical typesetting, and
|
||||||
|
cross-references are not preserved.
|
||||||
|
|
||||||
|
Pipeline (order matters):
|
||||||
|
1. Drop everything before ``\\begin{document}``.
|
||||||
|
2. Drop everything after ``\\end{document}``.
|
||||||
|
3. Strip line comments.
|
||||||
|
4. Drop block environments that yield no prose.
|
||||||
|
5. Strip 0-arg / brace-arg structural commands.
|
||||||
|
6. Reduce single-arg commands to their argument.
|
||||||
|
7. Apply symbol-level macro substitutions.
|
||||||
|
8. Whitespace normalize.
|
||||||
|
"""
|
||||||
|
text = tex
|
||||||
|
text = _PREAMBLE_RE.sub("", text)
|
||||||
|
text = _POSTMATTER_RE.sub("", text)
|
||||||
|
text = _LINE_COMMENT_RE.sub("", text)
|
||||||
|
# Run the drop-env pass twice to handle nested same-type drops
|
||||||
|
# (e.g., a `tabular` inside a `figure`).
|
||||||
|
for _ in range(2):
|
||||||
|
text = _DROP_ENV_RE.sub("", text)
|
||||||
|
text = _DROP_BRACE_RE.sub("", text)
|
||||||
|
text = _DROP_ZEROARG_RE.sub("", text)
|
||||||
|
text = _KEEP_ARG_RE.sub(r"\2", text)
|
||||||
|
for pat, sub in _MACRO_SUB_PATTERNS:
|
||||||
|
text = pat.sub(sub, text)
|
||||||
|
# Drop any residual single-token commands (catch-all for unknown
|
||||||
|
# cleanup-safe commands).
|
||||||
|
text = re.sub(r"\\[A-Za-z]+\*?", "", text)
|
||||||
|
# Drop residual {...} braces that lost their command.
|
||||||
|
text = re.sub(r"[{}]+", "", text)
|
||||||
|
# Whitespace normalize
|
||||||
|
text = _MULTI_SPACE_RE.sub(" ", text)
|
||||||
|
text = _MULTI_NEWLINE_RE.sub("\n\n", text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class TextbookTexSource(Source):
|
||||||
|
"""Fetch + strip a Project-Gutenberg-style LaTeX source URL into a
|
||||||
|
single Document.
|
||||||
|
|
||||||
|
One URL = one Document. Use a manifest entry's ``tex_url`` field
|
||||||
|
or pass URLs directly. Idempotent at the database layer because
|
||||||
|
the strip output is deterministic (same TeX → same prose → same
|
||||||
|
document_root).
|
||||||
|
"""
|
||||||
|
|
||||||
|
source_type = "textbook_tex"
|
||||||
|
|
||||||
|
def __init__(self, urls: list[str] | str, *, timeout: float = 60.0):
|
||||||
|
if isinstance(urls, str):
|
||||||
|
urls = [urls]
|
||||||
|
self.urls = list(urls)
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def iter_documents(self) -> Iterator[Document]:
|
||||||
|
with httpx.Client(
|
||||||
|
headers={"User-Agent": USER_AGENT},
|
||||||
|
timeout=self.timeout,
|
||||||
|
follow_redirects=True,
|
||||||
|
) as client:
|
||||||
|
for url in self.urls:
|
||||||
|
try:
|
||||||
|
resp = client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except httpx.HTTPError:
|
||||||
|
continue
|
||||||
|
tex = resp.text
|
||||||
|
prose = strip_tex(tex)
|
||||||
|
if not prose or len(prose) < 200:
|
||||||
|
# Stripped output too thin to be useful — likely
|
||||||
|
# an error page or a non-TeX response.
|
||||||
|
continue
|
||||||
|
title = _extract_title(tex) or url
|
||||||
|
yield Document(
|
||||||
|
uri=str(resp.url),
|
||||||
|
content=prose,
|
||||||
|
source_type=self.source_type,
|
||||||
|
title=title,
|
||||||
|
edges=[],
|
||||||
|
extra={"tex_url": url, "normalize_version": NORMALIZE_VERSION},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls, path: str | Path, **kwargs) -> "TextbookTexSource":
|
||||||
|
urls = [
|
||||||
|
line.strip()
|
||||||
|
for line in Path(path).read_text(encoding="utf-8").splitlines()
|
||||||
|
if line.strip() and not line.lstrip().startswith("#")
|
||||||
|
]
|
||||||
|
return cls(urls, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
_TITLE_RE = re.compile(r"\\title\{([^{}]+)\}")
|
||||||
|
_AUTHOR_RE = re.compile(r"\\author\{([^{}]+)\}")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_title(tex: str) -> str:
|
||||||
|
"""Pull the title from \\title{...} preamble (best-effort)."""
|
||||||
|
m = _TITLE_RE.search(tex)
|
||||||
|
if not m:
|
||||||
|
return ""
|
||||||
|
return m.group(1).strip()
|
||||||
|
|
@ -196,19 +196,40 @@ Coverage by g4 pillar (per the claim-pack source #000029):
|
||||||
|
|
||||||
| Pillar | Domain | Status | Source |
|
| Pillar | Domain | Status | Source |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| I | Logic | ✓ landed | Levin (CC-BY-SA), Aristotle Prior (PD), Aristotle Posterior (PD) |
|
| I | Logic | ✓ landed | Levin (CC-BY-SA), Aristotle Prior (PD), Aristotle Posterior (PD), Boole (PD, PG TeX) |
|
||||||
| II | Set Theory | ✓ landed | Levin (CC-BY-SA) — covers ZF basics |
|
| II | Set Theory | ✓ landed | Levin (CC-BY-SA) — covers ZF basics |
|
||||||
| III | Arithmetic (Peano) | ✓ landed | Levin (CC-BY-SA) — covers Peano basics |
|
| III | Arithmetic (Peano) | ✓ landed | Levin (CC-BY-SA) — covers Peano basics |
|
||||||
| IV | Geometry | ✗ pending | Hilbert PG #17384 — PDF/TeX only, awaits TeX-source |
|
| IV | Geometry | ✓ landed | Hilbert (PD, PG TeX) |
|
||||||
| V | Probability | ✗ pending | Kolmogorov 1933 license analysis pending |
|
| V | Probability | ✗ pending | Kolmogorov 1933 license analysis pending |
|
||||||
| VI | Classical Physics | ✓ landed | Newton Principia Motte (PD, Wikisource) |
|
| VI | Classical Physics | ✓ landed | Newton Principia Motte (PD, Wikisource) |
|
||||||
| VII | Combinatorics | ✓ landed | Bogart (GFDL), Keller-Trotter (CC-BY-SA, slow), Levin |
|
| VII | Combinatorics | ✓ landed | Bogart (GFDL), Keller-Trotter (CC-BY-SA, slow), Levin |
|
||||||
| IX | λ-Calculus | ✗ pending | Church 1936 + Turing 1936 papers; need separate ingest path |
|
| IX | λ-Calculus | ✗ pending | Church 1936 + Turing 1936 papers; need separate ingest path |
|
||||||
|
|
||||||
**5 of 7 pillars** have surface coverage; remaining gaps are
|
**6 of 7 pillars** have surface coverage. Remaining gaps:
|
||||||
explicit license (V) or format (IV PDF/TeX, IX paper-level)
|
|
||||||
issues, each documented in the corresponding manifest entry's
|
- **Pillar V (Probability)** — Kolmogorov *Foundations of the Theory
|
||||||
`notes` field.
|
of Probability* license analysis pending (German original PD-by-
|
||||||
|
age in EU; US copyright restored via URAA through 2058).
|
||||||
|
- **Pillar IX (λ-Calculus)** — Church 1936 + Turing 1936 papers
|
||||||
|
rather than full books; awaits a paper-level ingest helper.
|
||||||
|
|
||||||
|
## TeX-source ingest path
|
||||||
|
|
||||||
|
Two PD textbooks ship from Project Gutenberg as LaTeX source only
|
||||||
|
(no clean HTML edition):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make textbook-hilbert # PG #17384 — Foundations of Geometry
|
||||||
|
make textbook-boole # PG #15114 — Laws of Thought
|
||||||
|
make textbooks-tex # all manifest entries with a tex_url field
|
||||||
|
```
|
||||||
|
|
||||||
|
These use `arborist ingest --source textbook_tex` which fetches
|
||||||
|
the `.tex` URL, runs a focused PG-aware LaTeX-strip pipeline
|
||||||
|
(drops preamble + comments + `tabular`/`figure` envs;
|
||||||
|
keeps argument of `\textbf` / `\emph` / `\section` etc.;
|
||||||
|
substitutes `\to` → →, `\neg` → ¬, `\forall` → ∀, etc.), and
|
||||||
|
produces plain prose for the standard 512-token chunker.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,27 @@ def cmd_ids(stream: TextIO) -> int:
|
||||||
return 0
|
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:
|
def cmd_lookup(stream: TextIO) -> int:
|
||||||
"""Read entry id from sys.argv[2]; emit tab-separated
|
"""Read entry id from sys.argv[2]; emit tab-separated
|
||||||
`id\\tcrawl_url\\tdepth\\tmax\\tlicense\\tdomain` for the matching
|
`id\\tcrawl_url\\tdepth\\tmax\\tlicense\\tdomain` for the matching
|
||||||
|
|
@ -191,6 +212,7 @@ _COMMANDS = {
|
||||||
"urls": cmd_urls,
|
"urls": cmd_urls,
|
||||||
"summary": cmd_summary,
|
"summary": cmd_summary,
|
||||||
"crawl-targets": cmd_crawl_targets,
|
"crawl-targets": cmd_crawl_targets,
|
||||||
|
"tex-targets": cmd_tex_targets,
|
||||||
"ids": cmd_ids,
|
"ids": cmd_ids,
|
||||||
"lookup": cmd_lookup,
|
"lookup": cmd_lookup,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
215
tests/test_textbook_tex.py
Normal file
215
tests/test_textbook_tex.py
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
"""Tests for the TextbookTexSource (#000031 §5).
|
||||||
|
|
||||||
|
Pure unit tests over the strip_tex pipeline. Network ingest is
|
||||||
|
exercised via `make textbook-hilbert` / `make textbook-boole`; this
|
||||||
|
file stays offline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arborist.sources.textbook_tex import strip_tex
|
||||||
|
|
||||||
|
|
||||||
|
# --- preamble + postmatter stripping ---------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_strips_preamble_and_postmatter():
|
||||||
|
tex = r"""
|
||||||
|
% comment line
|
||||||
|
\documentclass{book}
|
||||||
|
\usepackage{amsmath}
|
||||||
|
\title{Test Book}
|
||||||
|
\begin{document}
|
||||||
|
This is the body.
|
||||||
|
\end{document}
|
||||||
|
% trailing junk
|
||||||
|
"""
|
||||||
|
out = strip_tex(tex)
|
||||||
|
assert "documentclass" not in out
|
||||||
|
assert "amsmath" not in out
|
||||||
|
assert "trailing junk" not in out
|
||||||
|
assert "This is the body." in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_drops_line_comments():
|
||||||
|
tex = r"""
|
||||||
|
\begin{document}
|
||||||
|
First sentence. % this is a comment
|
||||||
|
Second sentence.
|
||||||
|
\end{document}
|
||||||
|
"""
|
||||||
|
out = strip_tex(tex)
|
||||||
|
assert "comment" not in out.lower()
|
||||||
|
assert "First sentence." in out
|
||||||
|
assert "Second sentence." in out
|
||||||
|
|
||||||
|
|
||||||
|
# --- environment dropping --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_drops_tabular_environment():
|
||||||
|
tex = r"""
|
||||||
|
\begin{document}
|
||||||
|
Before table.
|
||||||
|
\begin{tabular}{lr}
|
||||||
|
foo & bar \\
|
||||||
|
\end{tabular}
|
||||||
|
After table.
|
||||||
|
\end{document}
|
||||||
|
"""
|
||||||
|
out = strip_tex(tex)
|
||||||
|
assert "Before table." in out
|
||||||
|
assert "After table." in out
|
||||||
|
assert "foo" not in out
|
||||||
|
assert "tabular" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_drops_figure_environment():
|
||||||
|
tex = r"""
|
||||||
|
\begin{document}
|
||||||
|
Body 1.
|
||||||
|
\begin{figure}
|
||||||
|
\includegraphics{img.png}
|
||||||
|
\caption{Caption text}
|
||||||
|
\end{figure}
|
||||||
|
Body 2.
|
||||||
|
\end{document}
|
||||||
|
"""
|
||||||
|
out = strip_tex(tex)
|
||||||
|
assert "Body 1." in out
|
||||||
|
assert "Body 2." in out
|
||||||
|
assert "Caption text" not in out
|
||||||
|
|
||||||
|
|
||||||
|
# --- single-arg command stripping ------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_keeps_arg_for_emphasis_commands():
|
||||||
|
tex = r"""
|
||||||
|
\begin{document}
|
||||||
|
This is \textbf{bold} and \emph{italic} text.
|
||||||
|
\end{document}
|
||||||
|
"""
|
||||||
|
out = strip_tex(tex)
|
||||||
|
assert "bold" in out
|
||||||
|
assert "italic" in out
|
||||||
|
assert "textbf" not in out
|
||||||
|
assert "emph" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_keeps_arg_for_section_commands():
|
||||||
|
tex = r"""
|
||||||
|
\begin{document}
|
||||||
|
\section{Introduction}
|
||||||
|
\subsection{Background}
|
||||||
|
Body text.
|
||||||
|
\end{document}
|
||||||
|
"""
|
||||||
|
out = strip_tex(tex)
|
||||||
|
assert "Introduction" in out
|
||||||
|
assert "Background" in out
|
||||||
|
assert "Body text." in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_keeps_pg_custom_commands():
|
||||||
|
"""PG uses \\rfa for roman fixed all-caps section headings."""
|
||||||
|
tex = r"""
|
||||||
|
\begin{document}
|
||||||
|
\rfa{CONTENTS}
|
||||||
|
Body.
|
||||||
|
\end{document}
|
||||||
|
"""
|
||||||
|
out = strip_tex(tex)
|
||||||
|
assert "CONTENTS" in out
|
||||||
|
assert "rfa" not in out
|
||||||
|
|
||||||
|
|
||||||
|
# --- macro substitution ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"tex_in,expected",
|
||||||
|
[
|
||||||
|
(r"\S 5", "§ 5"),
|
||||||
|
(r"A \to B", "A → B"),
|
||||||
|
(r"\neg P", "¬ P"),
|
||||||
|
(r"P \lor Q", "P ∨ Q"),
|
||||||
|
(r"P \land Q", "P ∧ Q"),
|
||||||
|
(r"\forall x", "∀ x"),
|
||||||
|
(r"\exists y", "∃ y"),
|
||||||
|
(r"\alpha + \beta", "α + β"),
|
||||||
|
(r"\dots", "…"),
|
||||||
|
(r"x \in S", "x ∈ S"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_macro_substitutions(tex_in, expected):
|
||||||
|
tex = r"\begin{document}" + "\n" + tex_in + "\n" + r"\end{document}"
|
||||||
|
out = strip_tex(tex)
|
||||||
|
assert expected in out
|
||||||
|
|
||||||
|
|
||||||
|
# --- structural commands dropped -------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_drops_structural_commands():
|
||||||
|
tex = r"""
|
||||||
|
\begin{document}
|
||||||
|
\noindent Body. \bigskip
|
||||||
|
\thispagestyle{empty}
|
||||||
|
\setcounter{page}{1}
|
||||||
|
\label{intro}
|
||||||
|
\index{topic}
|
||||||
|
More body.
|
||||||
|
\end{document}
|
||||||
|
"""
|
||||||
|
out = strip_tex(tex)
|
||||||
|
assert "Body." in out
|
||||||
|
assert "More body." in out
|
||||||
|
assert "noindent" not in out
|
||||||
|
assert "thispagestyle" not in out
|
||||||
|
assert "setcounter" not in out
|
||||||
|
assert "label" not in out
|
||||||
|
assert "index" not in out
|
||||||
|
|
||||||
|
|
||||||
|
# --- whitespace cleanup ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_collapses_multi_blank_lines():
|
||||||
|
tex = r"""
|
||||||
|
\begin{document}
|
||||||
|
First.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Second.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Third.
|
||||||
|
\end{document}
|
||||||
|
"""
|
||||||
|
out = strip_tex(tex)
|
||||||
|
# No more than 2 consecutive newlines (= 1 blank line)
|
||||||
|
assert "\n\n\n" not in out
|
||||||
|
|
||||||
|
|
||||||
|
# --- end-to-end stability --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_idempotent_on_already_stripped_text():
|
||||||
|
"""Plain ASCII text without any LaTeX should pass through with
|
||||||
|
only whitespace normalization."""
|
||||||
|
tex = r"""
|
||||||
|
\begin{document}
|
||||||
|
This is plain text with no LaTeX commands at all.
|
||||||
|
Multiple sentences. No special characters.
|
||||||
|
\end{document}
|
||||||
|
"""
|
||||||
|
out = strip_tex(tex)
|
||||||
|
assert "This is plain text" in out
|
||||||
|
# Round-trip: stripping again should produce the same output.
|
||||||
|
out2 = strip_tex(r"\begin{document}" + out + r"\end{document}")
|
||||||
|
assert out2 == out
|
||||||
Loading…
Add table
Add a link
Reference in a new issue