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.
- moca_reader/__init__.py +84 -0
- moca_reader/_js.py +291 -0
- moca_reader/attestations.py +271 -0
- moca_reader/audience.py +49 -0
- moca_reader/backends/__init__.py +1 -0
- moca_reader/backends/bm25.py +118 -0
- moca_reader/backends/dense.py +146 -0
- moca_reader/backends/hybrid.py +73 -0
- moca_reader/backends/lexical.py +112 -0
- moca_reader/backends/memory_store.py +122 -0
- moca_reader/citation.py +213 -0
- moca_reader/codes.py +110 -0
- moca_reader/conformance.py +249 -0
- moca_reader/content.py +312 -0
- moca_reader/diagnostics.py +45 -0
- moca_reader/digest.py +92 -0
- moca_reader/dsse.py +97 -0
- moca_reader/ed25519.py +83 -0
- moca_reader/embedders.py +224 -0
- moca_reader/evidence_text.py +187 -0
- moca_reader/frontmatter.py +151 -0
- moca_reader/library.py +176 -0
- moca_reader/manifest.py +186 -0
- moca_reader/metadata.py +58 -0
- moca_reader/profiles.py +5 -0
- moca_reader/py.typed +0 -0
- moca_reader/reader.py +468 -0
- moca_reader/relations.py +46 -0
- moca_reader/resolver.py +33 -0
- moca_reader/reviews.py +18 -0
- moca_reader/schemas/ag-ui-citations.schema.json +36 -0
- moca_reader/schemas/citation-record.schema.json +326 -0
- moca_reader/schemas/manifest.schema.json +171 -0
- moca_reader/schemas/node.schema.json +372 -0
- moca_reader/schemas/review-predicate.schema.json +42 -0
- moca_reader/schemas/search-hit.schema.json +62 -0
- moca_reader/schemas/sidecar-index.schema.json +155 -0
- moca_reader/schemas/structure.schema.json +93 -0
- moca_reader/schemas/trust-root.schema.json +130 -0
- moca_reader/schemas.py +44 -0
- moca_reader/search.py +346 -0
- moca_reader/sidecar.py +228 -0
- moca_reader/source.py +285 -0
- moca_reader/state.py +119 -0
- moca_reader/strategies/__init__.py +1 -0
- moca_reader/strategies/diversify.py +28 -0
- moca_reader/strategies/ontology_guided.py +63 -0
- moca_reader/structure.py +385 -0
- moca_reader/structure_ops.py +160 -0
- moca_reader/unicode.py +33 -0
- moca_reader-0.6.0.dist-info/METADATA +209 -0
- moca_reader-0.6.0.dist-info/RECORD +55 -0
- moca_reader-0.6.0.dist-info/WHEEL +4 -0
- moca_reader-0.6.0.dist-info/licenses/LICENSE +201 -0
- moca_reader-0.6.0.dist-info/licenses/NOTICE +15 -0
moca_reader/audience.py
ADDED
|
@@ -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]
|