moca-reader 0.6.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 (55) hide show
  1. moca_reader/__init__.py +84 -0
  2. moca_reader/_js.py +291 -0
  3. moca_reader/attestations.py +271 -0
  4. moca_reader/audience.py +49 -0
  5. moca_reader/backends/__init__.py +1 -0
  6. moca_reader/backends/bm25.py +118 -0
  7. moca_reader/backends/dense.py +146 -0
  8. moca_reader/backends/hybrid.py +73 -0
  9. moca_reader/backends/lexical.py +112 -0
  10. moca_reader/backends/memory_store.py +122 -0
  11. moca_reader/citation.py +213 -0
  12. moca_reader/codes.py +110 -0
  13. moca_reader/conformance.py +249 -0
  14. moca_reader/content.py +312 -0
  15. moca_reader/diagnostics.py +45 -0
  16. moca_reader/digest.py +92 -0
  17. moca_reader/dsse.py +97 -0
  18. moca_reader/ed25519.py +83 -0
  19. moca_reader/embedders.py +224 -0
  20. moca_reader/evidence_text.py +187 -0
  21. moca_reader/frontmatter.py +151 -0
  22. moca_reader/library.py +176 -0
  23. moca_reader/manifest.py +186 -0
  24. moca_reader/metadata.py +58 -0
  25. moca_reader/profiles.py +5 -0
  26. moca_reader/py.typed +0 -0
  27. moca_reader/reader.py +468 -0
  28. moca_reader/relations.py +46 -0
  29. moca_reader/resolver.py +33 -0
  30. moca_reader/reviews.py +18 -0
  31. moca_reader/schemas/ag-ui-citations.schema.json +36 -0
  32. moca_reader/schemas/citation-record.schema.json +326 -0
  33. moca_reader/schemas/manifest.schema.json +171 -0
  34. moca_reader/schemas/node.schema.json +372 -0
  35. moca_reader/schemas/review-predicate.schema.json +42 -0
  36. moca_reader/schemas/search-hit.schema.json +62 -0
  37. moca_reader/schemas/sidecar-index.schema.json +155 -0
  38. moca_reader/schemas/structure.schema.json +93 -0
  39. moca_reader/schemas/trust-root.schema.json +130 -0
  40. moca_reader/schemas.py +44 -0
  41. moca_reader/search.py +346 -0
  42. moca_reader/sidecar.py +228 -0
  43. moca_reader/source.py +285 -0
  44. moca_reader/state.py +119 -0
  45. moca_reader/strategies/__init__.py +1 -0
  46. moca_reader/strategies/diversify.py +28 -0
  47. moca_reader/strategies/ontology_guided.py +63 -0
  48. moca_reader/structure.py +385 -0
  49. moca_reader/structure_ops.py +160 -0
  50. moca_reader/unicode.py +33 -0
  51. moca_reader-0.6.0.dist-info/METADATA +209 -0
  52. moca_reader-0.6.0.dist-info/RECORD +55 -0
  53. moca_reader-0.6.0.dist-info/WHEEL +4 -0
  54. moca_reader-0.6.0.dist-info/licenses/LICENSE +201 -0
  55. moca_reader-0.6.0.dist-info/licenses/NOTICE +15 -0
@@ -0,0 +1,49 @@
1
+ """Audience (package spec §5.3, Reader contract §8, moca-spec ADR-0016).
2
+
3
+ - A node's label is its ``moca.audience``, a non-empty string.
4
+ - A translation with no ``moca.audience`` of its own takes the label of the node's default representation.
5
+ - A label that is present but not a non-empty string is malformed: it never matches any audience set, even when the
6
+ host allows unlabelled content.
7
+ - When the host supplies an audience set, a record with no label is outside it unless the host sets
8
+ ``allow_unlabelled``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Sequence
14
+ from typing import Any
15
+
16
+
17
+ def audience_of(node: Any, rep: Any) -> tuple[str | None, bool]:
18
+ """(label, invalid) for a representation."""
19
+ own = rep.frontmatter.get("moca")
20
+ if isinstance(own, dict) and "audience" in own:
21
+ source: Any = own
22
+ else:
23
+ reps = getattr(node, "representations", None) or []
24
+ source = reps[0].frontmatter.get("moca") if reps else None
25
+ if not isinstance(source, dict) or "audience" not in source:
26
+ return None, False
27
+ value = source["audience"]
28
+ return (value, False) if isinstance(value, str) and value != "" else (None, True)
29
+
30
+
31
+ def audience_allowed(
32
+ label: str | None, invalid: bool, audiences: Sequence[str] | None, allow_unlabelled: bool = False
33
+ ) -> bool:
34
+ """Whether a label passes the host's audience set."""
35
+ if audiences is None:
36
+ return True
37
+ if invalid:
38
+ return False
39
+ if label is None:
40
+ return allow_unlabelled
41
+ return label in audiences
42
+
43
+
44
+ def record_allowed(record: Any, audiences: Sequence[str] | None, allow_unlabelled: bool = False) -> bool:
45
+ """The audience rule for a citation record, for the gate and store backends."""
46
+ label = record.get("audience")
47
+ return audience_allowed(
48
+ label if isinstance(label, str) else None, record.get("audienceInvalid") is True, audiences, allow_unlabelled
49
+ )
@@ -0,0 +1 @@
1
+ """Search backends: lexical (BM25), dense over sidecar vectors, an in-memory store, and hybrid fusion."""
@@ -0,0 +1,118 @@
1
+ """BM25 over plain text, shared by the lexical and in-memory store backends.
2
+
3
+ Tokens. Every Reader's built-in lexical backend tokenises the same way, so they return the same results:
4
+ 1. NFKC-normalise the text, then lower-case it as package spec §6.3 does (one code point at a time).
5
+ 2. Letters, digits and combining marks are word characters; anything else separates words. Keeping marks inside
6
+ words keeps Devanagari, Arabic and other scripts with vowel signs whole.
7
+ 3. Scripts written without spaces (Chinese, Japanese kana, Thai, Lao, Khmer, Myanmar) are split into units, each
8
+ character with the combining marks after it, and indexed as each unit and each pair of neighbouring units.
9
+ No stemming: hosts that need it supply their own lexical backend.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ import unicodedata
16
+ from collections.abc import Sequence
17
+ from typing import Any
18
+
19
+ from .._js import round_scaled, utf16_key
20
+ from ..unicode import lower_code_point, normalize
21
+
22
+ UNSPACED = (
23
+ (0x0E00, 0x0EFF), # Thai, Lao
24
+ (0x1000, 0x109F), # Myanmar
25
+ (0x1780, 0x17FF), # Khmer
26
+ (0x3040, 0x30FF), # Hiragana, Katakana
27
+ (0x31F0, 0x31FF), # Katakana phonetic extensions
28
+ (0x3400, 0x4DBF), # CJK extension A
29
+ (0x4E00, 0x9FFF), # CJK unified ideographs
30
+ (0xF900, 0xFAFF), # CJK compatibility ideographs
31
+ (0x20000, 0x3FFFF), # CJK extensions B and later
32
+ )
33
+
34
+
35
+ def _unspaced(cp: int) -> bool:
36
+ return any(lo <= cp <= hi for lo, hi in UNSPACED)
37
+
38
+
39
+ def _word_char(ch: str) -> bool:
40
+ return len(ch) == 1 and unicodedata.category(ch)[0] in "LNM"
41
+
42
+
43
+ def _mark(ch: str) -> bool:
44
+ return len(ch) == 1 and unicodedata.category(ch)[0] == "M"
45
+
46
+
47
+ def tokens(text: str) -> list[str]:
48
+ out: list[str] = []
49
+ run: list[str] = []
50
+ run_unspaced = False
51
+
52
+ def flush() -> None:
53
+ if not run:
54
+ return
55
+ if not run_unspaced:
56
+ out.append("".join(run))
57
+ else:
58
+ for i, unit in enumerate(run):
59
+ out.append(unit)
60
+ if i + 1 < len(run):
61
+ out.append(unit + run[i + 1])
62
+ run.clear()
63
+
64
+ for ch in normalize("NFKC", text):
65
+ c = lower_code_point(ch)
66
+ if not _word_char(c):
67
+ flush()
68
+ continue
69
+ u = _unspaced(ord(c))
70
+ if u and run_unspaced and run and _mark(c):
71
+ run[-1] += c
72
+ continue
73
+ if run and u != run_unspaced:
74
+ flush()
75
+ run_unspaced = u
76
+ run.append(c)
77
+ flush()
78
+ return out
79
+
80
+
81
+ def count_terms(text: str) -> tuple[dict[str, int], int]:
82
+ """A text's term frequencies and length in tokens: all BM25 needs of it."""
83
+ t = tokens(text)
84
+ terms: dict[str, int] = {}
85
+ for term in t:
86
+ terms[term] = terms.get(term, 0) + 1
87
+ return terms, len(t)
88
+
89
+
90
+ def bm25(docs: Sequence[dict[str, Any]], query: str, k1: float = 1.2, b: float = 0.75) -> list[dict[str, Any]]:
91
+ """``docs`` are ``{item, text, key}``; key breaks ties. Best first, zero scores dropped."""
92
+ return bm25_counted(
93
+ [{"item": d["item"], "key": d["key"], "counts": count_terms(d["text"])} for d in docs], query, k1, b
94
+ )
95
+
96
+
97
+ def bm25_counted(docs: Sequence[dict[str, Any]], query: str, k1: float = 1.2, b: float = 0.75) -> list[dict[str, Any]]:
98
+ """bm25() over documents already counted (``{item, key, counts}``), so a backend can count each document once.
99
+ The same arithmetic in the same order, so scores are identical."""
100
+ q = list(dict.fromkeys(tokens(query)))
101
+ if not q or not docs:
102
+ return []
103
+ avg = sum(d["counts"][1] for d in docs) / len(docs) or 1
104
+ df = {term: sum(1 for d in docs if term in d["counts"][0]) for term in q}
105
+ scored = []
106
+ for d in docs:
107
+ terms, length = d["counts"]
108
+ score = 0.0
109
+ for term in q:
110
+ f = terms.get(term, 0)
111
+ if f == 0:
112
+ continue
113
+ idf = math.log(1 + (len(docs) - df[term] + 0.5) / (df[term] + 0.5))
114
+ score += idf * ((f * (k1 + 1)) / (f + k1 * (1 - b + (b * length) / avg)))
115
+ scored.append({"item": d["item"], "key": d["key"], "score": round_scaled(score, 1e4)})
116
+ kept = [s for s in scored if s["score"] > 0]
117
+ kept.sort(key=lambda s: (-s["score"], utf16_key(s["key"])))
118
+ return [{"item": s["item"], "score": s["score"]} for s in kept]
@@ -0,0 +1,146 @@
1
+ """Dense backend over sidecar vectors (Reader contract §9.3), scored by the index's ``model.distance`` (cosine by
2
+ default). The host supplies the embedder. Every loaded package it does not search is reported: a sidecar whose model
3
+ does not match the embedder (S006), or no vectors at all (S007)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import math
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from .._js import get, js_string, round_scaled, truthy
12
+ from ..audience import audience_allowed, audience_of
13
+ from ..citation import pick_representation
14
+ from ..diagnostics import Diagnostics
15
+
16
+ if TYPE_CHECKING:
17
+ from ..library import Library
18
+ from ..search import Embedder
19
+
20
+
21
+ class DenseBackend:
22
+ features = ("dense", "filterPushdown")
23
+
24
+ def __init__(self, library: Library, embedder: Embedder) -> None:
25
+ if embedder is None or not callable(getattr(embedder, "embed", None)):
26
+ raise TypeError("DenseBackend needs a host embedder")
27
+ self.library = library
28
+ self.embedder = embedder
29
+ self._checks = Diagnostics()
30
+ self.diagnostics = self._checks.items
31
+ self.checked: dict[str, bool] = {}
32
+ self._check()
33
+
34
+ def search(self, query: str, limit: int, filters: dict[str, Any]) -> list[dict[str, Any]]:
35
+ self._check()
36
+ digests = filters.get("digests")
37
+ usable = [
38
+ p
39
+ for p in self.library.packages
40
+ if self.checked.get(p.result.digest or "") and (digests is None or p.result.digest in digests)
41
+ ]
42
+ if not usable:
43
+ return []
44
+ q = self.embedder.embed([query], "query")[0]
45
+ excluded = {f"{x['digest']} {x['node']}" for x in filters.get("exclude") or []}
46
+ audiences, concepts, locale = filters.get("audiences"), filters.get("concepts"), filters.get("locale")
47
+ scored: list[dict[str, Any]] = []
48
+ for p in usable:
49
+ result = p.result
50
+ distance = get(get(p.index, "model"), "distance")
51
+ by_path = {n.path: n for n in result.nodes}
52
+ for c in p.chunks or []:
53
+ node = by_path.get(c["path"])
54
+ rep = (
55
+ next((r for r in node.representations if (r.locale or None) == (c.get("locale") or None)), None)
56
+ if node
57
+ else None
58
+ )
59
+ if node is None or rep is None or not isinstance(c.get("vector"), list):
60
+ continue
61
+ node_id = f"{js_string(result.manifest.get('id'))}#{node.path}"
62
+ if f"{result.digest} {node_id}" in excluded:
63
+ continue
64
+ if locale and pick_representation(node, locale) is not rep:
65
+ continue
66
+ label, invalid = audience_of(node, rep)
67
+ if not audience_allowed(label, invalid, audiences, filters.get("allowUnlabelled") is True):
68
+ continue
69
+ if concepts and not any(x in concepts for x in rep.concepts or []):
70
+ continue
71
+ hit: dict[str, Any] = {"digest": result.digest, "node": node_id, "path": node.path}
72
+ if rep.locale:
73
+ hit["locale"] = rep.locale
74
+ hit["span"] = {"start": c["start"], "end": c["end"]}
75
+ hit["score"] = round_scaled(similarity(distance, q, c["vector"]), 1e4)
76
+ scored.append(hit)
77
+ scored.sort(key=lambda h: -h["score"])
78
+ return scored[:limit]
79
+
80
+ def _check(self) -> None:
81
+ """Compares the embedder with each loaded sidecar's model, once per package digest."""
82
+ for p in self.library.packages:
83
+ digest = p.result.digest or ""
84
+ if digest in self.checked:
85
+ continue
86
+ vectors = [c for c in p.chunks or [] if isinstance(c.get("vector"), list)]
87
+ name = f"{js_string(p.result.manifest.get('id'))}@{js_string(p.result.manifest.get('version'))}"
88
+ if not truthy(p.index) or not vectors:
89
+ self._checks.add("S007_NO_VECTORS", f"{name}: no sidecar vectors; the package is not searched")
90
+ self.checked[digest] = False
91
+ continue
92
+ problem = model_mismatch(self.embedder, get(p.index, "model"), len(vectors[0]["vector"]))
93
+ if problem:
94
+ self._checks.add("S006_MODEL_MISMATCH", f"{name}: {problem}")
95
+ self.checked[digest] = not problem
96
+
97
+
98
+ def model_mismatch(embedder: Any, model: Any, vector_length: int) -> str | None:
99
+ """Why an embedder cannot search vectors made by ``model``, or None when it can."""
100
+ name = get(model, "name")
101
+ if not truthy(name):
102
+ return "the index names no model"
103
+ if name != embedder.name:
104
+ return f'index model "{name}" is not the host embedder "{embedder.name}"'
105
+ version, own_version = model.get("version"), getattr(embedder, "version", None)
106
+ if truthy(version) and truthy(own_version) and version != own_version:
107
+ return f"index model version {version} is not {own_version}"
108
+ dims = model["dimensions"] if model.get("dimensions") is not None else vector_length
109
+ if dims != embedder.dimensions:
110
+ return f"index has {dims} dimensions; the host embedder has {embedder.dimensions}"
111
+ # Prefixes are compared, never applied (Reader contract §9.3).
112
+ for key, attr in (("queryPrefix", "query_prefix"), ("documentPrefix", "document_prefix")):
113
+ theirs, ours = model.get(key), getattr(embedder, attr, None)
114
+ # A prefix declared on one side only is a mismatch too.
115
+ if theirs != ours:
116
+ return f"index {key} {json.dumps(theirs, ensure_ascii=False)} is not the host embedder's {json.dumps(ours, ensure_ascii=False)}"
117
+ return None
118
+
119
+
120
+ def similarity(distance: Any, a: Any, b: Any) -> float:
121
+ """How well vector ``b`` matches query ``a``: cosine (the default), the dot product, or the negative euclidean
122
+ distance. A higher score is always a better match."""
123
+ if not isinstance(a, list) or not isinstance(b, list) or len(a) != len(b):
124
+ return 0.0
125
+ if distance == "dot":
126
+ total = 0.0
127
+ for x, y in zip(a, b, strict=True):
128
+ total += x * y
129
+ return total
130
+ if distance == "euclidean":
131
+ squares = 0.0
132
+ for x, y in zip(a, b, strict=True):
133
+ squares += (x - y) ** 2
134
+ return 0 - math.sqrt(squares)
135
+ return cosine(a, b)
136
+
137
+
138
+ def cosine(a: list[float], b: list[float]) -> float:
139
+ if len(a) != len(b):
140
+ return 0.0
141
+ dot = na = nb = 0.0
142
+ for x, y in zip(a, b, strict=True):
143
+ dot += x * y
144
+ na += x * x
145
+ nb += y * y
146
+ return 0.0 if na == 0 or nb == 0 else dot / math.sqrt(na * nb)
@@ -0,0 +1,73 @@
1
+ """Hybrid backend: a lexical and a dense backend merged by Reciprocal Rank Fusion (Reader contract §9.6).
2
+
3
+ score(hit) = sum of weight_b / (k + rank_b(hit))
4
+
5
+ rank_b is the hit's 1-based position in backend b's results. k (default 60) and the weights (default 1) are the
6
+ host's; a package never sets them. A package the dense backend cannot search (S006, S007) still comes back through
7
+ the lexical side.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from typing import Any
14
+
15
+ from .._js import round_scaled, utf16_key
16
+
17
+
18
+ def _key(h: dict[str, Any]) -> str:
19
+ span = h.get("span") or {}
20
+ return (
21
+ f"{h.get('digest')}\0{h.get('node')}\0{h.get('locale') or ''}\0{span.get('start', '')}\0{span.get('end', '')}"
22
+ )
23
+
24
+
25
+ def _tie(h: dict[str, Any]) -> tuple[bytes, bytes, float]:
26
+ """Ties: node id, then locale, then span start; the same order in every Reader."""
27
+ return utf16_key(str(h.get("node"))), utf16_key(h.get("locale") or ""), (h.get("span") or {}).get("start", -1)
28
+
29
+
30
+ def _weight(name: str, w: Any) -> float:
31
+ if isinstance(w, bool) or not isinstance(w, (int, float)) or not math.isfinite(w) or w < 0:
32
+ raise ValueError(f"weights.{name} must be a number of at least 0")
33
+ return float(w)
34
+
35
+
36
+ class HybridBackend:
37
+ def __init__(self, lexical: Any, dense: Any, k: float = 60, weights: dict[str, float] | None = None) -> None:
38
+ if not callable(getattr(lexical, "search", None)) or not callable(getattr(dense, "search", None)):
39
+ raise TypeError("HybridBackend needs a lexical and a dense backend")
40
+ if isinstance(k, bool) or not isinstance(k, (int, float)) or not math.isfinite(k) or k < 0:
41
+ raise ValueError("k must be a number of at least 0")
42
+ self.lexical, self.dense, self.k = lexical, dense, k
43
+ w = weights or {}
44
+ self.weights = {
45
+ "lexical": _weight("lexical", w.get("lexical", 1)),
46
+ "dense": _weight("dense", w.get("dense", 1)),
47
+ }
48
+ if self.weights["lexical"] == 0 and self.weights["dense"] == 0:
49
+ raise ValueError("at least one weight must be above 0")
50
+ pushdown = all("filterPushdown" in (getattr(b, "features", None) or []) for b in (lexical, dense))
51
+ self.features = ("lexical", "dense", *(("filterPushdown",) if pushdown else ()))
52
+
53
+ @property
54
+ def diagnostics(self) -> list[Any]:
55
+ """Both backends' findings, such as S006 and S007 from the dense side."""
56
+ return [*(getattr(self.lexical, "diagnostics", None) or []), *(getattr(self.dense, "diagnostics", None) or [])]
57
+
58
+ def search(self, query: str, limit: int, filters: dict[str, Any]) -> list[dict[str, Any]]:
59
+ lexical = self.lexical.search(query, limit, filters)
60
+ dense = self.dense.search(query, limit, filters)
61
+ fused: dict[str, dict[str, Any]] = {}
62
+
63
+ def add(hits: Any, weight: float) -> None:
64
+ if weight == 0 or not isinstance(hits, list):
65
+ return
66
+ for i, hit in enumerate(hits):
67
+ entry = fused.setdefault(_key(hit), {"hit": hit, "score": 0.0})
68
+ entry["score"] += weight / (self.k + i + 1)
69
+
70
+ add(lexical, self.weights["lexical"])
71
+ add(dense, self.weights["dense"])
72
+ ranked = sorted((e for e in fused.values() if e["score"] > 0), key=lambda e: (-e["score"], _tie(e["hit"])))
73
+ return [{**e["hit"], "score": round_scaled(e["score"], 1e6)} for e in ranked[:limit]]
@@ -0,0 +1,112 @@
1
+ """Lexical (BM25) backend over the packages in a library: each package's sidecar chunks when it was added with them,
2
+ otherwise its nodes. With a locale, the representation package spec §5 selects for it; without one, every
3
+ representation, so content in any language can be found, and the best-scoring one for each node is returned."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from .._js import js_string
10
+ from ..audience import audience_allowed, audience_of
11
+ from ..citation import citation_for, pick_representation
12
+ from ..sidecar import chunk_text
13
+ from .bm25 import bm25_counted, count_terms
14
+
15
+ if TYPE_CHECKING:
16
+ from ..library import Library
17
+
18
+
19
+ class LexicalBackend:
20
+ features = ("lexical", "filterPushdown")
21
+
22
+ def __init__(self, library: Library) -> None:
23
+ self.library = library
24
+ self.diagnostics: list[Any] = []
25
+ # Each document's term counts, per package digest, kept after its first search and dropped when the package
26
+ # leaves the library; scores are the same as counting afresh.
27
+ self._counted: dict[str, dict[str, tuple[dict[str, int], int]]] = {}
28
+
29
+ def search(self, query: str, limit: int, filters: dict[str, Any]) -> list[dict[str, Any]]:
30
+ excluded = {f"{x['digest']} {x['node']}" for x in filters.get("exclude") or []}
31
+ digests = set(filters["digests"]) if filters.get("digests") is not None else None
32
+ audiences, concepts, locale = filters.get("audiences"), filters.get("concepts"), filters.get("locale")
33
+ docs: list[dict[str, Any]] = []
34
+ loaded = {p.result.digest or "" for p in self.library.packages}
35
+ for gone in [d for d in self._counted if d not in loaded]:
36
+ del self._counted[gone]
37
+ for p in self.library.packages:
38
+ result = p.result
39
+ if digests is not None and result.digest not in digests:
40
+ continue
41
+ counted = self._counted.setdefault(result.digest or "", {})
42
+ by_path = {n.path: n for n in result.nodes}
43
+ pid = js_string(result.manifest.get("id"))
44
+
45
+ def keep(node: Any, rep: Any, pid: str = pid, digest: Any = result.digest) -> str | None:
46
+ node_id = f"{pid}#{node.path}"
47
+ if f"{digest} {node_id}" in excluded:
48
+ return None
49
+ label, invalid = audience_of(node, rep)
50
+ if not audience_allowed(label, invalid, audiences, filters.get("allowUnlabelled") is True):
51
+ return None
52
+ if concepts and not any(c in concepts for c in rep.concepts or []):
53
+ return None
54
+ return node_id
55
+
56
+ if p.chunks is not None:
57
+ for c in p.chunks:
58
+ node = by_path.get(c["path"])
59
+ rep = (
60
+ next((r for r in node.representations if (r.locale or None) == (c.get("locale") or None)), None)
61
+ if node
62
+ else None
63
+ )
64
+ if node is None or rep is None:
65
+ continue
66
+ if locale and pick_representation(node, locale) is not rep:
67
+ continue
68
+ node_id = keep(node, rep)
69
+ if not node_id:
70
+ continue
71
+ key = f"{node_id}\0{js_string(c['start']).rjust(12, '0')}"
72
+ # The cache key adds the locale: chunks of two representations can start at the same offset.
73
+ cache_key = f"{key}\0{c.get('locale') or ''}"
74
+ if cache_key not in counted:
75
+ title = citation_for(result, node, rep)["node"].get("title") or ""
76
+ counted[cache_key] = count_terms(f"{title} {chunk_text(c, rep)}")
77
+ item: dict[str, Any] = {"digest": result.digest, "node": node_id, "path": node.path}
78
+ if rep.locale:
79
+ item["locale"] = rep.locale
80
+ item["span"] = {"start": c["start"], "end": c["end"]}
81
+ docs.append({"item": item, "counts": counted[cache_key], "key": key})
82
+ else:
83
+ for node in result.nodes:
84
+ reps = [pick_representation(node, locale)] if locale else node.representations
85
+ for rep in reps:
86
+ node_id = keep(node, rep)
87
+ if not node_id:
88
+ continue
89
+ key = f"{node_id}\0{rep.locale or ''}"
90
+ if key not in counted:
91
+ fm = rep.frontmatter
92
+ tags = fm["tags"] if isinstance(fm.get("tags"), list) else []
93
+ parts = [fm.get("title"), fm.get("description"), *tags, rep.body]
94
+ counted[key] = count_terms(" ".join(x for x in parts if isinstance(x, str)))
95
+ item = {"digest": result.digest, "node": node_id, "path": node.path}
96
+ if rep.locale:
97
+ item["locale"] = rep.locale
98
+ docs.append({"item": item, "counts": counted[key], "key": key})
99
+ seen: set[str] = set()
100
+ out: list[dict[str, Any]] = []
101
+ for hit in bm25_counted(docs, query):
102
+ item = hit["item"]
103
+ if "span" not in item:
104
+ # Node ids repeat across versions of a package, so key on the digest too.
105
+ key = f"{item['digest']} {item['node']}"
106
+ if key in seen:
107
+ continue
108
+ seen.add(key)
109
+ out.append({**item, "score": hit["score"]})
110
+ if len(out) >= limit:
111
+ break
112
+ return out
@@ -0,0 +1,122 @@
1
+ """A reference store backend, kept in memory (Reader contract §9.4).
2
+
3
+ It stands in for a host's vector store in tests and shows what a store binding must record at ingest: the whole
4
+ citation record, including the package digest. Search then resolves hits from those records, so the host never has
5
+ to load every package to search its store.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from .._js import js_string, round_scaled
13
+ from ..audience import record_allowed
14
+ from ..citation import citation_for
15
+ from ..sidecar import chunk_text
16
+ from .bm25 import bm25
17
+ from .dense import similarity
18
+
19
+ if TYPE_CHECKING:
20
+ from ..library import Library
21
+ from ..search import Embedder
22
+
23
+
24
+ class MemoryStoreBackend:
25
+ """Without an embedder the store is searched lexically. ``distance`` is how its vectors are compared."""
26
+
27
+ def __init__(
28
+ self, embedder: Embedder | None = None, filter_pushdown: bool = True, distance: str = "cosine"
29
+ ) -> None:
30
+ self.embedder = embedder
31
+ self.distance = distance
32
+ self.features = (
33
+ "dense" if embedder is not None else "lexical",
34
+ *(("filterPushdown",) if filter_pushdown else ()),
35
+ )
36
+ self.diagnostics: list[Any] = []
37
+ self.records: list[dict[str, Any]] = []
38
+
39
+ def ingest(self, library: Library) -> MemoryStoreBackend:
40
+ """Ingests every node representation (or sidecar chunk) of every package, through citation records."""
41
+ index = library.relation_index()
42
+ now = library.clock()
43
+ pending: list[dict[str, Any]] = []
44
+ for p in library.packages:
45
+ result = p.result
46
+ by_path = {n.path: n for n in result.nodes}
47
+ units: list[tuple[Any, Any, Any, Any, Any]]
48
+ if p.chunks is not None:
49
+ units = [
50
+ (by_path.get(c["path"]), c.get("locale") or None, {"start": c["start"], "end": c["end"]}, c, None)
51
+ for c in p.chunks
52
+ ]
53
+ else:
54
+ units = [
55
+ (node, rep.locale, None, None, rep.body) for node in result.nodes for rep in node.representations
56
+ ]
57
+ for node, locale, span, chunk, body in units:
58
+ rep = next((r for r in node.representations if (r.locale or None) == locale), None) if node else None
59
+ if node is None or rep is None:
60
+ continue
61
+ text = chunk_text(chunk, rep) if chunk is not None else body or ""
62
+ record = citation_for(result, node, rep, now, index, text)
63
+ if span is not None:
64
+ record["span"] = dict(span)
65
+ hit: dict[str, Any] = {"digest": result.digest, "node": record["node"]["id"], "path": node.path}
66
+ if rep.locale:
67
+ hit["locale"] = rep.locale
68
+ if span is not None:
69
+ hit["span"] = span
70
+ hit["record"] = record
71
+ entry: dict[str, Any] = {
72
+ "hit": hit,
73
+ "text": f"{record['node'].get('title') or ''} {text}",
74
+ "concepts": record.get("concepts") or [],
75
+ }
76
+ if record.get("audience"):
77
+ entry["audience"] = record["audience"]
78
+ if record.get("audienceInvalid") is True:
79
+ entry["audienceInvalid"] = True
80
+ pending.append(entry)
81
+ if self.embedder is not None and pending:
82
+ vectors = self.embedder.embed([e["text"] for e in pending], "document")
83
+ for entry, vector in zip(pending, vectors, strict=True):
84
+ entry["vector"] = vector
85
+ self.records.extend(pending)
86
+ return self
87
+
88
+ def remove(self, digest: str) -> MemoryStoreBackend:
89
+ """Removes everything ingested from a package digest."""
90
+ self.records = [r for r in self.records if r["hit"]["digest"] != digest]
91
+ return self
92
+
93
+ def search(self, query: str, limit: int, filters: dict[str, Any]) -> list[dict[str, Any]]:
94
+ candidates = self.records
95
+ if "filterPushdown" in self.features:
96
+ digests = set(filters.get("digests") or [])
97
+ excluded = {f"{x['digest']} {x['node']}" for x in filters.get("exclude") or []}
98
+ audiences, concepts = filters.get("audiences"), filters.get("concepts")
99
+ candidates = [
100
+ r
101
+ for r in candidates
102
+ if r["hit"]["digest"] in digests
103
+ and f"{r['hit']['digest']} {r['hit']['node']}" not in excluded
104
+ and record_allowed(r, audiences, filters.get("allowUnlabelled") is True)
105
+ and (not concepts or any(c in concepts for c in r["concepts"]))
106
+ ]
107
+ if self.embedder is None:
108
+ docs = [
109
+ {
110
+ "item": r["hit"],
111
+ "text": r["text"],
112
+ "key": f"{r['hit']['node']}\0{r['hit'].get('locale') or ''}\0{js_string((r['hit'].get('span') or {}).get('start', 0))}",
113
+ }
114
+ for r in candidates
115
+ ]
116
+ return [{**h["item"], "score": h["score"]} for h in bm25(docs, query)[:limit]]
117
+ q = self.embedder.embed([query], "query")[0]
118
+ scored = [
119
+ {**r["hit"], "score": round_scaled(similarity(self.distance, q, r.get("vector")), 1e4)} for r in candidates
120
+ ]
121
+ scored.sort(key=lambda h: -h["score"])
122
+ return scored[:limit]