---
phase: 02-search-core
plan: 02
type: tdd
wave: 2
depends_on:
  - 02-01
files_modified:
  - src/corpus_analyzer/search/engine.py
  - tests/search/test_engine.py
autonomous: true
requirements:
  - SEARCH-01
  - SEARCH-02
  - SEARCH-03
  - SEARCH-04
  - SEARCH-05
  - CLASS-02
  - CLASS-03
  - SUMM-02
  - CLI-02
  - CLI-03
  - CLI-04

must_haves:
  truths:
    - "User can call hybrid_search(query) and receive a list of result dicts with file_path, text, construct_type, summary, and _relevance_score"
    - "Results are fused from vector + BM25 via RRFReranker (not manual scoring)"
    - "Filters for source, file_type, and construct_type chain as AND predicates via .where() SQL"
    - "status() returns dict with files, chunks, last_indexed, model keys"
    - "Zero-result queries return empty list without raising an exception"
  artifacts:
    - path: "src/corpus_analyzer/search/engine.py"
      provides: "CorpusSearch class with hybrid_search() and status() methods"
      exports: ["CorpusSearch"]
      min_lines: 80
    - path: "tests/search/test_engine.py"
      provides: "Passing tests for all CorpusSearch behaviors"
  key_links:
    - from: "tests/search/test_engine.py"
      to: "src/corpus_analyzer/search/engine.py"
      via: "CorpusSearch imported and tested with real LanceDB in tmp_path + mock embedder"
      pattern: "from corpus_analyzer\\.search\\.engine import CorpusSearch"
    - from: "src/corpus_analyzer/search/engine.py"
      to: "lancedb.rerankers.RRFReranker"
      via: "table.search(query, query_type='hybrid').rerank(RRFReranker())"
      pattern: "RRFReranker"
    - from: "src/corpus_analyzer/search/engine.py"
      to: "src/corpus_analyzer/ingest/embedder.py"
      via: "OllamaEmbedder.embed_batch([query])[0] to get query vector"
      pattern: "embed_batch"
---

<objective>
TDD implementation of CorpusSearch — the hybrid BM25+vector search engine using LanceDB's native RRFReranker.

Purpose: Provides the core query capability for Phase 2. All search CLI commands and the MCP server (Phase 3) build on top of this class.

Output: Working, tested CorpusSearch with hybrid_search() and status() methods.
</objective>

<execution_context>
@/home/ollie/.claude/get-shit-done/workflows/execute-plan.md
@/home/ollie/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/phases/02-search-core/02-CONTEXT.md
@.planning/phases/02-search-core/02-RESEARCH.md
@.planning/phases/02-search-core/02-01-SUMMARY.md
@src/corpus_analyzer/store/schema.py
@src/corpus_analyzer/ingest/embedder.py
@src/corpus_analyzer/ingest/indexer.py
@tests/search/test_engine.py
</context>

<feature>
  <name>CorpusSearch hybrid search engine</name>
  <files>src/corpus_analyzer/search/engine.py, tests/search/test_engine.py</files>
  <behavior>
    CorpusSearch wraps a LanceDB table and OllamaEmbedder, providing:

    hybrid_search(query, source=None, file_type=None, construct_type=None, limit=10):
      - Embeds query with OllamaEmbedder.embed_batch([query])[0]
      - Calls table.search(query_text, vector=query_vec, query_type="hybrid").rerank(RRFReranker())
      - Chains .where() SQL predicates for each non-None filter (AND semantics)
      - Validates filter values: alphanumeric + hyphens + underscores + dots only; raises ValueError on invalid
      - Returns list of dicts with all ChunkRecord fields + _relevance_score
      - Empty results return [] not raise
      - construct_type None values should be preserved as-is (formatter handles display fallback)

    status(embedding_model: str):
      - Queries table for file count (distinct file_path), chunk count, max indexed_at, first embedding_model
      - Returns {"files": int, "chunks": int, "last_indexed": str, "model": str}
      - Empty table returns {"files": 0, "chunks": 0, "last_indexed": "never", "model": embedding_model}

    FTS index:
      - create_fts_index("text", replace=True) called once in __init__ or class method
      - Called at CorpusSearch construction time so it's always in sync

    Test cases (input -> expected output):
      - hybrid_search("skill") with seeded skill chunks -> list with at least 1 result
      - hybrid_search("skill", source="test-src") -> only results from test-src
      - hybrid_search("skill", file_type=".md") -> only .md results
      - hybrid_search("skill", construct_type="skill") -> only skill results
      - hybrid_search("zzzznosuchthing") -> []
      - _relevance_score present in every result dict
      - status() on seeded table -> files > 0, chunks > 0, last_indexed != "never"
      - status() on empty table -> files=0, chunks=0, last_indexed="never"
  </behavior>
  <implementation>
    Create src/corpus_analyzer/search/engine.py:

    ```python
    from __future__ import annotations
    import re
    from lancedb.rerankers import RRFReranker  # type: ignore[import-untyped]
    import lancedb  # type: ignore[import-untyped]
    from corpus_analyzer.ingest.embedder import OllamaEmbedder

    FILTER_SAFE_PATTERN = re.compile(r'^[\w\-\.]+$')

    class CorpusSearch:
        def __init__(self, table: lancedb.table.Table, embedder: OllamaEmbedder) -> None: ...
        def hybrid_search(self, query: str, *, source: str | None = None,
                          file_type: str | None = None, construct_type: str | None = None,
                          limit: int = 10) -> list[dict]: ...
        def status(self, embedding_model: str) -> dict: ...
        def _validate_filter(self, value: str, field: str) -> None: ...
    ```

    Test fixture pattern (for test_engine.py):
    - Use tmp_path to create a real LanceDB database
    - Create the chunks table using ChunkRecord schema
    - Call ensure_schema_v2(table) to add Phase 2 columns
    - Insert 3-5 hand-crafted chunk dicts covering different sources, file_types, and construct_types
    - Mock OllamaEmbedder.embed_batch to return fixed 768-dim zero vectors (fast, no Ollama needed)
    - Create CorpusSearch(table, mock_embedder) and test methods

    IMPORTANT: Open Question #2 from research — we must pass the query vector explicitly because Phase 1 uses manual (non-registry) embeddings. The query string is passed for FTS, the vector for ANN. Check if lancedb's hybrid search API accepts both: `table.search(query_text, vector=query_vec, query_type="hybrid")`. If that API doesn't exist, use the vector search path and pass the query as the vector parameter only, accepting that FTS will fall back gracefully. Add a comment noting which API path was verified.

    Use `pandas` for status() aggregation (already a transitive dep via lancedb). If pandas causes issues, use native lancedb to_list() and aggregate in Python.
  </implementation>
</feature>

<verification>
After GREEN phase (all tests passing):
`uv run pytest tests/search/test_engine.py -v`

All 11 tests must pass (status: PASSED, not XFAIL).

Run lint and type check:
`uv run ruff check src/corpus_analyzer/search/engine.py && uv run mypy src/corpus_analyzer/search/engine.py`
</verification>

<success_criteria>
- All test_engine.py tests pass (PASSED not XFAIL)
- CorpusSearch.hybrid_search() returns list of dicts with _relevance_score
- AND-filter chaining works for all three filter types
- status() returns correct structure for both empty and seeded tables
- mypy and ruff pass on engine.py
- No hand-rolled RRF — uses lancedb.rerankers.RRFReranker
</success_criteria>

<output>
After completion, create `.planning/phases/02-search-core/02-02-SUMMARY.md` with:
- CorpusSearch public API (method signatures)
- How query embedding is passed to hybrid search (resolved Open Question #2)
- Import path: from corpus_analyzer.search.engine import CorpusSearch
- Any behavior deviations from plan
</output>
