pyqmd-mlx 0.7.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.
- pyqmd_mlx/__init__.py +0 -0
- pyqmd_mlx/bench/__init__.py +0 -0
- pyqmd_mlx/bench/_fixture.py +82 -0
- pyqmd_mlx/bench/_metrics.py +101 -0
- pyqmd_mlx/bench/_pathmatch.py +60 -0
- pyqmd_mlx/bench/_sampling.py +62 -0
- pyqmd_mlx/bench/runner.py +283 -0
- pyqmd_mlx/cli/__init__.py +0 -0
- pyqmd_mlx/cli/_db.py +27 -0
- pyqmd_mlx/cli/_errors.py +48 -0
- pyqmd_mlx/cli/_format.py +95 -0
- pyqmd_mlx/cli/_fullpath.py +44 -0
- pyqmd_mlx/cli/_index_report.py +25 -0
- pyqmd_mlx/cli/_lineparse.py +30 -0
- pyqmd_mlx/cli/_metadata_filter.py +34 -0
- pyqmd_mlx/cli/_multiget.py +82 -0
- pyqmd_mlx/cli/_output_documents.py +146 -0
- pyqmd_mlx/cli/_output_search.py +247 -0
- pyqmd_mlx/cli/_progress.py +102 -0
- pyqmd_mlx/cli/_snippet.py +99 -0
- pyqmd_mlx/cli/_theme.py +27 -0
- pyqmd_mlx/cli/_types.py +55 -0
- pyqmd_mlx/cli/app.py +89 -0
- pyqmd_mlx/cli/commands/__init__.py +0 -0
- pyqmd_mlx/cli/commands/bench.py +170 -0
- pyqmd_mlx/cli/commands/cleanup.py +63 -0
- pyqmd_mlx/cli/commands/collection.py +220 -0
- pyqmd_mlx/cli/commands/context.py +115 -0
- pyqmd_mlx/cli/commands/documents.py +173 -0
- pyqmd_mlx/cli/commands/embed.py +83 -0
- pyqmd_mlx/cli/commands/mcp.py +67 -0
- pyqmd_mlx/cli/commands/pull.py +39 -0
- pyqmd_mlx/cli/commands/search.py +154 -0
- pyqmd_mlx/cli/commands/skill.py +171 -0
- pyqmd_mlx/cli/commands/skills.py +324 -0
- pyqmd_mlx/cli/commands/status.py +153 -0
- pyqmd_mlx/cli/commands/update.py +182 -0
- pyqmd_mlx/llm/__init__.py +23 -0
- pyqmd_mlx/llm/_cache.py +12 -0
- pyqmd_mlx/llm/_constants.py +15 -0
- pyqmd_mlx/llm/_expand_grammar.py +155 -0
- pyqmd_mlx/llm/_hub.py +43 -0
- pyqmd_mlx/llm/_prompts.py +60 -0
- pyqmd_mlx/llm/embed.py +43 -0
- pyqmd_mlx/llm/expand.py +195 -0
- pyqmd_mlx/llm/rerank.py +59 -0
- pyqmd_mlx/mcp/__init__.py +0 -0
- pyqmd_mlx/mcp/_errors.py +34 -0
- pyqmd_mlx/mcp/_formatting.py +52 -0
- pyqmd_mlx/mcp/_instructions.py +56 -0
- pyqmd_mlx/mcp/server.py +756 -0
- pyqmd_mlx/skills/pyqmd/SKILL.md +152 -0
- pyqmd_mlx/skills/pyqmd/references/mcp-setup.md +132 -0
- pyqmd_mlx/skills/pyqmd-bench/SKILL.md +71 -0
- pyqmd_mlx/skills/pyqmd-librarian/SKILL.md +52 -0
- pyqmd_mlx/skills/pyqmd-researcher/SKILL.md +43 -0
- pyqmd_mlx/store/__init__.py +3 -0
- pyqmd_mlx/store/_ast.py +333 -0
- pyqmd_mlx/store/_ast_queries.py +94 -0
- pyqmd_mlx/store/_chunking.py +234 -0
- pyqmd_mlx/store/_expansion.py +44 -0
- pyqmd_mlx/store/_fts_query.py +109 -0
- pyqmd_mlx/store/_indexing.py +186 -0
- pyqmd_mlx/store/_intent.py +112 -0
- pyqmd_mlx/store/_metadata.py +242 -0
- pyqmd_mlx/store/_metadata_filter.py +362 -0
- pyqmd_mlx/store/_rrf.py +53 -0
- pyqmd_mlx/store/_schema.py +143 -0
- pyqmd_mlx/store/_types.py +71 -0
- pyqmd_mlx/store/_yaml_alias_guard.py +33 -0
- pyqmd_mlx/store/store.py +1721 -0
- pyqmd_mlx/version.py +10 -0
- pyqmd_mlx-0.7.0.dist-info/METADATA +98 -0
- pyqmd_mlx-0.7.0.dist-info/RECORD +77 -0
- pyqmd_mlx-0.7.0.dist-info/WHEEL +4 -0
- pyqmd_mlx-0.7.0.dist-info/entry_points.txt +2 -0
- pyqmd_mlx-0.7.0.dist-info/licenses/LICENSE +21 -0
pyqmd_mlx/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Loads and validates a bench fixture JSON file into plain dataclasses.
|
|
2
|
+
Field names match Node's src/bench/fixtures/*.json exactly (id/query/
|
|
3
|
+
type/description/expected_files/expected_in_top_k per query;
|
|
4
|
+
description/version/collection at the top level) so an existing Node
|
|
5
|
+
fixture can be reused verbatim -- except for structured lex:/vec:/hyde:/
|
|
6
|
+
intent: multi-line queries, which aren't supported (see design spec) and
|
|
7
|
+
are rejected here at load time rather than silently mis-scored later."""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
_STRUCTURED_QUERY_PREFIXES = ("lex:", "vec:", "hyde:", "intent:")
|
|
13
|
+
_REQUIRED_QUERY_FIELDS = ("id", "query", "expected_files", "expected_in_top_k")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class BenchQuery:
|
|
18
|
+
id: str
|
|
19
|
+
query: str
|
|
20
|
+
type: str
|
|
21
|
+
description: str
|
|
22
|
+
expected_files: list[str]
|
|
23
|
+
expected_in_top_k: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class BenchFixture:
|
|
28
|
+
description: str
|
|
29
|
+
version: int
|
|
30
|
+
collection: str | None
|
|
31
|
+
queries: list[BenchQuery] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_structured_query(query_text: str) -> bool:
|
|
35
|
+
return any(
|
|
36
|
+
line.strip().lower().startswith(_STRUCTURED_QUERY_PREFIXES)
|
|
37
|
+
for line in query_text.splitlines()
|
|
38
|
+
if line.strip()
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_fixture(path: str) -> BenchFixture:
|
|
43
|
+
with open(path, encoding="utf-8") as f:
|
|
44
|
+
try:
|
|
45
|
+
raw = json.load(f)
|
|
46
|
+
except json.JSONDecodeError as exc:
|
|
47
|
+
raise ValueError(f"Invalid fixture JSON in {path}: {exc}") from exc
|
|
48
|
+
|
|
49
|
+
if not isinstance(raw.get("queries"), list):
|
|
50
|
+
raise ValueError(f"Invalid fixture: missing 'queries' array in {path}")
|
|
51
|
+
|
|
52
|
+
queries = []
|
|
53
|
+
for i, q in enumerate(raw["queries"]):
|
|
54
|
+
missing = [f for f in _REQUIRED_QUERY_FIELDS if f not in q]
|
|
55
|
+
if missing:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"Invalid fixture: query at index {i} is missing required field(s): "
|
|
58
|
+
f"{', '.join(missing)}"
|
|
59
|
+
)
|
|
60
|
+
if _is_structured_query(q["query"]):
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"Query '{q['id']}' uses structured lex:/vec:/hyde:/intent: syntax, "
|
|
63
|
+
"which pyqmd's bench doesn't support yet. Rewrite it as a single "
|
|
64
|
+
"plain-text query."
|
|
65
|
+
)
|
|
66
|
+
queries.append(
|
|
67
|
+
BenchQuery(
|
|
68
|
+
id=q["id"],
|
|
69
|
+
query=q["query"],
|
|
70
|
+
type=q.get("type", ""),
|
|
71
|
+
description=q.get("description", ""),
|
|
72
|
+
expected_files=q["expected_files"],
|
|
73
|
+
expected_in_top_k=q["expected_in_top_k"],
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return BenchFixture(
|
|
78
|
+
description=raw.get("description", ""),
|
|
79
|
+
version=raw.get("version", 1),
|
|
80
|
+
collection=raw.get("collection"),
|
|
81
|
+
queries=queries,
|
|
82
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Pure IR-metric functions, no I/O. Two families:
|
|
2
|
+
|
|
3
|
+
- Absolute metrics (reciprocal_rank / mean_reciprocal_rank / recall_at_k /
|
|
4
|
+
ndcg_at_k) score one system against a fixed set of relevant document ids
|
|
5
|
+
-- used in qrels-mode, where curated relevance judgments exist.
|
|
6
|
+
- Agreement metrics (top_k_overlap / spearman_rank_correlation) compare two
|
|
7
|
+
systems' own result lists to each other with no ground truth -- used in
|
|
8
|
+
agreement-mode, for a dataset profile with no qrels.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def reciprocal_rank(ranked_ids: list[str], relevant_ids: set[str]) -> float:
|
|
17
|
+
for i, doc_id in enumerate(ranked_ids):
|
|
18
|
+
if doc_id in relevant_ids:
|
|
19
|
+
return 1.0 / (i + 1)
|
|
20
|
+
return 0.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def mean_reciprocal_rank(
|
|
24
|
+
per_query_ranked_ids: list[list[str]], per_query_relevant_ids: list[set[str]]
|
|
25
|
+
) -> float:
|
|
26
|
+
if not per_query_ranked_ids:
|
|
27
|
+
return 0.0
|
|
28
|
+
scores = [
|
|
29
|
+
reciprocal_rank(ranked, relevant)
|
|
30
|
+
for ranked, relevant in zip(per_query_ranked_ids, per_query_relevant_ids)
|
|
31
|
+
]
|
|
32
|
+
return sum(scores) / len(scores)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def recall_at_k(ranked_ids: list[str], relevant_ids: set[str], k: int) -> float:
|
|
36
|
+
if not relevant_ids:
|
|
37
|
+
return 0.0
|
|
38
|
+
found = set(ranked_ids[:k]) & relevant_ids
|
|
39
|
+
return len(found) / len(relevant_ids)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ndcg_at_k(ranked_ids: list[str], relevant_ids: set[str], k: int) -> float:
|
|
43
|
+
"""Binary relevance nDCG: each relevant doc contributes gain 1, scored
|
|
44
|
+
by 1/log2(rank+1), normalized against the ideal ordering (all relevant
|
|
45
|
+
docs first). A relevant id repeated in ranked_ids earns gain only at
|
|
46
|
+
its first occurrence, so the score can never exceed 1."""
|
|
47
|
+
if not relevant_ids:
|
|
48
|
+
return 0.0
|
|
49
|
+
|
|
50
|
+
def dcg(ids: list[str]) -> float:
|
|
51
|
+
total = 0.0
|
|
52
|
+
credited: set[str] = set()
|
|
53
|
+
for i, doc_id in enumerate(ids[:k]):
|
|
54
|
+
if doc_id in relevant_ids and doc_id not in credited:
|
|
55
|
+
credited.add(doc_id)
|
|
56
|
+
total += 1.0 / math.log2(i + 2) # rank is 1-indexed -> log2(rank+1)
|
|
57
|
+
return total
|
|
58
|
+
|
|
59
|
+
actual = dcg(ranked_ids)
|
|
60
|
+
ideal_ids = list(relevant_ids)[:k] + [d for d in ranked_ids if d not in relevant_ids]
|
|
61
|
+
ideal = dcg(ideal_ids)
|
|
62
|
+
return actual / ideal if ideal > 0 else 0.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def top_k_overlap(a_ids: list[str], b_ids: list[str], k: int) -> float:
|
|
66
|
+
a_top = set(a_ids[:k])
|
|
67
|
+
b_top = set(b_ids[:k])
|
|
68
|
+
# Denominator shrinks with however few results either side actually
|
|
69
|
+
# returned -- a fixed `k` made perfect agreement on a query with fewer
|
|
70
|
+
# than k results (e.g. a sparse corpus) mathematically unreachable, so
|
|
71
|
+
# the 0.7 threshold could fail queries that agreed completely.
|
|
72
|
+
denom = min(k, len(a_ids), len(b_ids))
|
|
73
|
+
if denom == 0:
|
|
74
|
+
return 0.0
|
|
75
|
+
return len(a_top & b_top) / denom
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def spearman_rank_correlation(a_ids: list[str], b_ids: list[str]) -> float | None:
|
|
79
|
+
"""Spearman correlation over the ids both lists share -- ids unique to
|
|
80
|
+
either list are ignored, since there's nothing to compare their rank
|
|
81
|
+
against. Returns None if fewer than 2 ids are shared (correlation is
|
|
82
|
+
undefined for 0 or 1 points)."""
|
|
83
|
+
a_rank_full = {doc_id: i for i, doc_id in enumerate(a_ids)}
|
|
84
|
+
b_rank_full = {doc_id: i for i, doc_id in enumerate(b_ids)}
|
|
85
|
+
common = set(a_rank_full) & set(b_rank_full)
|
|
86
|
+
|
|
87
|
+
n = len(common)
|
|
88
|
+
if n < 2:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
# Ranks must span 0..n-1 over the common subset itself, not the ids'
|
|
92
|
+
# positions in the original (longer) lists -- using original positions
|
|
93
|
+
# let ids unique to one list shift the common ids' apparent rank gaps,
|
|
94
|
+
# producing results outside [-1, 1] and occasionally the wrong sign.
|
|
95
|
+
a_common_order = sorted(common, key=lambda d: a_rank_full[d])
|
|
96
|
+
b_common_order = sorted(common, key=lambda d: b_rank_full[d])
|
|
97
|
+
a_rank = {doc_id: i for i, doc_id in enumerate(a_common_order)}
|
|
98
|
+
b_rank = {doc_id: i for i, doc_id in enumerate(b_common_order)}
|
|
99
|
+
|
|
100
|
+
d_squared_sum = sum((a_rank[doc_id] - b_rank[doc_id]) ** 2 for doc_id in common)
|
|
101
|
+
return 1 - (6 * d_squared_sum) / (n * (n**2 - 1))
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Fuzzy path matching between search results and a bench fixture's
|
|
2
|
+
expected_files, ported from Node's src/bench/score.ts (normalizePath/
|
|
3
|
+
pathsMatch). Kept separate from _metrics.py so the exact-set-membership
|
|
4
|
+
metric functions never need fuzzy-matching logic of their own --
|
|
5
|
+
canonicalize_ranked_ids does the fuzzy resolution up front and hands
|
|
6
|
+
plain exact string ids downstream."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def normalize_path(p: str) -> str:
|
|
10
|
+
"""qmd://collection/docs/readme.md -> docs/readme.md; also lowercases
|
|
11
|
+
and trims leading/trailing slashes. A bare path (no qmd:// scheme) is
|
|
12
|
+
just lowercased and trimmed."""
|
|
13
|
+
if p.startswith("qmd://"):
|
|
14
|
+
without_scheme = p[len("qmd://") :]
|
|
15
|
+
slash_idx = without_scheme.find("/")
|
|
16
|
+
p = without_scheme[slash_idx + 1 :] if slash_idx >= 0 else without_scheme
|
|
17
|
+
return p.lower().strip("/")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def paths_match(result: str, expected: str) -> bool:
|
|
21
|
+
"""Exact match after normalization, or either string a suffix of the
|
|
22
|
+
other *at a path-segment boundary* -- lets a fixture author write a
|
|
23
|
+
bare filename ("readme.md") and match a deeper indexed path
|
|
24
|
+
("docs/subdir/readme.md"), without also matching a different file
|
|
25
|
+
whose name merely ends with it ("docs/myreadme.md"). Node's pathsMatch
|
|
26
|
+
has no boundary check; see the bench design spec's path-matching
|
|
27
|
+
section for this intentional divergence."""
|
|
28
|
+
nr = normalize_path(result)
|
|
29
|
+
ne = normalize_path(expected)
|
|
30
|
+
if nr == ne:
|
|
31
|
+
return True
|
|
32
|
+
return nr.endswith("/" + ne) or ne.endswith("/" + nr)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def canonicalize_ranked_ids(ranked_paths: list[str], expected_files: list[str]) -> list[str]:
|
|
36
|
+
"""Rewrite each ranked result path to the exact expected_files string
|
|
37
|
+
it matches (paths_match semantics), so callers can score with plain
|
|
38
|
+
exact-set-membership afterward. Each expected_files entry is claimed
|
|
39
|
+
by at most one ranked path -- the highest-ranked one that matches it,
|
|
40
|
+
preferring an exact match over a suffix match -- so a bare-filename
|
|
41
|
+
expectation matching several indexed files is credited once. A path
|
|
42
|
+
matching no unclaimed expected_files entry is left as its own
|
|
43
|
+
normalized form -- it simply won't be a member of the caller's
|
|
44
|
+
relevant_ids set (and if that form happens to equal an already-claimed
|
|
45
|
+
id, the metrics themselves count each relevant id only once)."""
|
|
46
|
+
claimed: set[str] = set()
|
|
47
|
+
canonical = []
|
|
48
|
+
for path in ranked_paths:
|
|
49
|
+
normalized = normalize_path(path)
|
|
50
|
+
candidates = [e for e in expected_files if e not in claimed and paths_match(path, e)]
|
|
51
|
+
match = next(
|
|
52
|
+
(e for e in candidates if normalize_path(e) == normalized),
|
|
53
|
+
candidates[0] if candidates else None,
|
|
54
|
+
)
|
|
55
|
+
if match is None:
|
|
56
|
+
canonical.append(normalized)
|
|
57
|
+
else:
|
|
58
|
+
claimed.add(match)
|
|
59
|
+
canonical.append(match)
|
|
60
|
+
return canonical
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Model-call wrappers for bench --samples (see
|
|
2
|
+
docs/specs/2026-09-25-bench-samples-design.md). Installed on a
|
|
3
|
+
Store with Store.wrapping_llm_fns, so bench still measures the real
|
|
4
|
+
Store.query pipeline."""
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def salted_memo(expand_fn, salt: str):
|
|
10
|
+
"""expand_fn with a fixed salt, memoized by (query, model): within one
|
|
11
|
+
sample, hybrid and full share a single draw per query. Failures aren't
|
|
12
|
+
memoized, so each backend sees the error the way it would unwrapped."""
|
|
13
|
+
memo: dict[tuple[str, str], list[str]] = {}
|
|
14
|
+
|
|
15
|
+
def expand(query: str, model: str) -> list[str]:
|
|
16
|
+
key = (query, model)
|
|
17
|
+
if key not in memo:
|
|
18
|
+
memo[key] = expand_fn(query, model, salt=salt)
|
|
19
|
+
return memo[key]
|
|
20
|
+
|
|
21
|
+
return expand
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RerankCache:
|
|
25
|
+
"""Rerank scores by (query, doc) across one bench run. Reranking is a
|
|
26
|
+
pure function of the pair, and samples share most of their candidates.
|
|
27
|
+
|
|
28
|
+
Sample 0 uses recording(): every call still reaches the reranker with
|
|
29
|
+
its full batch, so sample 0 matches a plain run. Later samples use
|
|
30
|
+
caching(), which sends only unseen docs. A cached score can differ in
|
|
31
|
+
the last bits from one computed in a different batch -- the replay
|
|
32
|
+
harness accepts the same."""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self._scores: dict[tuple[str, str], float] = {}
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def _key(query: str, doc: str) -> tuple[str, str]:
|
|
39
|
+
return (query, hashlib.sha1(doc.encode("utf-8")).hexdigest())
|
|
40
|
+
|
|
41
|
+
def recording(self, rerank_fn):
|
|
42
|
+
def rerank(query: str, documents: list[str], model: str) -> list[float]:
|
|
43
|
+
scores = rerank_fn(query, documents, model)
|
|
44
|
+
for doc, score in zip(documents, scores):
|
|
45
|
+
self._scores[self._key(query, doc)] = score
|
|
46
|
+
return scores
|
|
47
|
+
|
|
48
|
+
return rerank
|
|
49
|
+
|
|
50
|
+
def caching(self, rerank_fn):
|
|
51
|
+
def rerank(query: str, documents: list[str], model: str) -> list[float]:
|
|
52
|
+
keys = [self._key(query, doc) for doc in documents]
|
|
53
|
+
unseen: dict[tuple[str, str], str] = {}
|
|
54
|
+
for key, doc in zip(keys, documents):
|
|
55
|
+
if key not in self._scores:
|
|
56
|
+
unseen.setdefault(key, doc)
|
|
57
|
+
if unseen:
|
|
58
|
+
scores = rerank_fn(query, list(unseen.values()), model)
|
|
59
|
+
self._scores.update(zip(unseen.keys(), scores))
|
|
60
|
+
return [self._scores[key] for key in keys]
|
|
61
|
+
|
|
62
|
+
return rerank
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Orchestrates a bench run: for each fixture query, calls all 4 search
|
|
2
|
+
backends against the Store, scores results with pyqmd_mlx.bench._metrics, and
|
|
3
|
+
aggregates per-backend averages. Ported from Node's src/bench/bench.ts,
|
|
4
|
+
minus structured-query support (see design spec) and using pyqmd's own
|
|
5
|
+
existing IR metrics rather than Node's bespoke precision/recall@1,3,5/F1
|
|
6
|
+
set."""
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from functools import partial
|
|
13
|
+
|
|
14
|
+
from pyqmd_mlx.bench._fixture import BenchFixture
|
|
15
|
+
from pyqmd_mlx.bench._metrics import ndcg_at_k, recall_at_k, reciprocal_rank
|
|
16
|
+
from pyqmd_mlx.bench._pathmatch import canonicalize_ranked_ids
|
|
17
|
+
from pyqmd_mlx.bench._sampling import RerankCache, salted_memo
|
|
18
|
+
from pyqmd_mlx.llm import ExpansionModelError
|
|
19
|
+
from pyqmd_mlx.store import Store
|
|
20
|
+
|
|
21
|
+
BACKENDS = ("bm25", "vector", "hybrid", "full")
|
|
22
|
+
# Backends that expand the query, so their scores vary with the seed.
|
|
23
|
+
SAMPLED_BACKENDS = ("hybrid", "full")
|
|
24
|
+
_METRICS = ("recall_at_k", "mrr", "ndcg_at_k")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class BackendScore:
|
|
29
|
+
recall_at_k: float
|
|
30
|
+
mrr: float
|
|
31
|
+
ndcg_at_k: float
|
|
32
|
+
latency_ms: float
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class QueryResult:
|
|
37
|
+
id: str
|
|
38
|
+
query: str
|
|
39
|
+
backends: dict[str, BackendScore] = field(default_factory=dict)
|
|
40
|
+
# bench --samples > 1 only: every draw for SAMPLED_BACKENDS, sample 0
|
|
41
|
+
# first. backends then holds their means (latency from sample 0).
|
|
42
|
+
samples: dict[str, list[BackendScore]] | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class BenchResult:
|
|
47
|
+
fixture: str
|
|
48
|
+
collection: str | None
|
|
49
|
+
timestamp: str
|
|
50
|
+
results: list[QueryResult]
|
|
51
|
+
summary: dict[str, dict[str, float]]
|
|
52
|
+
samples: int = 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _dedupe(paths: list[str]) -> list[str]:
|
|
56
|
+
seen: set[str] = set()
|
|
57
|
+
out = []
|
|
58
|
+
for p in paths:
|
|
59
|
+
if p not in seen:
|
|
60
|
+
seen.add(p)
|
|
61
|
+
out.append(p)
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _run_bm25(store: Store, query: str, limit: int, collection: str | None) -> list[str]:
|
|
66
|
+
return [r.filepath for r in store.search_fts(query, limit=limit, collection=collection)]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run_vector(store: Store, query: str, limit: int, collection: str | None) -> list[str]:
|
|
70
|
+
return [r.filepath for r in store.search_vec(query, limit=limit, collection=collection)]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _run_hybrid(store: Store, query: str, limit: int, collection: str | None) -> list[str]:
|
|
74
|
+
results = store.query(query, limit=limit, collection=collection, skip_rerank=True)
|
|
75
|
+
return [r.file for r in results]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _run_full(store: Store, query: str, limit: int, collection: str | None) -> list[str]:
|
|
79
|
+
results = store.query(query, limit=limit, collection=collection, skip_rerank=False)
|
|
80
|
+
return [r.file for r in results]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_BACKEND_RUNNERS = {
|
|
84
|
+
"bm25": _run_bm25,
|
|
85
|
+
"vector": _run_vector,
|
|
86
|
+
"hybrid": _run_hybrid,
|
|
87
|
+
"full": _run_full,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def check_collection_ready(store: Store, collection: str | None) -> None:
|
|
92
|
+
"""Fail fast, before running any queries, if there's nothing to search
|
|
93
|
+
-- mirrors Node's assertBenchCollectionReady, avoiding a wall of
|
|
94
|
+
all-zero scores when the real problem is an empty/missing index."""
|
|
95
|
+
stats = store.get_collection_document_stats()
|
|
96
|
+
|
|
97
|
+
if collection:
|
|
98
|
+
names = [c["name"] for c in store.list_collections()]
|
|
99
|
+
if collection not in names:
|
|
100
|
+
hint = (
|
|
101
|
+
f"Available: {', '.join(names)}. Run 'pyqmd ls' to inspect."
|
|
102
|
+
if names
|
|
103
|
+
else "Run 'pyqmd ls' to see available collections."
|
|
104
|
+
)
|
|
105
|
+
raise ValueError(f"Collection not found: {collection}\n{hint}")
|
|
106
|
+
if stats.get(collection, {"count": 0})["count"] == 0:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"Collection '{collection}' has no indexed documents.\n"
|
|
109
|
+
f"Run 'pyqmd update', then 'pyqmd ls {collection}' to confirm "
|
|
110
|
+
"files are indexed before bench."
|
|
111
|
+
)
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
total = sum(s["count"] for s in stats.values())
|
|
115
|
+
if total == 0:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
"No indexed documents found.\n"
|
|
118
|
+
"Index a collection with 'pyqmd collection add' / 'pyqmd update' "
|
|
119
|
+
"before running bench."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _score_backend(store: Store, backend_name: str, query, collection: str | None) -> BackendScore:
|
|
124
|
+
limit = max(query.expected_in_top_k, 10)
|
|
125
|
+
start = time.monotonic()
|
|
126
|
+
try:
|
|
127
|
+
raw_paths = _BACKEND_RUNNERS[backend_name](store, query.query, limit, collection)
|
|
128
|
+
except ExpansionModelError:
|
|
129
|
+
# A misconfigured expansion model is a setup error, not one backend
|
|
130
|
+
# being unavailable: scoring it 0 would silently skew every
|
|
131
|
+
# hybrid/full result, so let the command report it and exit.
|
|
132
|
+
raise
|
|
133
|
+
except Exception:
|
|
134
|
+
# A single backend being unavailable (e.g. no embeddings yet) must
|
|
135
|
+
# not abort the whole run -- score it 0 and keep going, matching
|
|
136
|
+
# Node's own try/catch-per-backend resilience.
|
|
137
|
+
return BackendScore(0.0, 0.0, 0.0, (time.monotonic() - start) * 1000)
|
|
138
|
+
|
|
139
|
+
latency_ms = (time.monotonic() - start) * 1000
|
|
140
|
+
ranked_ids = canonicalize_ranked_ids(_dedupe(raw_paths), query.expected_files)
|
|
141
|
+
relevant_ids = set(query.expected_files)
|
|
142
|
+
|
|
143
|
+
return BackendScore(
|
|
144
|
+
recall_at_k=recall_at_k(ranked_ids, relevant_ids, query.expected_in_top_k),
|
|
145
|
+
mrr=reciprocal_rank(ranked_ids, relevant_ids),
|
|
146
|
+
ndcg_at_k=ndcg_at_k(ranked_ids, relevant_ids, query.expected_in_top_k),
|
|
147
|
+
latency_ms=latency_ms,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _compute_summary(query_results: list[QueryResult]) -> dict[str, dict[str, float]]:
|
|
152
|
+
summary: dict[str, dict[str, float]] = {}
|
|
153
|
+
for name in BACKENDS:
|
|
154
|
+
scores = [qr.backends[name] for qr in query_results if name in qr.backends]
|
|
155
|
+
if not scores:
|
|
156
|
+
continue
|
|
157
|
+
summary[name] = {
|
|
158
|
+
"avg_recall_at_k": sum(s.recall_at_k for s in scores) / len(scores),
|
|
159
|
+
"avg_mrr": sum(s.mrr for s in scores) / len(scores),
|
|
160
|
+
"avg_ndcg_at_k": sum(s.ndcg_at_k for s in scores) / len(scores),
|
|
161
|
+
"avg_latency_ms": sum(s.latency_ms for s in scores) / len(scores),
|
|
162
|
+
}
|
|
163
|
+
return summary
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _score_query(store: Store, query, collection: str | None) -> QueryResult:
|
|
167
|
+
backends = {name: _score_backend(store, name, query, collection) for name in BACKENDS}
|
|
168
|
+
return QueryResult(id=query.id, query=query.query, backends=backends)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _mean_score(draws: list[BackendScore]) -> BackendScore:
|
|
172
|
+
"""Metric means across samples; latency from sample 0, the only run
|
|
173
|
+
with no cache hits."""
|
|
174
|
+
n = len(draws)
|
|
175
|
+
return BackendScore(
|
|
176
|
+
recall_at_k=sum(d.recall_at_k for d in draws) / n,
|
|
177
|
+
mrr=sum(d.mrr for d in draws) / n,
|
|
178
|
+
ndcg_at_k=sum(d.ndcg_at_k for d in draws) / n,
|
|
179
|
+
latency_ms=draws[0].latency_ms,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _sample_spread(query_results: list[QueryResult], samples: int) -> dict[str, dict[str, float]]:
|
|
184
|
+
"""min_*/max_* of each sample's fixture-wide average. Per-query min/max
|
|
185
|
+
would just be 0..1; this is the spread that tells a real difference
|
|
186
|
+
between two configurations from seed noise."""
|
|
187
|
+
spread: dict[str, dict[str, float]] = {}
|
|
188
|
+
if not query_results:
|
|
189
|
+
return spread
|
|
190
|
+
for name in SAMPLED_BACKENDS:
|
|
191
|
+
entry: dict[str, float] = {}
|
|
192
|
+
for metric in _METRICS:
|
|
193
|
+
averages = [
|
|
194
|
+
sum(getattr(qr.samples[name][i], metric) for qr in query_results)
|
|
195
|
+
/ len(query_results)
|
|
196
|
+
for i in range(samples)
|
|
197
|
+
]
|
|
198
|
+
entry[f"min_{metric}"] = min(averages)
|
|
199
|
+
entry[f"max_{metric}"] = max(averages)
|
|
200
|
+
spread[name] = entry
|
|
201
|
+
return spread
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _run_samples(
|
|
205
|
+
store: Store,
|
|
206
|
+
fixture: BenchFixture,
|
|
207
|
+
collection: str | None,
|
|
208
|
+
samples: int,
|
|
209
|
+
on_sample: Callable[[int, int], None] | None,
|
|
210
|
+
) -> list[QueryResult]:
|
|
211
|
+
"""Sample 0 is a plain run whose rerank scores are recorded; samples
|
|
212
|
+
1..N-1 re-run SAMPLED_BACKENDS with seed salt str(i) and cached rerank
|
|
213
|
+
scores (see docs/specs/2026-09-25-bench-samples-design.md)."""
|
|
214
|
+
rerank_cache = RerankCache()
|
|
215
|
+
|
|
216
|
+
if on_sample:
|
|
217
|
+
on_sample(1, samples)
|
|
218
|
+
with store.wrapping_llm_fns(rerank=rerank_cache.recording):
|
|
219
|
+
query_results = [_score_query(store, q, collection) for q in fixture.queries]
|
|
220
|
+
for qr in query_results:
|
|
221
|
+
qr.samples = {name: [qr.backends[name]] for name in SAMPLED_BACKENDS}
|
|
222
|
+
|
|
223
|
+
for i in range(1, samples):
|
|
224
|
+
if on_sample:
|
|
225
|
+
on_sample(i + 1, samples)
|
|
226
|
+
with store.wrapping_llm_fns(
|
|
227
|
+
expand=partial(salted_memo, salt=str(i)), rerank=rerank_cache.caching
|
|
228
|
+
):
|
|
229
|
+
for query, qr in zip(fixture.queries, query_results):
|
|
230
|
+
for name in SAMPLED_BACKENDS:
|
|
231
|
+
qr.samples[name].append(_score_backend(store, name, query, collection))
|
|
232
|
+
|
|
233
|
+
for qr in query_results:
|
|
234
|
+
for name in SAMPLED_BACKENDS:
|
|
235
|
+
qr.backends[name] = _mean_score(qr.samples[name])
|
|
236
|
+
return query_results
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def run_benchmark(
|
|
240
|
+
store: Store,
|
|
241
|
+
fixture: BenchFixture,
|
|
242
|
+
collection: str | None,
|
|
243
|
+
samples: int = 1,
|
|
244
|
+
on_sample: Callable[[int, int], None] | None = None,
|
|
245
|
+
) -> BenchResult:
|
|
246
|
+
if samples == 1:
|
|
247
|
+
query_results = [_score_query(store, q, collection) for q in fixture.queries]
|
|
248
|
+
summary = _compute_summary(query_results)
|
|
249
|
+
else:
|
|
250
|
+
query_results = _run_samples(store, fixture, collection, samples, on_sample)
|
|
251
|
+
# Over the per-query means, avg_* is also the mean of the per-sample
|
|
252
|
+
# fixture averages (every sample scores the same queries).
|
|
253
|
+
summary = _compute_summary(query_results)
|
|
254
|
+
for name, spread in _sample_spread(query_results, samples).items():
|
|
255
|
+
summary[name].update(spread)
|
|
256
|
+
|
|
257
|
+
return BenchResult(
|
|
258
|
+
fixture="",
|
|
259
|
+
collection=collection,
|
|
260
|
+
timestamp=datetime.now(UTC).isoformat(),
|
|
261
|
+
results=query_results,
|
|
262
|
+
summary=summary,
|
|
263
|
+
samples=samples,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def all_zero(summary: dict[str, dict[str, float]]) -> bool:
|
|
268
|
+
if not summary:
|
|
269
|
+
return False
|
|
270
|
+
return all(
|
|
271
|
+
s["avg_recall_at_k"] == 0 and s["avg_mrr"] == 0 and s["avg_ndcg_at_k"] == 0
|
|
272
|
+
for s in summary.values()
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def result_to_dict(result: BenchResult) -> dict:
|
|
277
|
+
d = asdict(result)
|
|
278
|
+
if result.samples == 1:
|
|
279
|
+
# --samples 1 JSON stays byte-identical to before the flag existed.
|
|
280
|
+
del d["samples"]
|
|
281
|
+
for qr in d["results"]:
|
|
282
|
+
del qr["samples"]
|
|
283
|
+
return d
|
|
File without changes
|
pyqmd_mlx/cli/_db.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""DB path resolution and Store lifecycle for the pyqmd CLI.
|
|
2
|
+
|
|
3
|
+
Uses its own cache directory (~/.cache/pyqmd/) distinct from the live Node
|
|
4
|
+
CLI's ~/.cache/qmd/, so the two tools can coexist during the transition
|
|
5
|
+
without any risk of one clobbering the other's database.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pyqmd_mlx.store import Store
|
|
12
|
+
|
|
13
|
+
DEFAULT_DB_PATH = Path.home() / ".cache" / "pyqmd" / "index.sqlite"
|
|
14
|
+
|
|
15
|
+
# Lets a user point the CLI at a project-local or test index without any
|
|
16
|
+
# command needing a --db option: every command already calls get_store()
|
|
17
|
+
# with no arguments, so this env var is checked transparently here.
|
|
18
|
+
DB_PATH_ENV_VAR = "PYQMD_DB"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_store(db_path: str | None = None) -> Store:
|
|
22
|
+
"""Open (creating parent directories if needed) the pyqmd database and
|
|
23
|
+
return a Store. Resolution order: explicit `db_path` argument, then the
|
|
24
|
+
PYQMD_DB environment variable, then DEFAULT_DB_PATH."""
|
|
25
|
+
path = Path(db_path or os.environ.get(DB_PATH_ENV_VAR) or DEFAULT_DB_PATH)
|
|
26
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
return Store(str(path))
|
pyqmd_mlx/cli/_errors.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Shared error handling for CLI commands: catch expected Store-boundary
|
|
2
|
+
exceptions (bad input, missing resource) and exit cleanly with a one-line
|
|
3
|
+
message; let anything else propagate with its real traceback, matching the
|
|
4
|
+
original CLI's exitWithError pattern -- report the error, but never
|
|
5
|
+
silently swallow a real bug."""
|
|
6
|
+
|
|
7
|
+
import sqlite3
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import TypeVar
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from pyqmd_mlx.llm import ExpansionModelError
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
EXPECTED_EXCEPTIONS = (
|
|
18
|
+
sqlite3.IntegrityError,
|
|
19
|
+
ValueError,
|
|
20
|
+
FileNotFoundError,
|
|
21
|
+
NotADirectoryError,
|
|
22
|
+
ExpansionModelError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CliError(Exception):
|
|
27
|
+
"""An expected, user-facing failure whose stderr text is already shaped
|
|
28
|
+
to match Node qmd's: one or more lines (optionally styled via
|
|
29
|
+
pyqmd_mlx.cli._theme), printed as-is with no "Error: " prefix. Raise it
|
|
30
|
+
inside a run_or_exit callable."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, *lines: str) -> None:
|
|
33
|
+
if not lines:
|
|
34
|
+
raise ValueError("CliError needs at least one line")
|
|
35
|
+
super().__init__("\n".join(lines))
|
|
36
|
+
self.lines = lines
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_or_exit(fn: Callable[[], T]) -> T:
|
|
40
|
+
try:
|
|
41
|
+
return fn()
|
|
42
|
+
except CliError as exc:
|
|
43
|
+
for line in exc.lines:
|
|
44
|
+
typer.echo(line, err=True)
|
|
45
|
+
raise typer.Exit(1) from exc
|
|
46
|
+
except EXPECTED_EXCEPTIONS as exc:
|
|
47
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
48
|
+
raise typer.Exit(1) from exc
|