---
phase: 13-engine-min-score-filter
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
  - tests/search/test_engine.py
  - src/corpus_analyzer/search/engine.py
autonomous: true
requirements:
  - FILT-01

must_haves:
  truths:
    - "hybrid_search() accepts a min_score: float = 0.0 parameter without error"
    - "Calling hybrid_search() with min_score=0.0 returns identical results to calling it with no min_score argument"
    - "Calling hybrid_search() with min_score=99.0 returns an empty list regardless of query"
    - "Results returned when min_score is above zero all have _relevance_score >= min_score"
    - "All 281 existing tests remain green after the engine change"
  artifacts:
    - path: "tests/search/test_engine.py"
      provides: "Four new min_score test functions appended after existing tests"
      contains: "test_min_score_zero_is_noop"
    - path: "src/corpus_analyzer/search/engine.py"
      provides: "min_score: float = 0.0 parameter and post-retrieval filter comprehension"
      contains: "min_score"
  key_links:
    - from: "src/corpus_analyzer/search/engine.py"
      to: "filtered list (post-text-gate)"
      via: "list comprehension on _relevance_score after text-gate, before sort block"
      pattern: "float\\(r\\.get\\(\"_relevance_score\""
---

<objective>
Add `min_score: float = 0.0` filtering to `CorpusSearch.hybrid_search()` using TDD. Tests are written first (RED), then the engine is updated to make them pass (GREEN).

Purpose: Gives callers the ability to suppress low-relevance results with a single parameter. The `0.0` default guarantees zero regression for all existing callers.
Output: Updated `engine.py` with filter parameter + new test functions in `test_engine.py` covering the FILT-01 requirement.
</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/ROADMAP.md
@.planning/STATE.md
@.planning/phases/13-engine-min-score-filter/13-CONTEXT.md
@.planning/phases/13-engine-min-score-filter/13-RESEARCH.md
@src/corpus_analyzer/search/engine.py
@tests/search/test_engine.py
</context>

<tasks>

<task type="tdd">
  <name>Task 1 RED: Write failing min_score tests</name>
  <files>tests/search/test_engine.py</files>
  <action>
Append four new test functions to `tests/search/test_engine.py` AFTER the last existing test. Do NOT modify any existing test or fixture. Use the existing `search` fixture (which provides a `CorpusSearch` instance backed by `seeded_table`).

Test functions to add:

```python
def test_min_score_zero_is_noop(search: CorpusSearch) -> None:
    """FILT-01: min_score=0.0 returns identical results to no filter."""
    without_filter = search.hybrid_search("search")
    with_zero = search.hybrid_search("search", min_score=0.0)
    assert with_zero == without_filter


def test_min_score_above_max_returns_empty(search: CorpusSearch) -> None:
    """FILT-01: min_score=99.0 exceeds any real RRF score — must return []."""
    results = search.hybrid_search("search", min_score=99.0)
    assert results == []


def test_min_score_parameter_accepted(search: CorpusSearch) -> None:
    """FILT-01: hybrid_search() must accept min_score without TypeError."""
    # Raises TypeError if parameter does not exist
    _ = search.hybrid_search("search", min_score=0.0)


def test_min_score_filters_below_threshold(search: CorpusSearch) -> None:
    """FILT-01: results passing the filter all have _relevance_score >= threshold."""
    baseline = search.hybrid_search("search")
    if not baseline:
        pytest.skip("no results in seeded table for this query")
    max_score = max(float(r.get("_relevance_score", 0.0)) for r in baseline)
    # Threshold == max_score keeps at least one result (inclusive >=)
    results = search.hybrid_search("search", min_score=max_score)
    assert len(results) >= 1
    assert all(float(r.get("_relevance_score", 0.0)) >= max_score for r in results)
```

After writing the tests, run them to confirm they FAIL (RED):

```
uv run pytest tests/search/test_engine.py::test_min_score_zero_is_noop tests/search/test_engine.py::test_min_score_above_max_returns_empty tests/search/test_engine.py::test_min_score_parameter_accepted tests/search/test_engine.py::test_min_score_filters_below_threshold -v
```

Expected: `TypeError: hybrid_search() got an unexpected keyword argument 'min_score'` (confirming RED state).

Commit after confirming RED: `test(13-01): add failing min_score tests for FILT-01`
  </action>
  <verify>
    <automated>uv run pytest tests/search/test_engine.py::test_min_score_zero_is_noop tests/search/test_engine.py::test_min_score_above_max_returns_empty tests/search/test_engine.py::test_min_score_parameter_accepted tests/search/test_engine.py::test_min_score_filters_below_threshold -v 2>&1 | grep -E "FAILED|ERROR|TypeError"</automated>
    <manual>All four new tests fail with TypeError about unexpected keyword argument — confirms RED state before implementation</manual>
    <sampling_rate>run after this task, before Task 2</sampling_rate>
  </verify>
  <done>Four new test functions exist in test_engine.py. All four fail with TypeError because min_score parameter does not yet exist in hybrid_search().</done>
</task>

<task type="tdd">
  <name>Task 2 GREEN: Implement min_score in hybrid_search()</name>
  <files>src/corpus_analyzer/search/engine.py</files>
  <action>
Make the four failing tests pass with the minimal correct implementation. Two changes to `engine.py`:

**Change 1 — Add `min_score` to the `hybrid_search()` signature** (after `sort_by`):

```python
def hybrid_search(
    self,
    query: str,
    *,
    source: str | None = None,
    file_type: str | None = None,
    construct_type: str | None = None,
    limit: int = 10,
    sort_by: str = "relevance",
    min_score: float = 0.0,
) -> list[dict[str, Any]]:
    """Run a hybrid BM25+vector query with optional AND-filter chaining.

    Args:
        sort_by: Post-retrieval sort order. One of:
            "relevance" (default, RRF order), "construct" (CONSTRUCT_PRIORITY),
            "confidence" (descending), "date" (descending), "path" (ascending).
        min_score: Minimum _relevance_score threshold (inclusive). Results
            below this score are excluded. Default 0.0 keeps all results
            (all real RRF scores are positive). RRF scores range approximately
            0.009–0.033 for K=60. Negative values behave the same as 0.0.
    """
```

**Change 2 — Insert min_score filter AFTER the text-gate `filtered` comprehension, BEFORE the sort block** (around line 105 in the current file):

```python
        filtered = [
            r
            for r in results
            if any(term in str(r.get("text", "")).lower() for term in query_terms)
        ]

        # RRF scores range ~0.009–0.033 for K=60; min_score=0.0 is a no-op.
        if min_score > 0.0:
            filtered = [
                r for r in filtered
                if float(r.get("_relevance_score", 0.0)) >= min_score
            ]

        if sort_by == "construct":
            ...
```

Do NOT:
- Apply the filter to `results` (raw retrieval output) — it must operate on `filtered` (post-text-gate)
- Use `.where()` on the LanceDB builder — `_relevance_score` is not a stored column
- Change the type annotation to `Optional[float]` — locked decision requires `float = 0.0`
- Modify any existing sort block or other behaviour

After implementing, run the four new tests (GREEN check):

```
uv run pytest tests/search/test_engine.py::test_min_score_zero_is_noop tests/search/test_engine.py::test_min_score_above_max_returns_empty tests/search/test_engine.py::test_min_score_parameter_accepted tests/search/test_engine.py::test_min_score_filters_below_threshold -v
```

Then run the full suite to confirm zero regression:

```
uv run pytest -v
```

Then confirm ruff and mypy are clean:

```
uv run ruff check .
uv run mypy src/
```

Commit after all checks pass: `feat(13-01): add min_score filter to hybrid_search() — FILT-01`
  </action>
  <verify>
    <automated>uv run pytest tests/search/test_engine.py -v && uv run pytest -v && uv run ruff check . && uv run mypy src/</automated>
    <manual>All four new min_score tests pass. Full suite (≥281 tests) green. Ruff exits 0. Mypy exits 0.</manual>
    <sampling_rate>run after this task before phase-complete gate</sampling_rate>
  </verify>
  <done>All four min_score tests pass (GREEN). Full test suite ≥281 tests green. `uv run ruff check .` exits 0. `uv run mypy src/` exits 0. hybrid_search() signature includes min_score: float = 0.0 with correct docstring.</done>
</task>

</tasks>

<verification>
Phase-complete gate (run after both tasks):

```bash
uv run pytest -v
uv run ruff check .
uv run mypy src/
```

All three commands must exit 0. Full test count must be ≥285 (281 existing + 4 new).
</verification>

<success_criteria>
1. `hybrid_search()` accepts `min_score: float = 0.0` — existing callers unchanged
2. `hybrid_search("search", min_score=0.0)` returns the same results as `hybrid_search("search")` — zero regression
3. `hybrid_search("search", min_score=99.0)` returns `[]`
4. All results from a filtered call have `_relevance_score >= min_score`
5. Full pytest suite ≥285 tests, all green
6. `uv run ruff check .` exits 0
7. `uv run mypy src/` exits 0
</success_criteria>

<output>
After completion, create `.planning/phases/13-engine-min-score-filter/13-01-SUMMARY.md`
</output>
