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.
Files changed (77) hide show
  1. pyqmd_mlx/__init__.py +0 -0
  2. pyqmd_mlx/bench/__init__.py +0 -0
  3. pyqmd_mlx/bench/_fixture.py +82 -0
  4. pyqmd_mlx/bench/_metrics.py +101 -0
  5. pyqmd_mlx/bench/_pathmatch.py +60 -0
  6. pyqmd_mlx/bench/_sampling.py +62 -0
  7. pyqmd_mlx/bench/runner.py +283 -0
  8. pyqmd_mlx/cli/__init__.py +0 -0
  9. pyqmd_mlx/cli/_db.py +27 -0
  10. pyqmd_mlx/cli/_errors.py +48 -0
  11. pyqmd_mlx/cli/_format.py +95 -0
  12. pyqmd_mlx/cli/_fullpath.py +44 -0
  13. pyqmd_mlx/cli/_index_report.py +25 -0
  14. pyqmd_mlx/cli/_lineparse.py +30 -0
  15. pyqmd_mlx/cli/_metadata_filter.py +34 -0
  16. pyqmd_mlx/cli/_multiget.py +82 -0
  17. pyqmd_mlx/cli/_output_documents.py +146 -0
  18. pyqmd_mlx/cli/_output_search.py +247 -0
  19. pyqmd_mlx/cli/_progress.py +102 -0
  20. pyqmd_mlx/cli/_snippet.py +99 -0
  21. pyqmd_mlx/cli/_theme.py +27 -0
  22. pyqmd_mlx/cli/_types.py +55 -0
  23. pyqmd_mlx/cli/app.py +89 -0
  24. pyqmd_mlx/cli/commands/__init__.py +0 -0
  25. pyqmd_mlx/cli/commands/bench.py +170 -0
  26. pyqmd_mlx/cli/commands/cleanup.py +63 -0
  27. pyqmd_mlx/cli/commands/collection.py +220 -0
  28. pyqmd_mlx/cli/commands/context.py +115 -0
  29. pyqmd_mlx/cli/commands/documents.py +173 -0
  30. pyqmd_mlx/cli/commands/embed.py +83 -0
  31. pyqmd_mlx/cli/commands/mcp.py +67 -0
  32. pyqmd_mlx/cli/commands/pull.py +39 -0
  33. pyqmd_mlx/cli/commands/search.py +154 -0
  34. pyqmd_mlx/cli/commands/skill.py +171 -0
  35. pyqmd_mlx/cli/commands/skills.py +324 -0
  36. pyqmd_mlx/cli/commands/status.py +153 -0
  37. pyqmd_mlx/cli/commands/update.py +182 -0
  38. pyqmd_mlx/llm/__init__.py +23 -0
  39. pyqmd_mlx/llm/_cache.py +12 -0
  40. pyqmd_mlx/llm/_constants.py +15 -0
  41. pyqmd_mlx/llm/_expand_grammar.py +155 -0
  42. pyqmd_mlx/llm/_hub.py +43 -0
  43. pyqmd_mlx/llm/_prompts.py +60 -0
  44. pyqmd_mlx/llm/embed.py +43 -0
  45. pyqmd_mlx/llm/expand.py +195 -0
  46. pyqmd_mlx/llm/rerank.py +59 -0
  47. pyqmd_mlx/mcp/__init__.py +0 -0
  48. pyqmd_mlx/mcp/_errors.py +34 -0
  49. pyqmd_mlx/mcp/_formatting.py +52 -0
  50. pyqmd_mlx/mcp/_instructions.py +56 -0
  51. pyqmd_mlx/mcp/server.py +756 -0
  52. pyqmd_mlx/skills/pyqmd/SKILL.md +152 -0
  53. pyqmd_mlx/skills/pyqmd/references/mcp-setup.md +132 -0
  54. pyqmd_mlx/skills/pyqmd-bench/SKILL.md +71 -0
  55. pyqmd_mlx/skills/pyqmd-librarian/SKILL.md +52 -0
  56. pyqmd_mlx/skills/pyqmd-researcher/SKILL.md +43 -0
  57. pyqmd_mlx/store/__init__.py +3 -0
  58. pyqmd_mlx/store/_ast.py +333 -0
  59. pyqmd_mlx/store/_ast_queries.py +94 -0
  60. pyqmd_mlx/store/_chunking.py +234 -0
  61. pyqmd_mlx/store/_expansion.py +44 -0
  62. pyqmd_mlx/store/_fts_query.py +109 -0
  63. pyqmd_mlx/store/_indexing.py +186 -0
  64. pyqmd_mlx/store/_intent.py +112 -0
  65. pyqmd_mlx/store/_metadata.py +242 -0
  66. pyqmd_mlx/store/_metadata_filter.py +362 -0
  67. pyqmd_mlx/store/_rrf.py +53 -0
  68. pyqmd_mlx/store/_schema.py +143 -0
  69. pyqmd_mlx/store/_types.py +71 -0
  70. pyqmd_mlx/store/_yaml_alias_guard.py +33 -0
  71. pyqmd_mlx/store/store.py +1721 -0
  72. pyqmd_mlx/version.py +10 -0
  73. pyqmd_mlx-0.7.0.dist-info/METADATA +98 -0
  74. pyqmd_mlx-0.7.0.dist-info/RECORD +77 -0
  75. pyqmd_mlx-0.7.0.dist-info/WHEEL +4 -0
  76. pyqmd_mlx-0.7.0.dist-info/entry_points.txt +2 -0
  77. 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))
@@ -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