primarysource 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (303) hide show
  1. primarysource/__init__.py +5 -0
  2. primarysource/bbf/__init__.py +56 -0
  3. primarysource/bbf/_xml.py +63 -0
  4. primarysource/bbf/catalogue.json +777 -0
  5. primarysource/bbf/catalogue.py +137 -0
  6. primarysource/bbf/cli.py +79 -0
  7. primarysource/bbf/datamodel.py +281 -0
  8. primarysource/bbf/importer.py +151 -0
  9. primarysource/bbf/library.py +380 -0
  10. primarysource/bbf/server.py +72 -0
  11. primarysource/budget.py +63 -0
  12. primarysource/c2sp/__init__.py +53 -0
  13. primarysource/c2sp/_fetch.py +5 -0
  14. primarysource/c2sp/catalogue.json +231 -0
  15. primarysource/c2sp/catalogue.py +95 -0
  16. primarysource/c2sp/cli.py +62 -0
  17. primarysource/c2sp/importer.py +90 -0
  18. primarysource/c2sp/library.py +309 -0
  19. primarysource/c2sp/markdown.py +31 -0
  20. primarysource/c2sp/server.py +68 -0
  21. primarysource/cavp/__init__.py +42 -0
  22. primarysource/cavp/cli.py +84 -0
  23. primarysource/cavp/data/vectors.toml +47 -0
  24. primarysource/cavp/importer.py +156 -0
  25. primarysource/cavp/model.py +279 -0
  26. primarysource/cavp/parse.py +269 -0
  27. primarysource/cavp/paths.py +17 -0
  28. primarysource/cavp/server.py +67 -0
  29. primarysource/cavp/tools.py +169 -0
  30. primarysource/cli.py +107 -0
  31. primarysource/cursor.py +82 -0
  32. primarysource/cve/__init__.py +5 -0
  33. primarysource/cve/__main__.py +6 -0
  34. primarysource/cve/cli.py +144 -0
  35. primarysource/cve/collection.py +20 -0
  36. primarysource/cve/config.py +58 -0
  37. primarysource/cve/errors.py +16 -0
  38. primarysource/cve/kernel.py +232 -0
  39. primarysource/cve/mcp.py +139 -0
  40. primarysource/cve/parse.py +318 -0
  41. primarysource/cve/py.typed +0 -0
  42. primarysource/cve/service.py +760 -0
  43. primarysource/cve/store.py +214 -0
  44. primarysource/cve/sync.py +438 -0
  45. primarysource/cve/views.py +304 -0
  46. primarysource/debpolicy/__init__.py +16 -0
  47. primarysource/debpolicy/catalogue.py +263 -0
  48. primarysource/debpolicy/cli.py +12 -0
  49. primarysource/debpolicy/server.py +11 -0
  50. primarysource/draft/__init__.py +11 -0
  51. primarysource/draft/catalogue.py +117 -0
  52. primarysource/draft/cli.py +149 -0
  53. primarysource/draft/collection.py +21 -0
  54. primarysource/draft/datatracker.py +158 -0
  55. primarysource/draft/importer.py +163 -0
  56. primarysource/draft/licence.py +89 -0
  57. primarysource/draft/names.py +64 -0
  58. primarysource/draft/net.py +133 -0
  59. primarysource/draft/segment.py +192 -0
  60. primarysource/draft/server.py +91 -0
  61. primarysource/draft/status.py +146 -0
  62. primarysource/draft/store.py +199 -0
  63. primarysource/draft/tracked.json +104 -0
  64. primarysource/draft/views.py +430 -0
  65. primarysource/draft/xmlmeta.py +74 -0
  66. primarysource/ecma/__init__.py +52 -0
  67. primarysource/ecma/_pdf.py +143 -0
  68. primarysource/ecma/catalogue.json +22 -0
  69. primarysource/ecma/catalogue.py +100 -0
  70. primarysource/ecma/cli.py +67 -0
  71. primarysource/ecma/identify.py +25 -0
  72. primarysource/ecma/importer.py +108 -0
  73. primarysource/ecma/library.py +240 -0
  74. primarysource/ecma/server.py +67 -0
  75. primarysource/errors.py +29 -0
  76. primarysource/fetch.py +360 -0
  77. primarysource/fsutil.py +150 -0
  78. primarysource/gitdocs/__init__.py +15 -0
  79. primarysource/gitdocs/catalogue.py +116 -0
  80. primarysource/gitdocs/cli.py +12 -0
  81. primarysource/gitdocs/server.py +8 -0
  82. primarysource/home.py +65 -0
  83. primarysource/iana/__init__.py +32 -0
  84. primarysource/iana/catalogue.json +244 -0
  85. primarysource/iana/catalogue.py +69 -0
  86. primarysource/iana/cli.py +69 -0
  87. primarysource/iana/importer.py +157 -0
  88. primarysource/iana/model.py +325 -0
  89. primarysource/iana/reader.py +315 -0
  90. primarysource/iana/server.py +67 -0
  91. primarysource/iana/store.py +83 -0
  92. primarysource/iana/xmlsafe.py +47 -0
  93. primarysource/identify.py +186 -0
  94. primarysource/ieee/__init__.py +58 -0
  95. primarysource/ieee/_html.py +49 -0
  96. primarysource/ieee/catalogue.json +74 -0
  97. primarysource/ieee/catalogue.py +123 -0
  98. primarysource/ieee/cli.py +79 -0
  99. primarysource/ieee/identify.py +24 -0
  100. primarysource/ieee/ieee802.py +47 -0
  101. primarysource/ieee/importer.py +305 -0
  102. primarysource/ieee/posix.py +233 -0
  103. primarysource/ieee/server.py +79 -0
  104. primarysource/index.py +446 -0
  105. primarysource/iso/__init__.py +51 -0
  106. primarysource/iso/cli.py +98 -0
  107. primarysource/iso/designation.py +95 -0
  108. primarysource/iso/identify.py +100 -0
  109. primarysource/iso/importer.py +224 -0
  110. primarysource/iso/library.py +249 -0
  111. primarysource/iso/server.py +69 -0
  112. primarysource/itu/__init__.py +41 -0
  113. primarysource/itu/__main__.py +8 -0
  114. primarysource/itu/asn1mod.py +478 -0
  115. primarysource/itu/catalog.py +472 -0
  116. primarysource/itu/cli.py +445 -0
  117. primarysource/itu/collection.py +20 -0
  118. primarysource/itu/corpus.py +118 -0
  119. primarysource/itu/errata.py +16 -0
  120. primarysource/itu/formal.py +565 -0
  121. primarysource/itu/identify.py +26 -0
  122. primarysource/itu/index.py +490 -0
  123. primarysource/itu/mcp.py +474 -0
  124. primarysource/itu/py.typed +0 -0
  125. primarysource/itu/sources.py +600 -0
  126. primarysource/itu/text.py +609 -0
  127. primarysource/kconfig/__init__.py +16 -0
  128. primarysource/kconfig/catalogue.py +98 -0
  129. primarysource/kconfig/cli.py +12 -0
  130. primarysource/kconfig/server.py +8 -0
  131. primarysource/model.py +63 -0
  132. primarysource/nist/__init__.py +5 -0
  133. primarysource/nist/collection.py +46 -0
  134. primarysource/nist/data/acquired-nist.json +1657 -0
  135. primarysource/nist/data/nist-catalog.json +12090 -0
  136. primarysource/nist/data/sources.toml +1996 -0
  137. primarysource/nist/identify.py +41 -0
  138. primarysource/nist/ingest/__init__.py +1 -0
  139. primarysource/nist/ingest/acquire_nist.py +128 -0
  140. primarysource/nist/ingest/controls.py +82 -0
  141. primarysource/nist/ingest/discover_nist.py +218 -0
  142. primarysource/nist/ingest/extract.py +48 -0
  143. primarysource/nist/ingest/fetch.py +211 -0
  144. primarysource/nist/ingest/index.py +14 -0
  145. primarysource/nist/ingest/oscal.py +480 -0
  146. primarysource/nist/ingest/prose_layouts.py +236 -0
  147. primarysource/nist/ingest/sources.py +94 -0
  148. primarysource/nist/ingest/status.py +255 -0
  149. primarysource/nist/models.py +7 -0
  150. primarysource/nist/paths.py +33 -0
  151. primarysource/nist/server/__init__.py +3 -0
  152. primarysource/nist/server/corpus.py +38 -0
  153. primarysource/nist/server/mcp.py +19 -0
  154. primarysource/nist/server/tools.py +35 -0
  155. primarysource/nist/spec.py +38 -0
  156. primarysource/pdfcorpus/__init__.py +16 -0
  157. primarysource/pdfcorpus/corpus.py +159 -0
  158. primarysource/pdfcorpus/fts.py +175 -0
  159. primarysource/pdfcorpus/index.py +70 -0
  160. primarysource/pdfcorpus/models.py +30 -0
  161. primarysource/pdfcorpus/server.py +96 -0
  162. primarysource/pdfcorpus/spec.py +37 -0
  163. primarysource/pdfcorpus/split.py +391 -0
  164. primarysource/pdfcorpus/tools.py +244 -0
  165. primarysource/pep/__init__.py +15 -0
  166. primarysource/pep/catalogue.py +191 -0
  167. primarysource/pep/cli.py +12 -0
  168. primarysource/pep/server.py +8 -0
  169. primarysource/pkgdocs/__init__.py +19 -0
  170. primarysource/pkgdocs/fetch.py +356 -0
  171. primarysource/pkgdocs/index.py +89 -0
  172. primarysource/pkgdocs/model.py +76 -0
  173. primarysource/pkgdocs/readers/__init__.py +1 -0
  174. primarysource/pkgdocs/readers/_common.py +134 -0
  175. primarysource/pkgdocs/readers/asciidoc.py +160 -0
  176. primarysource/pkgdocs/readers/latex.py +315 -0
  177. primarysource/pkgdocs/readers/markdown.py +173 -0
  178. primarysource/pkgdocs/readers/pod.py +132 -0
  179. primarysource/pkgdocs/readers/rst.py +239 -0
  180. primarysource/pkgdocs/readers/scdoc.py +108 -0
  181. primarysource/pkgdocs/runtime.py +552 -0
  182. primarysource/pkgdocs/serving.py +158 -0
  183. primarysource/pkgdocs/store.py +167 -0
  184. primarysource/pms/__init__.py +16 -0
  185. primarysource/pms/catalogue.py +88 -0
  186. primarysource/pms/cli.py +12 -0
  187. primarysource/pms/server.py +8 -0
  188. primarysource/py.typed +0 -0
  189. primarysource/readers/__init__.py +10 -0
  190. primarysource/readers/bookmarks.py +192 -0
  191. primarysource/readers/html.py +1312 -0
  192. primarysource/readers/markdown.py +189 -0
  193. primarysource/readers/pdf.py +187 -0
  194. primarysource/readers/xml.py +230 -0
  195. primarysource/refs.py +87 -0
  196. primarysource/registry.py +161 -0
  197. primarysource/render.py +49 -0
  198. primarysource/rfc/__init__.py +3 -0
  199. primarysource/rfc/__main__.py +6 -0
  200. primarysource/rfc/cite.py +204 -0
  201. primarysource/rfc/cli.py +903 -0
  202. primarysource/rfc/collection.py +19 -0
  203. primarysource/rfc/config.py +138 -0
  204. primarysource/rfc/errata.py +203 -0
  205. primarysource/rfc/fsutil.py +82 -0
  206. primarysource/rfc/graph.py +213 -0
  207. primarysource/rfc/index/__init__.py +1 -0
  208. primarysource/rfc/index/chunker.py +281 -0
  209. primarysource/rfc/index/embed.py +166 -0
  210. primarysource/rfc/index/hybrid.py +305 -0
  211. primarysource/rfc/index/lexical.py +223 -0
  212. primarysource/rfc/index/rfcxml.py +673 -0
  213. primarysource/rfc/index/search.py +508 -0
  214. primarysource/rfc/index/txtseg.py +585 -0
  215. primarysource/rfc/library.py +294 -0
  216. primarysource/rfc/mcp/__init__.py +1 -0
  217. primarysource/rfc/mcp/server.py +381 -0
  218. primarysource/rfc/packs/__init__.py +1 -0
  219. primarysource/rfc/packs/builder.py +228 -0
  220. primarysource/rfc/packs/collections/all-current-txt.json +10 -0
  221. primarysource/rfc/packs/collections/essentials.json +19 -0
  222. primarysource/rfc/packs/collections/fyi-current.json +10 -0
  223. primarysource/rfc/packs/collections/standards-current.json +10 -0
  224. primarysource/rfc/packs/collections/year-2025.json +10 -0
  225. primarysource/rfc/packs/collections/year-2026.json +10 -0
  226. primarysource/rfc/packs/collections.py +163 -0
  227. primarysource/rfc/packs/docsetexport.py +163 -0
  228. primarysource/rfc/packs/epub.py +159 -0
  229. primarysource/rfc/packs/export.py +306 -0
  230. primarysource/rfc/packs/htmlexport.py +82 -0
  231. primarysource/rfc/packs/serve.py +143 -0
  232. primarysource/rfc/packs/zimexport.py +171 -0
  233. primarysource/rfc/query.py +466 -0
  234. primarysource/rfc/subjects.py +457 -0
  235. primarysource/rfc/sync/__init__.py +1 -0
  236. primarysource/rfc/sync/downloader.py +132 -0
  237. primarysource/rfc/sync/manifest.py +162 -0
  238. primarysource/rfc/sync/scanner.py +49 -0
  239. primarysource/rfc/sync/state.py +464 -0
  240. primarysource/rfc/views.py +1067 -0
  241. primarysource/rpmdocs/__init__.py +15 -0
  242. primarysource/rpmdocs/catalogue.py +117 -0
  243. primarysource/rpmdocs/cli.py +12 -0
  244. primarysource/rpmdocs/server.py +8 -0
  245. primarysource/scaffold.py +223 -0
  246. primarysource/serve.py +75 -0
  247. primarysource/settings.py +152 -0
  248. primarysource/usb/__init__.py +5 -0
  249. primarysource/usb/collection.py +37 -0
  250. primarysource/usb/data/acquired.json +2025 -0
  251. primarysource/usb/data/sources-usb.toml +852 -0
  252. primarysource/usb/data/sources.toml +10 -0
  253. primarysource/usb/data/usb-catalog.json +1663 -0
  254. primarysource/usb/data/usb-classification.json +1986 -0
  255. primarysource/usb/data/usb-corpus-build.json +608 -0
  256. primarysource/usb/data/usb-pdf-choice.json +839 -0
  257. primarysource/usb/data/usb-split-measure.json +1803 -0
  258. primarysource/usb/ingest/__init__.py +1 -0
  259. primarysource/usb/ingest/acquire.py +130 -0
  260. primarysource/usb/ingest/discover_usb.py +88 -0
  261. primarysource/usb/ingest/extract.py +48 -0
  262. primarysource/usb/ingest/fetch.py +197 -0
  263. primarysource/usb/ingest/index.py +14 -0
  264. primarysource/usb/ingest/sources.py +85 -0
  265. primarysource/usb/models.py +7 -0
  266. primarysource/usb/paths.py +33 -0
  267. primarysource/usb/server/__init__.py +3 -0
  268. primarysource/usb/server/corpus.py +38 -0
  269. primarysource/usb/server/mcp.py +19 -0
  270. primarysource/usb/server/tools.py +35 -0
  271. primarysource/usb/spec.py +20 -0
  272. primarysource/usbids/__init__.py +56 -0
  273. primarysource/usbids/cli.py +65 -0
  274. primarysource/usbids/importer.py +272 -0
  275. primarysource/usbids/parse.py +283 -0
  276. primarysource/usbids/server.py +55 -0
  277. primarysource/usbids/store.py +80 -0
  278. primarysource/usbids/tools.py +174 -0
  279. primarysource/zim/__init__.py +5 -0
  280. primarysource/zim/__main__.py +8 -0
  281. primarysource/zim/_libzim.py +37 -0
  282. primarysource/zim/catalog.py +594 -0
  283. primarysource/zim/cli.py +555 -0
  284. primarysource/zim/collection.py +41 -0
  285. primarysource/zim/errors.py +100 -0
  286. primarysource/zim/groups.py +30 -0
  287. primarysource/zim/library.py +668 -0
  288. primarysource/zim/manage.py +636 -0
  289. primarysource/zim/markdown.py +1247 -0
  290. primarysource/zim/mcp.py +201 -0
  291. primarysource/zim/purl.py +130 -0
  292. primarysource/zim/py.typed +0 -0
  293. primarysource/zim/refs.py +90 -0
  294. primarysource/zim/service.py +1182 -0
  295. primarysource/zim/snapshot.py +197 -0
  296. primarysource/zim/sync.py +269 -0
  297. primarysource/zim/views.py +230 -0
  298. primarysource-0.1.0.dist-info/METADATA +118 -0
  299. primarysource-0.1.0.dist-info/RECORD +303 -0
  300. primarysource-0.1.0.dist-info/WHEEL +4 -0
  301. primarysource-0.1.0.dist-info/entry_points.txt +2 -0
  302. primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
  303. primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
@@ -0,0 +1,490 @@
1
+ """A full-text index over one edition, on the core's index (`primarysource/index.py`), tagged with its source.
2
+
3
+ One SQLite file per edition, built by `primarysource.index.rebuild` (a temporary file `os.replace`d over the old
4
+ one, so a server sees the old index or the new one, never half of one) and opened for serving with
5
+ `primarysource.index.open_read` (`?mode=ro`: nothing is created or migrated, and a read-only volume works).
6
+
7
+ How a clause sits in the core's schema, chosen so itulib's measured BM25 weights carry over unchanged:
8
+
9
+ - every clause is a core document of one section, in reading order. `doc_id` is its zero-padded ordinal, so two
10
+ fragments that carry the same number both survive and `fragments` can say so;
11
+ - the document title is the clause number. It fills the core's `doc_title` column, weighted 2.0 as itulib's
12
+ `number` column was;
13
+ - the section title is the document's own heading, or empty when the clause has none (the label `outline` shows
14
+ is then the first sentence, derived from the body). It is weighted 15.0 as itulib's `head` column was;
15
+ - the section's one unit is the clause body, weighted 1.0. ¶n is still the nth sentence of that body.
16
+
17
+ One more document, `~edition`, has no sections and holds what the index was built from as JSON in its title: the
18
+ collection's own schema, the edition key and the PDF's sha256, size and modification time. The index refuses to
19
+ serve when the PDF no longer matches. That is the difference between "this clause says X in the edition you
20
+ hold" and a stale table that reports the same confident answer after ITU republished something - a gate that
21
+ silently skips, in the shape of a search result. The check is by `stat` first: the PDF is hashed only when its
22
+ size or modification time differs from the recorded ones, and a hash that matches is remembered for the life of
23
+ the process, so serving does not read the whole PDF on every call. The cost, stated: a file rewritten with the
24
+ same size and its old modification time restored is not noticed.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import json
31
+ import pathlib
32
+ import re
33
+ import sqlite3
34
+ from collections.abc import Iterator
35
+ from contextlib import contextmanager
36
+ from dataclasses import dataclass
37
+
38
+ from primarysource import index as core
39
+ from primarysource.errors import LibError
40
+ from primarysource.model import Document, Section, Unit
41
+
42
+ from . import corpus, text
43
+ from .sources import Amendment, Edition
44
+
45
+ SCHEMA = "4" # this collection's layout on the core schema; the core's own version is its PRAGMA user_version
46
+ META_ID = "~edition"
47
+ CANDIDATES = 150
48
+ DEMOTION = 0.6 # score multiplier for clauses that are rarely the answer; flat optimum between 0.4 and 0.8
49
+ _INFORMATIVE = re.compile(r"does not form an integral part", re.IGNORECASE)
50
+
51
+ # BM25 column weights as itulib measured them: the clause number 2.0, the document's own heading 15.0 (a heading
52
+ # match is a much stronger signal than the same word somewhere in a body), the body 1.0.
53
+ NUMBER_WEIGHT, HEADING_WEIGHT, BODY_WEIGHT = 2.0, 15.0, 1.0
54
+ CHARS_PER_TOKEN = 4
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Hit:
59
+ number: str
60
+ title: str
61
+ excerpt: str
62
+ score: float # higher is better: BM25 negated, times the demotion
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Results:
67
+ """One page of search hits. `relaxed`: no clause held every term, so these hold some. `next_start`: the 0-based
68
+ `start` of the next page, or None at the end. No hits means nothing matched."""
69
+
70
+ hits: tuple[Hit, ...]
71
+ relaxed: bool
72
+ next_start: int | None
73
+
74
+
75
+ def _db_path(edition: Edition) -> pathlib.Path:
76
+ return corpus.home() / "index" / f"{edition.key.replace('#', '-')}.sqlite3"
77
+
78
+
79
+ def index_command(edition: Edition) -> str:
80
+ return f"primarysource itu index {edition.key}"
81
+
82
+
83
+ def sync_command(edition: Edition) -> str:
84
+ return f"primarysource itu sync {edition.key}"
85
+
86
+
87
+ def _sha256(path: pathlib.Path) -> str:
88
+ digest = hashlib.sha256()
89
+ with path.open("rb") as handle:
90
+ for block in iter(lambda: handle.read(1 << 20), b""):
91
+ digest.update(block)
92
+ return digest.hexdigest()
93
+
94
+
95
+ def _documents(rows: tuple[text.Clause, ...], meta: dict[str, object]) -> Iterator[Document]:
96
+ yield Document(META_ID, json.dumps(meta, sort_keys=True), Unit.SENTENCE)
97
+ for ordinal, row in enumerate(rows):
98
+ section = Section(
99
+ id=row.number,
100
+ title=row.title if row.heading else "",
101
+ units=(row.body,) if row.body else (),
102
+ depth=row.number.count(".") + 1,
103
+ )
104
+ yield Document(f"{ordinal:05d}", row.number, Unit.SENTENCE, (section,))
105
+
106
+
107
+ def build(edition: Edition) -> int:
108
+ """(Re)build the index for one edition and swap it in atomically. Returns the clause count."""
109
+ pdf = corpus.require(edition)
110
+ stat = pdf.stat()
111
+ digest = _sha256(pdf)
112
+ extracted = text.pages(pdf)
113
+ rows = text.clauses(extracted, text.headings(pdf, extracted))
114
+ if not rows:
115
+ raise LibError(
116
+ f"{edition.key}: extracted 0 clauses. The corpus exists but nothing parsed, which is a failure of the "
117
+ "extractor, not an empty document. Refusing to write an empty index.",
118
+ "",
119
+ f"check that {pdf} opens as a PDF, then run: primarysource itu fetch {edition.key} --refresh",
120
+ )
121
+ meta: dict[str, object] = {
122
+ "schema": SCHEMA,
123
+ "edition": edition.key,
124
+ "pdf_sha256": digest,
125
+ "pdf_octets": stat.st_size,
126
+ "pdf_mtime_ns": stat.st_mtime_ns,
127
+ "clauses": len(rows),
128
+ }
129
+ core.rebuild(_db_path(edition), _documents(rows, meta), rebuild_command=index_command(edition))
130
+ _VERIFIED.pop(pdf, None)
131
+ return len(rows)
132
+
133
+
134
+ # PDF path -> (size, mtime_ns, sha256) that this process hashed and found to match its index.
135
+ _VERIFIED: dict[pathlib.Path, tuple[int, int, str]] = {}
136
+
137
+
138
+ def _check_source(edition: Edition, meta: dict[str, object]) -> None:
139
+ """Refuse when the PDF on disk is not the one the index was built from. Hashes only when `stat` differs."""
140
+ pdf = corpus.pdf_path(edition)
141
+ try:
142
+ stat = pdf.stat()
143
+ except FileNotFoundError:
144
+ raise LibError(
145
+ f"{edition.key}: the PDF this index was built from is not in {corpus.home()}",
146
+ "itu_info shows which editions are fetched",
147
+ sync_command(edition),
148
+ ) from None
149
+ expected = str(meta.get("pdf_sha256", ""))
150
+ if (stat.st_size, stat.st_mtime_ns) == (meta.get("pdf_octets"), meta.get("pdf_mtime_ns")):
151
+ return
152
+ if _VERIFIED.get(pdf) == (stat.st_size, stat.st_mtime_ns, expected):
153
+ return
154
+ if _sha256(pdf) != expected:
155
+ raise LibError(
156
+ f"{edition.key}: the corpus changed since the index was built",
157
+ "itu_info shows the registered digest of each edition",
158
+ index_command(edition),
159
+ )
160
+ _VERIFIED[pdf] = (stat.st_size, stat.st_mtime_ns, expected)
161
+
162
+
163
+ @contextmanager
164
+ def _open(edition: Edition) -> Iterator[sqlite3.Connection]:
165
+ """A read-only connection to an edition's index that matches the PDF on disk, closed on exit."""
166
+ path = _db_path(edition)
167
+ if not path.is_file():
168
+ raise LibError(f"{edition.key}: no index", "itu_info shows which editions are fetched", sync_command(edition))
169
+ try:
170
+ connection = core.open_read(path, rebuild_command=index_command(edition))
171
+ except LibError as error: # the core names the command in `hint`; here it is a human's to run, so `action`
172
+ raise LibError(error.message, "", index_command(edition)) from None
173
+ try:
174
+ row = connection.execute("SELECT title FROM documents WHERE doc_id = ?", (META_ID,)).fetchone()
175
+ meta = json.loads(row[0]) if row else {}
176
+ if not isinstance(meta, dict) or meta.get("schema") != SCHEMA:
177
+ raise LibError(
178
+ f"{edition.key}: the index was built by an older itulib or primarysource", "", index_command(edition)
179
+ )
180
+ _check_source(edition, meta)
181
+ yield connection
182
+ finally:
183
+ connection.close()
184
+
185
+
186
+ def _display(title: str, body: str) -> str:
187
+ """The label of a clause: its own heading, or the first sentence of its body when it has none."""
188
+ return title or text.first_sentence(body)
189
+
190
+
191
+ def search(edition: Edition, query: str, limit: int = 8, start: int = 0) -> Results:
192
+ """The best clauses for a question, best first, `limit` from the 0-based `start`.
193
+
194
+ The core's search (`primarysource.index.search`): every term is required first, and only when no clause holds
195
+ them all will any term do, which the result says with `relaxed`. The terms are the query's words without
196
+ question words and filler (`text.query_words`, measured on itulib's blind retrieval set). BM25 weights the
197
+ heading above the number above the body, and each hit is then adjusted by where the clause sits: definitions
198
+ (clause 3.x) and informative annexes are demoted unless the query asks what a term means, because a
199
+ definition that merely mentions the words is rarely the rule that was asked about.
200
+
201
+ Tried and dropped on the blind set because they did not help: pushing a subtree's best score up to its
202
+ heading-only parent, and a bonus for words found in the heading.
203
+ """
204
+ if start < 0:
205
+ raise LibError(f"start={start} is negative", "start is a 0-based offset; pass the value of next:")
206
+ words = " ".join(text.query_words(query))
207
+ wants_definitions = text.asks_for_definition(query)
208
+ with _open(edition) as connection:
209
+ informative = _informative_annexes(connection)
210
+
211
+ def adjust(hit: core.Hit, _query: str) -> float:
212
+ number, factor = hit.section_id, 1.0
213
+ if number.startswith("3.") and not wants_definitions:
214
+ factor *= DEMOTION
215
+ if number.split(".")[0] in informative:
216
+ factor *= DEMOTION
217
+ return factor
218
+
219
+ ranking = core.Ranking(NUMBER_WEIGHT, HEADING_WEIGHT, BODY_WEIGHT, adjust=adjust, candidates=CANDIDATES)
220
+ found = core.search(connection, words, ranking=ranking, limit=limit, start=start)
221
+ marks = ",".join("?" * len(found.hits))
222
+ bodies: dict[str, str] = dict(
223
+ connection.execute(
224
+ f"SELECT doc_id, body FROM sections WHERE doc_id IN ({marks})", # noqa: S608 - only '?' marks
225
+ [hit.doc_id for hit in found.hits],
226
+ ).fetchall()
227
+ )
228
+ hits = []
229
+ for hit in found.hits:
230
+ title = _display(hit.title, bodies.get(hit.doc_id, ""))
231
+ hits.append(Hit(hit.section_id, title, hit.snippet or title, hit.score))
232
+ return Results(tuple(hits), found.relaxed, found.next_start)
233
+
234
+
235
+ def _informative_annexes(connection: sqlite3.Connection) -> set[str]:
236
+ """Letters of the annexes that say they do not form an integral part of the recommendation."""
237
+ return {
238
+ number
239
+ for number, body in connection.execute("SELECT section_id, body FROM sections WHERE length(section_id) = 1")
240
+ if _INFORMATIVE.search(body)
241
+ }
242
+
243
+
244
+ def _rows(edition: Edition) -> list[tuple[str, str, str]]:
245
+ """(number, heading or "", body) of every clause, in reading order."""
246
+ with _open(edition) as connection:
247
+ return connection.execute("SELECT section_id, title, body FROM sections ORDER BY id").fetchall()
248
+
249
+
250
+ def read(edition: Edition, number: str) -> text.Clause | None:
251
+ with _open(edition) as connection:
252
+ row = connection.execute(
253
+ "SELECT section_id, title, body FROM sections WHERE section_id = ? ORDER BY id LIMIT 1", (number,)
254
+ ).fetchone()
255
+ if row is None:
256
+ return None
257
+ return text.Clause(number=row[0], title=_display(row[1], row[2]), body=row[2], heading=bool(row[1]))
258
+
259
+
260
+ def outline(edition: Edition) -> tuple[tuple[str, str], ...]:
261
+ return tuple((number, _display(title, body)) for number, title, body in _rows(edition))
262
+
263
+
264
+ # --- paging, citation, references ----------------------------------------------------------------------------
265
+
266
+
267
+ @dataclass(frozen=True)
268
+ class Page:
269
+ """A budgeted slice of one clause. `next` is the ¶ the rest starts at, or None at the end."""
270
+
271
+ number: str
272
+ title: str
273
+ first: int
274
+ last: int
275
+ total: int
276
+ text: str
277
+ next: int | None
278
+
279
+
280
+ def citation(edition: Edition | Amendment, number: str, para: int | None = None) -> str:
281
+ """`Rec. ITU-T X.690 (02/2021) §11.6¶2`. Built from the registry, never typed by hand."""
282
+ label = edition.edition if isinstance(edition, Edition) else edition.label
283
+ tail = f"¶{para}" if para else ""
284
+ return f"Rec. ITU-T {edition.rec} ({label}) §{number}{tail}"
285
+
286
+
287
+ def page(edition: Edition, number: str, *, start: int = 1, max_tokens: int = 2000) -> Page | None:
288
+ """Sentences `start`.. of a clause, cut at a sentence boundary within the token budget.
289
+
290
+ At least one sentence is always returned, so a single over-long sentence cannot stall a caller that is
291
+ following `next`. Each sentence is prefixed with its `[¶n]` so any of them is citable.
292
+ """
293
+ clause = read(edition, number)
294
+ if clause is None:
295
+ return None
296
+ if not clause.body:
297
+ return Page(clause.number, clause.title, 1, 0, 0, "", None)
298
+ units = text.sentences(clause.body)
299
+ if not 1 <= start <= max(len(units), 1):
300
+ raise LibError(
301
+ f"§{number} has {len(units)} sentences; ¶{start} is outside 1..{len(units)}",
302
+ f"itu_read('{number}') reads the clause from its first sentence",
303
+ )
304
+ budget = max(max_tokens, 1) * CHARS_PER_TOKEN
305
+ used, last = 0, start - 1
306
+ for n in range(start, len(units) + 1):
307
+ cost = len(units[n - 1]) + 8
308
+ if last >= start and used + cost > budget:
309
+ break
310
+ used += cost
311
+ last = n
312
+ body = " ".join(f"[¶{n}] {units[n - 1]}" for n in range(start, last + 1))
313
+ return Page(clause.number, clause.title, start, last, len(units), body, last + 1 if last < len(units) else None)
314
+
315
+
316
+ def fragments(edition: Edition, number: str) -> int:
317
+ """How many extracted fragments carry this clause number. More than one means the splitter could not tell a
318
+ heading from a numbered sub-item or a table cell, and `read` returns the first. Callers should say so."""
319
+ with _open(edition) as connection:
320
+ row = connection.execute("SELECT count(*) FROM sections WHERE section_id = ?", (number,)).fetchone()
321
+ return int(row[0])
322
+
323
+
324
+ def sentence(edition: Edition, number: str, para: int) -> str | None:
325
+ clause = read(edition, number)
326
+ units = text.sentences(clause.body) if clause else ()
327
+ return units[para - 1] if 1 <= para <= len(units) else None
328
+
329
+
330
+ def normative_statements(edition: Edition, number: str) -> tuple[tuple[int, str], ...] | None:
331
+ clause = read(edition, number)
332
+ return None if clause is None else text.normative(clause.body)
333
+
334
+
335
+ @dataclass(frozen=True)
336
+ class Links:
337
+ number: str
338
+ clauses: tuple[str, ...] # same-document clauses this one points at that actually exist
339
+ unresolved: tuple[str, ...] # pointed at but not found in the index: extraction gap, or a cross-document ref
340
+ recommendations: tuple[str, ...]
341
+ standards: tuple[str, ...]
342
+ cited_by: tuple[str, ...]
343
+
344
+
345
+ def links(edition: Edition, number: str) -> Links | None:
346
+ """Outgoing references of a clause, and the clauses of the same edition that point back at it."""
347
+ rows = [(n, body) for n, _title, body in _rows(edition)]
348
+ known = {row[0] for row in rows}
349
+ body = next((row[1] for row in rows if row[0] == number), None)
350
+ if body is None:
351
+ return None
352
+ found = text.refs(body)
353
+ back = tuple(n for n, b in rows if n != number and _points_at(b, number))
354
+ return Links(
355
+ number=number,
356
+ clauses=tuple(c for c in (*found.clauses, *found.bare) if c in known and c != number),
357
+ unresolved=tuple(c for c in found.clauses if c not in known),
358
+ recommendations=tuple(r for r in found.recommendations if r != edition.rec),
359
+ standards=found.standards,
360
+ cited_by=back,
361
+ )
362
+
363
+
364
+ @dataclass(frozen=True)
365
+ class GrepHit:
366
+ number: str
367
+ excerpt: str
368
+
369
+
370
+ def grep(
371
+ edition: Edition, pattern: str, *, start: int = 0, max_tokens: int = 2000, context: int = 120
372
+ ) -> tuple[tuple[GrepHit, ...], int | None]:
373
+ """Regex over clause bodies in ONE query, with an excerpt around the first match, cut to a token budget.
374
+
375
+ Returns (hits, next start): matching clauses from the 0-based `start`, and the `start` of the rest when the
376
+ budget stopped the listing, or None when it is complete. At least one hit is returned when any remain.
377
+ """
378
+ try:
379
+ compiled = re.compile(pattern, re.IGNORECASE)
380
+ except re.error as error:
381
+ raise LibError(f"{pattern!r} is not a regular expression: {error}", "itu_search takes plain words") from None
382
+ if start < 0:
383
+ raise LibError(f"start={start} is negative", "start is a 0-based offset; pass the value of next:")
384
+ budget, used, seen = max(max_tokens, 1) * CHARS_PER_TOKEN, 0, 0
385
+ hits: list[GrepHit] = []
386
+ for number, _title, body in _rows(edition):
387
+ match = compiled.search(body)
388
+ if not match:
389
+ continue
390
+ seen += 1
391
+ if seen <= start:
392
+ continue
393
+ excerpt = body[max(match.start() - context, 0) : match.end() + context]
394
+ cost = len(excerpt) + len(number) + 20
395
+ if hits and used + cost > budget:
396
+ return tuple(hits), start + len(hits)
397
+ used += cost
398
+ hits.append(GrepHit(number, excerpt))
399
+ return tuple(hits), None
400
+
401
+
402
+ def _points_at(body: str, number: str) -> bool:
403
+ found = text.refs(body)
404
+ return number in found.clauses or number in found.bare
405
+
406
+
407
+ def slice_rows(
408
+ rows: tuple[tuple[str, str], ...], start: int, max_tokens: int
409
+ ) -> tuple[tuple[tuple[str, str], ...], int | None]:
410
+ """(rows[start:...], next start or None), cut to a token budget. Always returns at least one row."""
411
+ if not 0 <= start <= len(rows):
412
+ raise LibError(
413
+ f"start={start} is outside 0..{len(rows)}", "start is a 0-based offset; pass the value of next:"
414
+ )
415
+ budget, used, end = max(max_tokens, 1) * CHARS_PER_TOKEN, 0, start
416
+ while end < len(rows):
417
+ cost = len(rows[end][0]) + len(rows[end][1]) + 12
418
+ if end > start and used + cost > budget:
419
+ break
420
+ used += cost
421
+ end += 1
422
+ return rows[start:end], (end if end < len(rows) else None)
423
+
424
+
425
+ @dataclass(frozen=True)
426
+ class Comparison:
427
+ number: str
428
+ in_a: bool
429
+ in_b: bool
430
+ same: bool
431
+ removed: tuple[tuple[int, str], ...] # (¶ in edition a, sentence) present only in a
432
+ added: tuple[tuple[int, str], ...] # (¶ in edition b, sentence) present only in b
433
+
434
+
435
+ def _sentence_key(sentence: str) -> str:
436
+ """What two sentences must share to count as the same across editions.
437
+
438
+ The extractor splits words differently in different editions ("a ppear"), drops mathematical symbols from some
439
+ ("M ≠ M'" loses its sign) and the editions cite each other in a different order ("ITU-T Rec. X.680" became
440
+ "Rec. ITU-T X.680"). So matching ignores case, whitespace, punctuation and symbols. The cost is stated in the
441
+ docstring of `compare`: a change that consists only of a symbol or punctuation mark is invisible.
442
+ """
443
+ return re.sub(r"[\W_]+", "", sentence.replace("ITU-T Rec.", "Rec. ITU-T")).casefold()
444
+
445
+
446
+ def compare(a: Edition, b: Edition, number: str) -> Comparison:
447
+ """What changed in clause `number` between two editions, by sentence.
448
+
449
+ Numbering can differ between editions, so a clause that exists in only one is reported as such rather than
450
+ as a deletion: the rule may simply have moved. Matching is by sentence, so a reworded sentence appears as one
451
+ removed and one added. Matching ignores punctuation and symbols (see `_sentence_key`), so a change consisting
452
+ only of a symbol, such as a sign or a comparison operator, is NOT reported: check the PDF when that matters.
453
+ """
454
+ import difflib
455
+
456
+ clause_a, clause_b = read(a, number), read(b, number)
457
+ sa = text.sentences(clause_a.body) if clause_a and clause_a.body else ()
458
+ sb = text.sentences(clause_b.body) if clause_b and clause_b.body else ()
459
+ keys_a, keys_b = [_sentence_key(s) for s in sa], [_sentence_key(s) for s in sb]
460
+ matcher = difflib.SequenceMatcher(a=keys_a, b=keys_b, autojunk=False)
461
+ removed: list[tuple[int, str]] = []
462
+ added: list[tuple[int, str]] = []
463
+ for op, a0, a1, b0, b1 in matcher.get_opcodes():
464
+ if op in {"delete", "replace"}:
465
+ removed += [(n + 1, sa[n]) for n in range(a0, a1)]
466
+ if op in {"insert", "replace"}:
467
+ added += [(n + 1, sb[n]) for n in range(b0, b1)]
468
+ return Comparison(
469
+ number,
470
+ clause_a is not None,
471
+ clause_b is not None,
472
+ clause_a is not None and clause_b is not None and not removed and not added,
473
+ tuple(removed),
474
+ tuple(added),
475
+ )
476
+
477
+
478
+ def children(edition: Edition, number: str) -> tuple[tuple[str, str], ...]:
479
+ """The nearest descendants of a clause that has no body of its own. Heading-only clauses ("8.1.2 Identifier
480
+ octets") are not indexed, so `read` would otherwise say "no clause" about a clause that plainly exists."""
481
+ below = [(n, t) for n, t in outline(edition) if n.startswith(number + ".")]
482
+ if not below:
483
+ return ()
484
+ shallowest = min(n.count(".") for n, _ in below)
485
+ return tuple(row for row in below if row[0].count(".") == shallowest)
486
+
487
+
488
+ def top_level(edition: Edition, depth: int = 2) -> tuple[tuple[str, str], ...]:
489
+ """Clauses whose number has at most `depth` dotted components: the shape of the document, not its detail."""
490
+ return tuple((n, t) for n, t in outline(edition) if n.count(".") + 1 <= depth)