arborist/bench/scripts/textbooks_manifest.py
russell@unturf.com 551c9695e0
ticket #000031 follow-ups B-1 + B-2: alias attribution + source-side title author
B-1: via_citation_alias attribution — resolver no longer mislabels
citation-alias-substituted chains as DIRECT.

  - New Citation.via_citation_alias field (default False, preserves
    parse-from-source-ref path).
  - warrant_status sets via_citation_alias=True on substitute
    Citations from #000041 lookup_citation_aliases.
  - resolve_chunks reads citation.via_citation_alias as a "floor" for
    via_alias on every match it produces (Pass 1 hits inherit it
    too, not just Pass 2 term-alias hits). Audit-honest: matches
    from a substitute Citation are alias-driven regardless of which
    cascade pass found the chunk.

  Live re-resolve under the new attribution: 18 direct + 74 +alias
  (was 75/17 mis-labeled). The 18 direct = exactly Hilbert pillar IV
  records resolving on the literally-cited Hilbert textbook. All 74
  records resolved via citation-alias substitution now carry
  process_id="warrant-resolver-v1+alias" in derivations.

  3 new unit tests (test_warrant_resolver.py): default-False on
  parsed Citations, explicit-True construction works, resolve_chunks
  propagates the floor onto every ResolutionMatch.

B-2: source-side title-from-author backfill — eliminates the
per-shard SQL UPDATE workaround.

  - HtmlPageSource accepts default_author kwarg; appends ', by
    <author>' to ingested document titles when the <title> tag
    doesn't already include the surname.
  - TextbookTexSource accepts default_author kwarg; appends ' by
    <author>' to titles when the LaTeX has no \author{} macro AND
    no PG-style 'Author:' boilerplate.
  - _CrawledHtmlSource (BFS-crawler bridge) accepts default_author
    kwarg; same append logic. ingest_crawled() and arborist crawl
    --ingest plumb it through.
  - arborist ingest --author + arborist crawl --author CLI flags.
  - bench/scripts/textbooks_manifest.py:cmd_lookup emits the
    manifest's `author` field as a 7th tab column.
  - make textbook target reads the author column and threads
    --author into both crawl-ingest and shallow-ingest paths.

  Idempotency preserved — surname-already-in-title detection prevents
  double-stamping on re-ingest. Shards previously SQL-backfilled
  (Cantor / Russell IMP / Bogart / Judson / Levin / KT / Peano /
  Grinstead-Snell) keep their existing titles; new ingests pick up
  the author signal at source time.

  Live smoke: arborist ingest --source html --author "Bertrand
  Russell" against PG #41654 yields title "Introduction to
  Mathematical Philosophy | Project Gutenberg, by Bertrand Russell"
  with no SQL UPDATE needed.

Total: 1655 tests pass (was 1652). Both follow-ups land additive,
fail-closed, idempotent. The two cleanup items from #000031
Phase 3's commit message are now closed.
2026-05-10 09:35:49 -04:00

236 lines
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\\tauthor` for the
matching entry. Used by per-book make targets to look up one
textbook's crawl parameters + author signal (#000031 Phase 1
follow-up — author flows into ``arborist crawl --author`` /
``arborist ingest --author`` so document titles carry the
surname downstream resolvers need).
"""
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)
author = entry.get("author", "")
print(
f"{entry.get('id')}\t{crawl_url}\t{depth}\t{max_pages}\t"
f"{entry.get('license')}\t{entry.get('domain')}\t{author}"
)
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))