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.
236 lines
8 KiB
Python
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))
|