Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .agents/pm/decisions/pm-f2al.toon
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
id: pm-f2al
title: "ADR: offline BM25 lexical provider + relevance eval harness for search quality"
description: ""
type: Decision
status: open
priority: 2
tags[3]: adr,"area:search","area:semantic"
created_at: "2026-06-20T05:29:45.329Z"
updated_at: "2026-06-20T05:29:45.329Z"
author: claude-code-agent
parent: pm-ydp6
dependencies[2]{id,kind,created_at,author,source_kind}:
pm-75k9,related,"2026-06-20T05:29:45.329Z",claude-code-agent,null
pm-u8n5,related,"2026-06-20T05:29:45.329Z",claude-code-agent,null
body: "## Context\n\nSemantic/hybrid search required an embedding service (Ollama/OpenAI) + vector store, leaving air-gapped/CI/zero-setup users on the naive field-weighted keyword scorer. There was also no offline way to MEASURE retrieval quality, so relevance regressions went undetected.\n\n## Decision\n\n1. Ship a built-in **BM25** lexical ranker (Okapi BM25: IDF + k1 saturation + b length-normalization) that runs entirely in-process over the existing search corpus. It activates for --semantic/--hybrid when search.provider=bm25 (always) or auto (offline fallback only when no embedding/extension provider). Plain keyword mode is unchanged.\n2. Ship **pm eval**: an nDCG@k / MRR@k / precision@k / recall@k runner over a git-tracked golden-query set, with --fail-under as a CI gate.\n\n## Rationale\n\n- BM25 is a strong, dependency-free lexical baseline — far better than raw term counts, available with zero setup, and reuses the configurable search.corpus_fields. It is honestly tagged matched_fields:[bm25] and documented as below dense retrieval.\n- Reusing combineHybridHits (with a dense-component label param) and forceExactIdHitsToTop kept BM25 consistent with the vector path (exact-ID guarantee preserved) and minimized new branches.\n- A binary-relevance golden set is cheap to curate yet supports DCG's positional discount; --fail-under makes relevance a build-gateable signal. The eval harness is the validation vehicle for BM25 and any future provider/weight change.\n\n## Alternatives considered\n\n- Bundling a WASM embedding model for true offline dense retrieval: heavy artifact, deferred. BM25 is the pragmatic first step (matches pm-75k9 acceptance).\n- A new blocking CI workflow job: deferred in favor of the reusable --fail-under primitive (CI can adopt it without a curated corpus dependency in core CI).\n\n## Consequences\n\nsearch.provider now recognizes bm25/auto; search.bm25.{k1,b} are validated/persisted knobs; eval-queries.json lives at <pmRoot>/search/ (gitignore negation keeps the cache dir ignored). pm eval is a core command (contracts + help + fixture)."
18 changes: 15 additions & 3 deletions .agents/pm/features/pm-75k9.toon
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
id: pm-75k9
title: "Offline/fallback embedding provider: built-in TF-IDF or BM25 when no Ollama/OpenAI is configured"
title: Offline BM25 lexical retrieval provider when no Ollama/OpenAI is configured
description: "When no embedding provider is configured, pm search falls back to keyword-only mode silently. There is no built-in dense-retrieval fallback that works without a running Ollama instance or OpenAI key. A lightweight offline provider (TF-IDF, BM25, or a tiny WASM embedding model) would enable semantic-quality search in air-gapped or no-setup environments."
type: Feature
status: open
status: closed
priority: 3
tags[4]: "area:search",embedding,offline,ux
created_at: "2026-06-07T10:05:12.215Z"
updated_at: "2026-06-07T10:16:00.726Z"
updated_at: "2026-06-20T05:44:42.879Z"
author: audit-data-agent
acceptance_criteria: "- Add a built-in `bm25` search provider that can be activated with `pm config set search.provider bm25`\n- BM25 operates entirely on the front-matter cache, requiring no external service\n- The provider registers as a fallback when `search.provider = auto` and no HTTP provider is configured\n- Quality is clearly documented as lower than dense retrieval\n- Extension authors can override with `registerSearchProvider`"
value: Enables semantic-quality search for new users and CI pipelines without requiring an embedding service
parent: pm-ydp6
risk: medium
confidence: medium
resolution: Built-in BM25 offline provider shipped and validated end-to-end.
dependencies[1]{id,kind,created_at,author,source_kind}:
pm-u8n5,related,"2026-06-20T05:19:46.714Z",claude-code-agent,null
comments[1]{created_at,author,text}:
"2026-06-20T05:19:47.094Z",claude-code-agent,"Shipped built-in offline BM25 lexical retrieval (src/core/search/bm25.ts): Okapi BM25 with IDF + k1 term-frequency saturation + b length-normalization, scoring the in-memory corpus with NO embedding service or vector store. Activated via search.provider=bm25 (always) or auto (offline fallback only when no embedding/extension provider). Wired into runSearch semantic/hybrid: semantic returns pure BM25 hits (matched_fields:[bm25]); hybrid blends BM25 with the keyword scorer via combineHybridHits. Tuning: search.bm25.k1 (default 1.2) and search.bm25.b (default 0.75), settable via pm config set search_bm25_k1/b and validated/persisted. Keyword mode unchanged. 100% coverage; full manual sandbox verification."
files[2]{path,scope,note}:
src/core/search/bm25.ts,project,Offline BM25 index + scorer + param resolution
src/cli/commands/search.ts,project,BM25 activation + computeBuiltInBm25Hits wiring
docs[2]{path,scope,note}:
docs/COMMANDS.md,project,Offline BM25 provider docs
docs/CONFIGURATION.md,project,search.provider bm25 + search.bm25 knobs
close_reason: "Implemented built-in offline BM25 lexical provider (search.provider=bm25|auto) powering --semantic/--hybrid with no embedding service or vector store. New pure module src/core/search/bm25.ts (Okapi BM25: IDF + k1 saturation + b length-normalization), wired into runSearch via resolveBuiltInBm25Mode + computeBuiltInBm25Hits; combineHybridHits gained a dense-component label so BM25 hybrid hits read matched_fields:[bm25]. Config: search.bm25.{k1,b} validated/persisted + pm config set search_bm25_k1/b. Docs in COMMANDS.md + CONFIGURATION.md. 100/100/100/100 coverage; sandbox-verified."
body: "## Context\n\n`src/core/search/providers.ts:182-201` — `resolveEmbeddingProviders` returns `active: null` when no openai/ollama is configured. The search command silently degrades to keyword.\n\nFor CI-only, air-gapped, or minimal-setup projects the semantic search path is completely unavailable. The extension SDK allows `registerSearchProvider` but there is no built-in offline fallback.\n\nThis is a post-v0.1 feature (complexity of bundling a WASM embedding model is non-trivial) but a BM25 implementation would be feasible as a first step.\n\n## Acceptance criteria\n\n- [ ] Add a built-in `bm25` search provider that can be activated with `pm config set search.provider bm25`\n- [ ] BM25 operates entirely on the front-matter cache, requiring no external service\n- [ ] The provider registers as a fallback when `search.provider = auto` and no HTTP provider is configured\n- [ ] Quality is clearly documented as lower than dense retrieval\n- [ ] Extension authors can override with `registerSearchProvider`\n\n## Evidence\n\n- `src/core/search/providers.ts:182-201` — only openai/ollama; no offline fallback\n- `src/core/extensions/loader.ts:1930` — `registerSearchProvider` exists in SDK; could be extended for built-in BM25"
17 changes: 14 additions & 3 deletions .agents/pm/features/pm-u8n5.toon
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,28 @@ id: pm-u8n5
title: "Search relevance eval harness: implement nDCG/MRR runner and gate in CI"
description: "pm-22x2 and pm-fhsg (golden-query eval harness) are closed but the harness was filed as a task/story, not necessarily shipped as a CI gate. There is no actual nDCG/MRR runner in src/core/search/ and no CI job measuring search quality regressions. This means relevance regressions from corpus changes, hybrid weight changes, or provider upgrades go undetected."
type: Feature
status: open
status: closed
priority: 2
tags[5]: "area:search",ci,eval,quality,relevance
created_at: "2026-06-07T10:03:46.324Z"
updated_at: "2026-06-07T10:22:44.153Z"
updated_at: "2026-06-20T05:29:25.899Z"
author: audit-data-agent
acceptance_criteria: "- `pm eval` command or `pm reindex --eval` subcommand runs golden-query set against current index\n- Outputs nDCG@10, MRR@10 per query and aggregate\n- Golden queries stored in `.agents/pm/search/eval-queries.json` (gittracked, human-curated)\n- CI gate fails if nDCG drops below baseline by more than epsilon\n- Supports both keyword and hybrid modes"
parent: pm-ydp6
risk: medium
confidence: medium
dependencies[2]{id,kind,created_at,author,source_kind}:
resolution: Search relevance eval harness (pm eval) shipped with nDCG/MRR runner + CI gate.
dependencies[3]{id,kind,created_at,author,source_kind}:
pm-22x2,related,"2026-06-07T10:22:44.143Z",null,null
pm-fhsg,related,"2026-06-07T10:22:44.143Z",null,null
pm-75k9,related,"2026-06-20T05:29:24.847Z",claude-code-agent,null
comments[1]{created_at,author,text}:
"2026-06-20T05:29:25.216Z",claude-code-agent,"Shipped the relevance eval harness as a first-class pm eval command. Pure metrics module src/core/search/eval.ts (nDCG@k, MRR@k, precision@k, recall@k over a binary-relevance golden set + parseEvalQuerySet validator). Command src/cli/commands/eval.ts loads <pmRoot>/search/eval-queries.json (git-tracked; gitignore negation added so the cache dir stays ignored), runs each query through the live search path, reports per-query + macro-aggregate metrics, and --fail-under turns it into a CI gate (prints report, exits non-zero on regression). Registered as a core command (enum-contracts + EVAL_FLAG_CONTRACTS + help-content + contracts fixture). Curated a 4-query golden set for this repo (aggregate nDCG 0.74). Builds on the now-superseded pm-22x2/pm-fhsg eval items. 100% coverage; sandbox-verified gate pass+fail."
files[3]{path,scope,note}:
src/core/search/eval.ts,project,nDCG/MRR/precision/recall metrics + golden-set validation
src/cli/commands/eval.ts,project,pm eval runner + --fail-under CI gate
.agents/pm/search/eval-queries.json,project,Curated golden-query set for this repo
docs[1]{path,scope,note}:
docs/COMMANDS.md,project,pm eval command docs
close_reason: "Implemented pm eval: an nDCG@k/MRR@k/precision@k/recall@k relevance runner over a git-tracked golden-query set (<pmRoot>/search/eval-queries.json), with --fail-under as a CI gate (non-zero exit on aggregate-nDCG regression). New pure module src/core/search/eval.ts + command src/cli/commands/eval.ts, registered as a core command (enum-contracts, EVAL_FLAG_CONTRACTS, help-content, contracts fixture). Validates the offline BM25 provider (pm-75k9) and any future provider/weight change. Docs in COMMANDS.md. 100/100/100/100 coverage; sandbox-verified."
body: "## Context\n\nSearch eval harness items pm-22x2 and pm-fhsg are closed. However, a grep for 'ndcg', 'mrr', 'precision', 'recall', or 'ground_truth' in src/ finds no results. The closed items may have been closed as 'deferred/post-v0.1' rather than shipped.\n\nThe `src/core/search/relevance.ts` module has the scoring primitives (cosine similarity) but no offline evaluation path.\n\n## Acceptance criteria\n\n- [ ] `pm eval` command or `pm reindex --eval` subcommand runs golden-query set against current index\n- [ ] Outputs nDCG@10, MRR@10 per query and aggregate\n- [ ] Golden queries stored in `.agents/pm/search/eval-queries.json` (gittracked, human-curated)\n- [ ] CI gate fails if nDCG drops below baseline by more than epsilon\n- [ ] Supports both keyword and hybrid modes\n\n## Evidence\n\n- `src/core/search/relevance.ts` — cosine similarity + rerank helpers but no eval runner\n- pm-22x2, pm-fhsg closed — check if actually shipped or deferred\n- No `eval` command in `src/cli/commands/`"
5 changes: 5 additions & 0 deletions .agents/pm/history/pm-75k9.jsonl
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
{"ts":"2026-06-07T10:05:12.215Z","author":"audit-data-agent","op":"create","patch":[{"op":"replace","path":"/body","value":"## Context\n\n`src/core/search/providers.ts:182-201` — `resolveEmbeddingProviders` returns `active: null` when no openai/ollama is configured. The search command silently degrades to keyword.\n\nFor CI-only, air-gapped, or minimal-setup projects the semantic search path is completely unavailable. The extension SDK allows `registerSearchProvider` but there is no built-in offline fallback.\n\nThis is a post-v0.1 feature (complexity of bundling a WASM embedding model is non-trivial) but a BM25 implementation would be feasible as a first step.\n\n## Acceptance criteria\n\n- [ ] Add a built-in `bm25` search provider that can be activated with `pm config set search.provider bm25`\n- [ ] BM25 operates entirely on the front-matter cache, requiring no external service\n- [ ] The provider registers as a fallback when `search.provider = auto` and no HTTP provider is configured\n- [ ] Quality is clearly documented as lower than dense retrieval\n- [ ] Extension authors can override with `registerSearchProvider`\n\n## Evidence\n\n- `src/core/search/providers.ts:182-201` — only openai/ollama; no offline fallback\n- `src/core/extensions/loader.ts:1930` — `registerSearchProvider` exists in SDK; could be extended for built-in BM25"},{"op":"add","path":"/metadata/id","value":"pm-75k9"},{"op":"add","path":"/metadata/title","value":"Offline/fallback embedding provider: built-in TF-IDF or BM25 when no Ollama/OpenAI is configured"},{"op":"add","path":"/metadata/description","value":"When no embedding provider is configured, pm search falls back to keyword-only mode silently. There is no built-in dense-retrieval fallback that works without a running Ollama instance or OpenAI key. A lightweight offline provider (TF-IDF, BM25, or a tiny WASM embedding model) would enable semantic-quality search in air-gapped or no-setup environments."},{"op":"add","path":"/metadata/type","value":"Feature"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":3},{"op":"add","path":"/metadata/tags","value":["area:search","embedding","offline","ux"]},{"op":"add","path":"/metadata/created_at","value":"2026-06-07T10:05:12.215Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-06-07T10:05:12.215Z"},{"op":"add","path":"/metadata/author","value":"audit-data-agent"},{"op":"add","path":"/metadata/value","value":"Enables semantic-quality search for new users and CI pipelines without requiring an embedding service"},{"op":"add","path":"/metadata/parent","value":"pm-ydp6"},{"op":"add","path":"/metadata/risk","value":"medium"},{"op":"add","path":"/metadata/confidence","value":"medium"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"6290a952c733c56f4a6cb6089b5124161b50895364d8bfec6ba36c61546ade16","message":""}
{"ts":"2026-06-07T10:16:00.726Z","author":"maintainer-agent","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-06-07T10:16:00.726Z"},{"op":"add","path":"/metadata/acceptance_criteria","value":"- Add a built-in `bm25` search provider that can be activated with `pm config set search.provider bm25`\n- BM25 operates entirely on the front-matter cache, requiring no external service\n- The provider registers as a fallback when `search.provider = auto` and no HTTP provider is configured\n- Quality is clearly documented as lower than dense retrieval\n- Extension authors can override with `registerSearchProvider`"}],"before_hash":"6290a952c733c56f4a6cb6089b5124161b50895364d8bfec6ba36c61546ade16","after_hash":"f2447babf0113cd08a12c1f678f3da6b2e51ed4760fa71a5864904d6a8c25897","message":"Backfill structured acceptance_criteria from body (best-practice/dogfooding)"}
{"ts":"2026-06-20T04:22:35.284Z","author":"claude-code-agent","op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-06-20T04:22:35.284Z"},{"op":"add","path":"/metadata/assignee","value":"claude-code-agent"}],"before_hash":"f2447babf0113cd08a12c1f678f3da6b2e51ed4760fa71a5864904d6a8c25897","after_hash":"89cc615568b3c90acd237faeed91dd15185ea324043e541f0efe7952112e4a5d"}
{"ts":"2026-06-20T05:19:46.730Z","author":"claude-code-agent","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-06-20T05:19:46.730Z"},{"op":"add","path":"/metadata/dependencies","value":[{"id":"pm-u8n5","kind":"related","created_at":"2026-06-20T05:19:46.714Z","author":"claude-code-agent"}]},{"op":"add","path":"/metadata/files","value":[{"path":"src/core/search/bm25.ts","scope":"project","note":"Offline BM25 index + scorer + param resolution"},{"path":"src/cli/commands/search.ts","scope":"project","note":"BM25 activation + computeBuiltInBm25Hits wiring"}]},{"op":"add","path":"/metadata/docs","value":[{"path":"docs/COMMANDS.md","scope":"project","note":"Offline BM25 provider docs"},{"path":"docs/CONFIGURATION.md","scope":"project","note":"search.provider bm25 + search.bm25 knobs"}]}],"before_hash":"89cc615568b3c90acd237faeed91dd15185ea324043e541f0efe7952112e4a5d","after_hash":"a810aabab46c62d403bcec0a3b0acbc519f4f489317796e8a9de3ad25f8a31eb"}
{"ts":"2026-06-20T05:19:47.094Z","author":"claude-code-agent","op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-06-20T05:19:47.094Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-06-20T05:19:47.094Z","author":"claude-code-agent","text":"Shipped built-in offline BM25 lexical retrieval (src/core/search/bm25.ts): Okapi BM25 with IDF + k1 term-frequency saturation + b length-normalization, scoring the in-memory corpus with NO embedding service or vector store. Activated via search.provider=bm25 (always) or auto (offline fallback only when no embedding/extension provider). Wired into runSearch semantic/hybrid: semantic returns pure BM25 hits (matched_fields:[bm25]); hybrid blends BM25 with the keyword scorer via combineHybridHits. Tuning: search.bm25.k1 (default 1.2) and search.bm25.b (default 0.75), settable via pm config set search_bm25_k1/b and validated/persisted. Keyword mode unchanged. 100% coverage; full manual sandbox verification."}]}],"before_hash":"a810aabab46c62d403bcec0a3b0acbc519f4f489317796e8a9de3ad25f8a31eb","after_hash":"f094b44558d65719f6da1cb190985b98ef00a5ce6475958440cd5735ce54ef57"}
{"ts":"2026-06-20T05:19:55.632Z","author":"claude-code-agent","op":"close","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-06-20T05:19:55.632Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/resolution","value":"Built-in BM25 offline provider shipped and validated end-to-end."},{"op":"add","path":"/metadata/close_reason","value":"Implemented built-in offline BM25 lexical provider (search.provider=bm25|auto) powering --semantic/--hybrid with no embedding service or vector store. New pure module src/core/search/bm25.ts (Okapi BM25: IDF + k1 saturation + b length-normalization), wired into runSearch via resolveBuiltInBm25Mode + computeBuiltInBm25Hits; combineHybridHits gained a dense-component label so BM25 hybrid hits read matched_fields:[bm25]. Config: search.bm25.{k1,b} validated/persisted + pm config set search_bm25_k1/b. Docs in COMMANDS.md + CONFIGURATION.md. 100/100/100/100 coverage; sandbox-verified."}],"before_hash":"f094b44558d65719f6da1cb190985b98ef00a5ce6475958440cd5735ce54ef57","after_hash":"74d1f82008e8738dab29bc814595172f99c5188ee77f00028085cc28d2884b11"}
{"ts":"2026-06-20T05:44:42.879Z","author":"claude-code-agent","op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-06-20T05:44:42.879Z"},{"op":"replace","path":"/metadata/title","value":"Offline BM25 lexical retrieval provider when no Ollama/OpenAI is configured"}],"before_hash":"74d1f82008e8738dab29bc814595172f99c5188ee77f00028085cc28d2884b11","after_hash":"252deefe0c25799b123f6cc88aec1726956a63bc78ff82a2ce41b2754b94a0c6"}
Loading