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,305 @@
1
+ """Hybrid retrieval: RRF fusion of BM25 + KNN + doc-title matches, status-aware.
2
+
3
+ RRF (score = sum of 1/(k+rank) per list, k=60) is rank-based, so it needs
4
+ no score calibration between bm25 and cosine distance. On top, a small
5
+ multiplicative prior favors standards-track and BCP/STD documents — for
6
+ normative questions an Internet Standard outranks an Experimental memo
7
+ that happens to mention the same words.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ import re
14
+ import sqlite3
15
+
16
+ # k=25 (not the classic 60): our lists are shallow (depth ~30) and we want
17
+ # a #2 BM25 hit to actually move its document, not drown in flat 1/(60+r).
18
+ RRF_K = 25
19
+
20
+ # multiplicative prior on current_status (from the rfc-index manifest)
21
+ _STATUS_PRIOR = (
22
+ ("INTERNET STANDARD", 1.25),
23
+ ("BEST CURRENT PRACTICE", 1.20),
24
+ ("DRAFT STANDARD", 1.15),
25
+ ("PROPOSED STANDARD", 1.10),
26
+ ("HISTORIC", 0.80),
27
+ )
28
+
29
+ # weight of the document title/abstract match in the RRF sum, proportional
30
+ # to IDF-weighted term COVERAGE: "Key words for use in RFCs..." (coverage
31
+ # 2.3, RFC2119's literal title) must lift its doc ~2.3x as hard as a doc
32
+ # whose title carries one weak term. Capped so one title can't outrank a
33
+ # doc backed by two strong lists unconditionally.
34
+ TITLE_BOOST_BASE, TITLE_BOOST_COV, TITLE_BOOST_CAP = 0.15, 0.7, 1.4
35
+
36
+ # weight of the exact-identifier list (lexical.ident_search) relative to the
37
+ # BM25 and KNN lists (1.0 each); only present when the query has identifiers
38
+ # (0.5: at 1.0 a frequent identifier such as base64url pushed the defining
39
+ # RFC out of the top 5; at 0.5 identifier MRR rises 0.556 -> 0.631 with no
40
+ # top-5 losses on scripts/eval_retrieval.py)
41
+ IDENT_WEIGHT = 0.5
42
+
43
+
44
+ def status_prior(current_status: str | None, is_current: bool = True) -> float:
45
+ st = (current_status or "").upper()
46
+ for needle, w in _STATUS_PRIOR:
47
+ if needle in st:
48
+ return w if is_current else min(w, 0.8)
49
+ return 1.0
50
+
51
+
52
+ def _has(text: str, word: str, alts: dict[str, str]) -> bool:
53
+ """word occurs in text, or word is a query acronym whose expansion does."""
54
+ if re.search(rf"\b{re.escape(word)}\b", text):
55
+ return True
56
+ exp = alts.get(word)
57
+ return bool(exp) and re.search(rf"\b{re.escape(exp)}\b", text) is not None
58
+
59
+
60
+ def _doc_title_list(conn: sqlite3.Connection, query: str,
61
+ include_obsolete: bool,
62
+ alts: dict[str, str] | None = None) -> list[dict]:
63
+ """Document-level (title/abstract) matches ranked by TERM COVERAGE.
64
+
65
+ Raw bm25 over an OR of terms lets one rare word ("NewReno", "QUIC")
66
+ win the list; what identifies the canonical spec is the FRACTION of
67
+ query terms the document's own title/abstract carries ("TCP
68
+ Congestion Control" ⊃ "congestion control TCP Reno NewReno"). So:
69
+ bm25 pulls a wide candidate pool, then IDF-weighted coverage reorders
70
+ it. AND conjuncts would drop docs missing one incidental word.
71
+
72
+ alts maps a query acronym to its expansion: "ntp" is covered by a title
73
+ that says "Network Time Protocol" (RFC 5905 never says "NTP").
74
+ """
75
+ alts = alts or {}
76
+ words, seen = [], set()
77
+ # keep decimal version tokens ("1.3", "2.0", "802.11"): they are often
78
+ # THE discriminator — "TLS 1.3 handshake" must beat generic TLS docs.
79
+ for t in re.findall(r"[A-Za-z0-9]+(?:\.[0-9]+)*", query):
80
+ if (re.search(r"[A-Za-z]", t) or "." in t) and t.lower() not in seen:
81
+ seen.add(t.lower())
82
+ words.append(t.lower())
83
+ if not words:
84
+ return []
85
+ sql = ("SELECT d.doc_id, bm25(docs_fts) AS rank FROM docs_fts "
86
+ "JOIN docs d ON d.doc_id = docs_fts.doc_id "
87
+ "WHERE docs_fts MATCH ?")
88
+ params: list = [" OR ".join([f'"{w}"' for w in words]
89
+ + [f'"{e}"' for e in alts.values()])]
90
+ if not include_obsolete:
91
+ sql += " AND d.is_current = 1"
92
+ sql += " ORDER BY rank LIMIT 300"
93
+ try:
94
+ cand = [dict(r) for r in conn.execute(sql, params).fetchall()]
95
+ except sqlite3.OperationalError:
96
+ return []
97
+ # long specs have diluted bm25 and can miss a 300-row pool, yet their
98
+ # TITLE may be the exact answer — sweep titles directly (docs is ~10k
99
+ # rows, LIKE is negligible) and merge them into the candidate set.
100
+ have = {c["doc_id"] for c in cand}
101
+ for w in words + list(alts.values()):
102
+ wsql = ("SELECT doc_id, 1000 AS rank FROM docs "
103
+ "WHERE lower(title) LIKE ?")
104
+ if not include_obsolete:
105
+ wsql += " AND is_current = 1"
106
+ for r in conn.execute(wsql, (f"%{w}%",)).fetchall():
107
+ if r[0] not in have:
108
+ have.add(r[0])
109
+ cand.append({"doc_id": r[0], "rank": r[1]})
110
+
111
+ total = max(conn.execute("SELECT COUNT(*) FROM docs_fts").fetchone()[0],
112
+ 1)
113
+ weights = {}
114
+ for w in words:
115
+ try:
116
+ df = conn.execute(
117
+ "SELECT COUNT(*) FROM docs_fts WHERE docs_fts MATCH ?",
118
+ (f'"{w}"',)).fetchone()[0]
119
+ except sqlite3.OperationalError:
120
+ df = 1
121
+ weights[w] = max(0.0, math.log(total / max(df, 1)))
122
+ wsum = sum(weights.values()) or 1.0
123
+
124
+ scored = []
125
+ for c in cand:
126
+ row = conn.execute(
127
+ "SELECT lower(COALESCE(title,'')) t, lower(COALESCE(title,'') "
128
+ "|| ' ' || COALESCE(abstract,'') || ' ' || "
129
+ "COALESCE(keywords,'')) a FROM docs WHERE doc_id=?",
130
+ (c["doc_id"],)).fetchone()
131
+ title = row["t"] if row else ""
132
+ full = row["a"] if row else ""
133
+ hit_t = sum(weights[w] for w in words if _has(title, w, alts)) / wsum
134
+ hit_a = sum(weights[w] for w in words if _has(full, w, alts)) / wsum
135
+ # THE TITLE IS THE DOCUMENT'S IDENTITY: a spec whose title carries
136
+ # the query terms outscores one whose long abstract merely
137
+ # mentions them (surveys lose to RFC9000 for "QUIC transport").
138
+ scored.append((2.0 * hit_t + 0.3 * hit_a, c))
139
+ scored.sort(key=lambda x: (-x[0], x[1]["rank"]))
140
+ out = []
141
+ for cov, c in scored[:25]:
142
+ if cov <= 0.15:
143
+ break
144
+ c["coverage"] = round(cov, 3)
145
+ out.append(c)
146
+ return out
147
+
148
+
149
+ def hybrid_search(conn: sqlite3.Connection, query: str, limit: int = 10,
150
+ include_obsolete: bool = False,
151
+ depth: int = 0) -> list[dict]:
152
+ """Fuse section BM25, embedding KNN and doc-title lists via weighted RRF."""
153
+ from . import embed as ix_embed
154
+ from . import lexical
155
+ from . import search as ix_search
156
+
157
+ depth = depth or max(5 * limit, 100)
158
+ alts = lexical.expansions(conn, query) # {'ntp': 'network ...'}
159
+ # acronym expansions stay out of the BM25 query: "border gateway
160
+ # protocol" pulls in every BGP document; they only count toward title
161
+ # coverage, where a title spells the acronym out (RFC 5905's)
162
+ bm25 = ix_search.search_chunks(conn, query, limit=depth,
163
+ include_obsolete=include_obsolete,
164
+ hybrid_bm25=True)
165
+ try:
166
+ # (acronym expansions are NOT appended here: measured on the golden
167
+ # set, the gains came from wrong expansions and were offset by
168
+ # losses on TLS, TCP, PKCS and BGP queries)
169
+ knn = ix_embed.semantic_search(conn, query, limit=depth,
170
+ include_obsolete=include_obsolete)
171
+ except RuntimeError:
172
+ knn = [] # vectors not built yet: degrade to pure BM25
173
+ # exact identifier matches (preferred_address, X25519, Content-Length):
174
+ # its own list, so the section that contains THE identifier is not
175
+ # outvoted by sections that merely share its words
176
+ ident = lexical.ident_search(conn, query, depth, include_obsolete)
177
+ ident_w = (IDENT_WEIGHT * lexical.ident_weight(conn, query) if ident
178
+ else 0.0)
179
+
180
+ doc_hits = _doc_title_list(conn, query, include_obsolete, alts)
181
+ doc_rank = {h["doc_id"]: r for r, h in enumerate(doc_hits)}
182
+
183
+ # diversify each input list BEFORE fusing: three chunks of one hot RFC
184
+ # should not occupy ranks 1-3 and push the canonical spec out of reach
185
+ def _once_per_doc(lst: list[dict]) -> list[dict]:
186
+ seen, out = set(), []
187
+ for h in lst:
188
+ if h["doc_id"] not in seen:
189
+ seen.add(h["doc_id"])
190
+ out.append(h)
191
+ return out
192
+
193
+ bm25 = _once_per_doc(bm25)
194
+ knn = _once_per_doc(knn)
195
+ ident = _once_per_doc(ident)
196
+
197
+ fused: dict[int, dict] = {}
198
+ for rank, h in enumerate(bm25):
199
+ e = fused.setdefault(h["chunk_id"], {
200
+ "chunk_id": h["chunk_id"], "doc_id": h["doc_id"],
201
+ "sec_num": h["sec_num"], "sec_title": h["sec_title"],
202
+ "start_line": h["start_line"], "end_line": h["end_line"],
203
+ "title": h["title"], "is_current": h["is_current"],
204
+ "current_status": h.get("current_status"),
205
+ "snippet": h["snippet"], "rrf": 0.0, "bm25_rank": None,
206
+ "knn_rank": None})
207
+ e["rrf"] += 1.0 / (RRF_K + rank + 1)
208
+ e["bm25_rank"] = rank + 1
209
+ for rank, h in enumerate(knn):
210
+ e = fused.get(h["chunk_id"])
211
+ if e is None:
212
+ e = fused[h["chunk_id"]] = {
213
+ "chunk_id": h["chunk_id"], "doc_id": h["doc_id"],
214
+ "sec_num": h["sec_num"], "sec_title": h["sec_title"],
215
+ "start_line": h["start_line"], "end_line": h["end_line"],
216
+ "title": h["title"], "is_current": h["is_current"],
217
+ "current_status": h.get("current_status"),
218
+ "snippet": h["preview"][:240], "rrf": 0.0,
219
+ "bm25_rank": None, "knn_rank": None}
220
+ e["rrf"] += 1.0 / (RRF_K + rank + 1)
221
+ e["knn_rank"] = rank + 1
222
+ for rank, h in enumerate(ident):
223
+ e = fused.get(h["chunk_id"])
224
+ if e is None:
225
+ e = fused[h["chunk_id"]] = {
226
+ "chunk_id": h["chunk_id"], "doc_id": h["doc_id"],
227
+ "sec_num": h["sec_num"], "sec_title": h["sec_title"],
228
+ "start_line": h["start_line"], "end_line": h["end_line"],
229
+ "title": h["title"], "is_current": h["is_current"],
230
+ "current_status": h.get("current_status"),
231
+ "snippet": h["preview"][:240], "rrf": 0.0,
232
+ "bm25_rank": None, "knn_rank": None}
233
+ e["rrf"] += ident_w / (RRF_K + rank + 1)
234
+ e["ident_rank"] = rank + 1
235
+
236
+ # a title-matching doc with no chunk in either section list is exactly
237
+ # the "find me the TLS 1.3 spec" case: surface its Abstract section as
238
+ # a rank-`dr` entry of the title list (the final loop adds the boost)
239
+ for dr, dh in enumerate(doc_hits[:5]):
240
+ if any(e["doc_id"] == dh["doc_id"] for e in fused.values()):
241
+ continue
242
+ rep = conn.execute(
243
+ """SELECT c.id AS chunk_id, c.doc_id, c.sec_num, c.sec_title,
244
+ c.start_line, c.end_line, d.title, d.is_current,
245
+ d.current_status,
246
+ substr(replace(c.body, char(10), ' '), 1, 240) AS preview
247
+ FROM chunks c JOIN docs d ON d.doc_id = c.doc_id
248
+ WHERE c.doc_id = ?
249
+ ORDER BY (c.sec_title = 'Abstract') DESC, c.start_line
250
+ LIMIT 1""", (dh["doc_id"],)).fetchone()
251
+ if rep is None:
252
+ continue
253
+ fused[rep["chunk_id"]] = {
254
+ "chunk_id": rep["chunk_id"], "doc_id": rep["doc_id"],
255
+ "sec_num": rep["sec_num"], "sec_title": rep["sec_title"],
256
+ "start_line": rep["start_line"], "end_line": rep["end_line"],
257
+ "title": rep["title"], "is_current": rep["is_current"],
258
+ "current_status": rep["current_status"],
259
+ "snippet": rep["preview"], "rrf": 1.0 / (RRF_K + dr + 1),
260
+ "bm25_rank": None, "knn_rank": None}
261
+
262
+ # doc-level corroboration: fusion is chunk-keyed, so BM25 hitting §6
263
+ # and KNN hitting §3 of the SAME doc never sum (RFC5905 for "NTP era
264
+ # rollover" died exactly this way). The doc's best chunk absorbs a
265
+ # share of its siblings' rrf before the title boost is applied.
266
+ best_by_doc: dict[str, dict] = {}
267
+ for e in fused.values():
268
+ cur = best_by_doc.get(e["doc_id"])
269
+ if cur is None or e["rrf"] > cur["rrf"]:
270
+ best_by_doc[e["doc_id"]] = e
271
+ for e in fused.values():
272
+ b = best_by_doc[e["doc_id"]]
273
+ if b is not e:
274
+ b["rrf"] += 0.5 * e["rrf"]
275
+
276
+ for e in fused.values():
277
+ p = status_prior(e["current_status"], bool(e["is_current"]))
278
+ if ix_search.NOISE_TITLE_RE.search(e["sec_title"] or ""):
279
+ p *= 0.55
280
+ e["rrf"] *= p
281
+ dr = doc_rank.get(e["doc_id"])
282
+ if dr is not None and dr < 15: # chunk of a doc whose title matched
283
+ cov = min(doc_hits[dr]["coverage"], TITLE_BOOST_CAP)
284
+ e["rrf"] += (TITLE_BOOST_BASE + TITLE_BOOST_COV * cov) \
285
+ / (RRF_K + dr + 1)
286
+ e["sources"] = "+".join(
287
+ n for n, hit in (("bm25", e["bm25_rank"]), ("knn", e["knn_rank"]),
288
+ ("ident", e.get("ident_rank")), ("title", dr))
289
+ if hit is not None)
290
+ e["rrf"] = round(e["rrf"], 5)
291
+ # diversify: the best chunk of each document first, then overflow —
292
+ # ten hits on six chunks of one RFC teach the agent nothing new
293
+ ordered = sorted(fused.values(), key=lambda e: -e["rrf"])
294
+ seen: set[str] = set()
295
+ ranked, overflow = [], []
296
+ for e in ordered:
297
+ if e["doc_id"] in seen:
298
+ overflow.append(e)
299
+ else:
300
+ seen.add(e["doc_id"])
301
+ ranked.append(e)
302
+ ranked = (ranked + overflow)[:limit]
303
+ for e in ranked:
304
+ e.pop("preview", None)
305
+ return ranked
@@ -0,0 +1,223 @@
1
+ """Lexical helpers for searching protocol text: identifiers and acronyms.
2
+
3
+ The section index (chunks_fts) uses `porter unicode61`, which is right for
4
+ prose but splits the tokens RFC readers search for most precisely:
5
+ "preferred_address" becomes "preferr" + "address", "Sec-WebSocket-Accept"
6
+ three words, "X25519" stays whole but "KeyUpdate" loses its identity once
7
+ stemmed. Two small additions keep the main index and repair the rest:
8
+
9
+ ident_fts one row per section holding only its identifier-shaped tokens
10
+ (snake_case, SCREAMING_CASE, hyphenated names, CamelCase, mixed
11
+ letters+digits), tokenized with '_' and '-' kept inside tokens.
12
+ A query carrying such tokens gets an extra ranked list in hybrid
13
+ fusion: sections that contain THE identifier, not its words.
14
+
15
+ acronyms "Network Time Protocol (NTP)" pairs mined from the corpus, kept
16
+ only when the acronym's letters are the initials of the words.
17
+ A query acronym is then also covered by a title that spells it
18
+ out (RFC 5905's title never says "NTP").
19
+
20
+ Query stop words were tried and measured worse (topic MRR 0.694 -> 0.653
21
+ on scripts/eval_retrieval.py): BM25's IDF already discounts them, and
22
+ dropping them broke title matches like RFC 2119's "Key words for use in
23
+ RFCs ...". So there are none.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ import sqlite3
30
+ from collections import Counter, defaultdict
31
+
32
+ _IDENT = re.compile(r"""
33
+ [A-Za-z0-9]+(?:_[A-Za-z0-9]+)+ # snake_case, SCREAMING_SNAKE
34
+ | [A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+ # Content-Length, SYN-RECEIVED
35
+ | [A-Za-z]+[0-9][A-Za-z0-9]* # X25519, SHA256, UUIDv7, IPv6
36
+ | [A-Z][a-z]+[A-Z][A-Za-z0-9]* # KeyUpdate, NewReno, WebSocket
37
+ | [a-z]+[A-Z][A-Za-z0-9]* # camelCase
38
+ """, re.X)
39
+ _NOT_IDENT = re.compile(r"^(rfc|bcp|std|fyi)\d+$|^[a-z]+-$", re.I)
40
+
41
+
42
+ def identifiers(text: str) -> list[str]:
43
+ """Identifier-shaped tokens of text, lowercased, in order (with repeats:
44
+ term frequency still means something). RFC numbers are left out: they
45
+ are citations, served by the citation graph."""
46
+ out = []
47
+ for m in _IDENT.finditer(text):
48
+ t = m.group(0).strip("-_").lower()
49
+ if len(t) >= 3 and not _NOT_IDENT.match(t):
50
+ out.append(t)
51
+ return out
52
+
53
+
54
+ # --------------------------------------------------------------------------
55
+ # identifier index
56
+
57
+ def index_identifiers(conn: sqlite3.Connection, chunk_ids: list[int],
58
+ drop_ids: list[int] = ()) -> None:
59
+ """(Re)write ident_fts rows for these chunks; drop rows of chunks that
60
+ no longer exist."""
61
+ if drop_ids:
62
+ conn.executemany("DELETE FROM ident_fts WHERE rowid=?",
63
+ [(i,) for i in drop_ids])
64
+ for cid in chunk_ids:
65
+ row = conn.execute("SELECT body FROM chunks WHERE id=?",
66
+ (cid,)).fetchone()
67
+ conn.execute("DELETE FROM ident_fts WHERE rowid=?", (cid,))
68
+ terms = identifiers(row[0]) if row else []
69
+ if terms:
70
+ conn.execute("INSERT INTO ident_fts(rowid, terms) VALUES(?,?)",
71
+ (cid, " ".join(terms)))
72
+
73
+
74
+ def backfill_identifiers(conn: sqlite3.Connection, progress=None) -> int:
75
+ """Index sections that have no ident_fts row yet (after an upgrade)."""
76
+ todo = [r[0] for r in conn.execute(
77
+ "SELECT id FROM chunks WHERE id NOT IN (SELECT rowid FROM ident_fts)"
78
+ " AND id NOT IN (SELECT chunk_id FROM ident_none)")]
79
+ for i in range(0, len(todo), 2000):
80
+ part = todo[i:i + 2000]
81
+ for cid, body in conn.execute(
82
+ "SELECT id, body FROM chunks WHERE id IN (%s)"
83
+ % ",".join("?" * len(part)), part).fetchall():
84
+ terms = identifiers(body)
85
+ if terms:
86
+ conn.execute("INSERT INTO ident_fts(rowid, terms) "
87
+ "VALUES(?,?)", (cid, " ".join(terms)))
88
+ else: # remember "nothing to index" so backfill stays cheap
89
+ conn.execute("INSERT OR IGNORE INTO ident_none(chunk_id) "
90
+ "VALUES(?)", (cid,))
91
+ conn.commit()
92
+ if progress:
93
+ progress(min(i + 2000, len(todo)), len(todo))
94
+ return len(todo)
95
+
96
+
97
+ def ident_weight(conn: sqlite3.Connection, query: str) -> float:
98
+ """How much an exact-identifier match should count, 0..1: by the
99
+ rarest identifier's document frequency. 'preferred_address' (1 RFC)
100
+ pins its section; 'base64url' (78 RFCs) or 'Content-Length' (278) says
101
+ little about which RFC is meant, and must not outvote the other lists."""
102
+ import math
103
+ terms = list(dict.fromkeys(identifiers(query)))
104
+ if not terms:
105
+ return 0.0
106
+ total = max(conn.execute("SELECT COUNT(*) FROM chunk_src").fetchone()[0],
107
+ 2)
108
+ best = 0.0
109
+ for t in terms:
110
+ try:
111
+ df = conn.execute(
112
+ "SELECT COUNT(DISTINCT c.doc_id) FROM ident_fts JOIN chunks c "
113
+ "ON c.id = ident_fts.rowid WHERE ident_fts MATCH ?",
114
+ (f'"{t}"',)).fetchone()[0]
115
+ except sqlite3.OperationalError:
116
+ continue
117
+ if df:
118
+ best = max(best, math.log(total / df) / math.log(total))
119
+ return best ** 2 # sharpen: rare -> ~1, common -> small
120
+
121
+
122
+ def ident_search(conn: sqlite3.Connection, query: str, limit: int,
123
+ include_obsolete: bool = False) -> list[dict]:
124
+ """Sections ranked by BM25 over the query's identifier tokens only;
125
+ [] when the query has none."""
126
+ terms = list(dict.fromkeys(identifiers(query)))
127
+ if not terms:
128
+ return []
129
+ sql = """
130
+ SELECT c.id AS chunk_id, c.doc_id, c.sec_num, c.sec_title,
131
+ c.start_line, c.end_line, d.title, d.is_current,
132
+ d.current_status,
133
+ substr(replace(c.body, char(10), ' '), 1, 240) AS preview
134
+ FROM ident_fts JOIN chunks c ON c.id = ident_fts.rowid
135
+ JOIN docs d ON d.doc_id = c.doc_id
136
+ WHERE ident_fts MATCH ?"""
137
+ if not include_obsolete:
138
+ sql += " AND d.is_current = 1"
139
+ sql += " ORDER BY bm25(ident_fts) LIMIT ?"
140
+ match = " OR ".join(f'"{t}"' for t in terms)
141
+ try:
142
+ return [dict(r) for r in conn.execute(sql, (match, limit))]
143
+ except sqlite3.OperationalError:
144
+ return []
145
+
146
+
147
+ # --------------------------------------------------------------------------
148
+ # acronyms
149
+
150
+ _EXPANSION = re.compile(
151
+ r"((?:[A-Za-z][\w/]*[\s-]+){1,8}?[A-Za-z][\w/]*)\s+\(([A-Z][A-Za-z0-9]{1,9})s?\)")
152
+ _JOIN = {"of", "for", "and", "the", "to", "over", "in", "on", "a", "an", "with"}
153
+
154
+
155
+ def _match_initials(acro: str, words: list[str]) -> list[str] | None:
156
+ """The shortest suffix of words whose initials spell acro's letters
157
+ (join words may be skipped, a word may contribute 2 letters as in
158
+ 'IPsec' / 'Ethernet' cases are left out). None if no suffix fits."""
159
+ letters = [ch.lower() for ch in acro if ch.isalpha()]
160
+ if len(letters) < 2:
161
+ return None
162
+ for start in range(len(words) - 1, -1, -1):
163
+ cand = words[start:]
164
+ initials = [w[0].lower() for w in cand
165
+ if w.lower() not in _JOIN or w[0].isupper()]
166
+ if initials == letters:
167
+ if cand[0].lower() in _JOIN:
168
+ return None
169
+ return cand
170
+ if len(initials) > len(letters):
171
+ break
172
+ return None
173
+
174
+
175
+ def mine_acronyms(conn: sqlite3.Connection) -> int:
176
+ """Rebuild the acronyms table from titles, abstracts, keywords and the
177
+ opening sections of every indexed document (where terms are defined)."""
178
+ counts: dict[str, Counter] = defaultdict(Counter)
179
+
180
+ def scan(text: str) -> None:
181
+ for m in _EXPANSION.finditer(text or ""):
182
+ acro = m.group(2)
183
+ words = re.split(r"[\s-]+", m.group(1).strip())
184
+ hit = _match_initials(acro, words)
185
+ if hit:
186
+ counts[acro.upper()][" ".join(w.lower() for w in hit)] += 1
187
+
188
+ for r in conn.execute("SELECT title, abstract, keywords FROM docs"):
189
+ scan(" ".join(x or "" for x in r))
190
+ for (body,) in conn.execute(
191
+ "SELECT body FROM chunks WHERE sec_title IN ('Abstract', "
192
+ "'Introduction', 'Terminology', 'Overview') OR sec_num IN "
193
+ "('1', '1.1', '2')"):
194
+ scan(body.replace("\n", " "))
195
+ conn.execute("DELETE FROM acronyms")
196
+ rows = []
197
+ for acro, exp in counts.items():
198
+ expansion, n = exp.most_common(1)[0]
199
+ # one sighting is noise ('QUIC' is not an acronym); series names
200
+ # are document ids, not terms to expand
201
+ if n >= 2 and acro not in ("RFC", "RFCS", "BCP", "STD", "FYI"):
202
+ rows.append((acro, expansion, n))
203
+ conn.executemany("INSERT INTO acronyms(acro, expansion, n) "
204
+ "VALUES(?,?,?)", rows)
205
+ conn.commit()
206
+ return len(rows)
207
+
208
+
209
+ def expansions(conn: sqlite3.Connection, query: str) -> dict[str, str]:
210
+ """{query acronym (lowercase): expansion} for acronyms the query uses
211
+ in capitals ('NTP', 'DCCP'); an all-lowercase query word is left
212
+ alone so ordinary words never get expanded."""
213
+ out = {}
214
+ for w in re.findall(r"\b[A-Z][A-Z0-9]{1,9}s?\b", query):
215
+ key = w.rstrip("s") if w.endswith("s") and w[:-1].isupper() else w
216
+ try:
217
+ row = conn.execute("SELECT expansion FROM acronyms WHERE acro=?",
218
+ (key,)).fetchone()
219
+ except sqlite3.OperationalError: # table not built yet
220
+ return {}
221
+ if row:
222
+ out[key.lower()] = row[0]
223
+ return out