---
phase: 14-api-mcp-cli-parity
plan: "02"
type: tdd
wave: 1
depends_on: []
files_modified:
  - src/corpus_analyzer/api/public.py
  - src/corpus_analyzer/mcp/server.py
  - tests/api/test_public.py
  - tests/mcp/test_server.py
autonomous: true
requirements: [PARITY-01, PARITY-02, PARITY-03]

must_haves:
  truths:
    - "Python search() API accepts sort_by and min_score keyword arguments and forwards them to hybrid_search() via the translation map"
    - "Invalid sort_by values raise ValueError listing the three valid options"
    - "MCP corpus_search tool accepts min_score: Optional[float] and passes it to hybrid_search() as 0.0 when None"
    - "Existing assert_called_once_with tests updated to include new kwargs — no broken tests"
    - "ruff check . and mypy src/ both exit 0 after changes"
  artifacts:
    - path: "src/corpus_analyzer/api/public.py"
      provides: "_API_SORT_MAP, _VALID_API_SORT_VALUES, sort_by + min_score params on search()"
      contains: "_API_SORT_MAP"
    - path: "src/corpus_analyzer/mcp/server.py"
      provides: "min_score: Optional[float] = None parameter on corpus_search()"
      contains: "min_score: Optional[float] = None  # noqa: UP045"
    - path: "tests/api/test_public.py"
      provides: "Tests for PARITY-01, PARITY-02; updated exact-match assertions"
    - path: "tests/mcp/test_server.py"
      provides: "Test for PARITY-03; updated exact-match assertion"
  key_links:
    - from: "src/corpus_analyzer/api/public.py search()"
      to: "engine.hybrid_search()"
      via: "sort_by=_API_SORT_MAP[sort_by], min_score=min_score kwargs"
      pattern: "_API_SORT_MAP\\[sort_by\\]"
    - from: "src/corpus_analyzer/mcp/server.py corpus_search()"
      to: "engine.hybrid_search()"
      via: "min_score=min_score if min_score is not None else 0.0"
      pattern: "min_score if min_score is not None else 0\\.0"
---

<objective>
Add `sort_by` and `min_score` parameters to the Python public `search()` API with a translation map from API vocabulary (`score`, `date`, `title`) to engine vocabulary (`relevance`, `date`, `path`). Add `min_score: Optional[float]` to the MCP `corpus_search` tool. Update all existing `assert_called_once_with` tests that will break due to the new kwargs being forwarded.

Purpose: Complete PARITY-01, PARITY-02, PARITY-03 — the Python API and MCP surfaces for the min-score engine built in Phase 13.
Output: Updated `api/public.py` and `mcp/server.py` with new parameters, updated tests proving correct forwarding and validation.
</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-01-SUMMARY.md
@src/corpus_analyzer/api/public.py
@src/corpus_analyzer/mcp/server.py
@tests/api/test_public.py
@tests/mcp/test_server.py
</context>

<tasks>

<task type="tdd">
  <name>Task 1 RED: Write failing API and MCP tests for PARITY-01, PARITY-02, PARITY-03</name>
  <files>tests/api/test_public.py, tests/mcp/test_server.py</files>
  <action>
Add new test functions and update the broken exact-match assertions to match the intended post-implementation state.

**In `tests/api/test_public.py`:**

1. **Update `test_search_calls_hybrid_search_and_maps_to_dataclasses`** — The mock's `hybrid_search.assert_called_once_with` will break when new kwargs are added. Pre-emptively update it to include the new defaults:
   ```python
   mock_engine.hybrid_search.assert_called_once_with(
       "q",
       source=None,
       file_type=None,
       construct_type=None,
       limit=10,
       sort_by="relevance",   # default "score" mapped to "relevance"
       min_score=0.0,
   )
   ```

2. **Update `test_search_passes_filters_to_hybrid_search`** — same pattern, add:
   ```python
   sort_by="relevance",   # default "score" mapped to "relevance"
   min_score=0.0,
   ```

3. **Add `test_search_sort_by_translates_and_forwards` (new):**
   Patch `_open_engine`, call `search("q", sort_by="title")`, assert `hybrid_search` was called with `sort_by="path"`. Call `search("q", sort_by="date")`, assert `sort_by="date"`.

4. **Add `test_search_invalid_sort_by_raises_value_error` (new):**
   Patch `_open_engine`. Call `search("q", sort_by="invalid")`. Assert `ValueError` is raised with message containing `"Invalid sort_by"`.

5. **Add `test_search_min_score_forwarded` (new):**
   Patch `_open_engine`, call `search("q", min_score=0.02)`, assert `hybrid_search` was called with `min_score=0.02`.

**In `tests/mcp/test_server.py`:**

1. **Update `test_corpus_search_passes_filters_to_hybrid_search`** — Add `min_score=0.0` (None → 0.0 default) to the `assert_called_once_with` assertion.

2. **Add `test_corpus_search_min_score_forwarded` (new):**
   Set up the mock engine, call `corpus_search("q", min_score=0.015)`, assert `engine.hybrid_search` was called with `min_score=0.015`.

3. **Add `test_corpus_search_min_score_none_uses_zero` (new):**
   Call `corpus_search("q", min_score=None)`, assert `engine.hybrid_search` was called with `min_score=0.0`.

Run `uv run pytest tests/api/test_public.py tests/mcp/test_server.py -x -v` — the updated existing tests should FAIL (because `sort_by` and `min_score` aren't forwarded yet, so the assertion won't match) and the new tests should also FAIL. Commit: `test(14-02): add failing API and MCP tests for PARITY-01, PARITY-02, PARITY-03`.
  </action>
  <verify>
    <automated>uv run pytest tests/api/test_public.py tests/mcp/test_server.py -x -v 2>&1 | tail -30</automated>
    <manual>Confirm updated assertion tests fail with call argument mismatch, and new tests fail with TypeError (parameter doesn't exist yet).</manual>
    <sampling_rate>run after this task, before Task 2</sampling_rate>
  </verify>
  <done>Updated and new tests exist in both test files. All affected tests fail in RED state.</done>
</task>

<task type="tdd">
  <name>Task 2 GREEN: Add sort_by + min_score to Python API and min_score to MCP</name>
  <files>src/corpus_analyzer/api/public.py, src/corpus_analyzer/mcp/server.py</files>
  <action>
Implement the parameter additions in both source files to bring all tests GREEN.

**`src/corpus_analyzer/api/public.py`:**

1. Add the translation map and valid-values set as module-level constants immediately before the `search()` function:

```python
_API_SORT_MAP: dict[str, str] = {
    "score": "relevance",
    "date": "date",
    "title": "path",
}
_VALID_API_SORT_VALUES: frozenset[str] = frozenset(_API_SORT_MAP.keys())
```

2. Add `sort_by: str = "score"` and `min_score: float = 0.0` to the `search()` function signature as keyword-only parameters (after the existing `limit` param):

```python
def search(
    query: str,
    *,
    source: str | None = None,
    file_type: str | None = None,
    construct_type: str | None = None,
    limit: int = 10,
    sort_by: str = "score",
    min_score: float = 0.0,
) -> list[SearchResult]:
```

3. Add validation before the engine call:

```python
if sort_by not in _VALID_API_SORT_VALUES:
    raise ValueError(
        f"Invalid sort_by value: {sort_by!r}. "
        f"Allowed values: {sorted(_VALID_API_SORT_VALUES)}"
    )
```

4. Update the `hybrid_search()` call to include the new kwargs:

```python
raw = engine.hybrid_search(
    query,
    source=source,
    file_type=file_type,
    construct_type=construct_type,
    limit=limit,
    sort_by=_API_SORT_MAP[sort_by],
    min_score=min_score,
)
```

5. Update the docstring to document both new parameters.

**`src/corpus_analyzer/mcp/server.py`:**

1. Add `min_score: Optional[float] = None  # noqa: UP045` as a new parameter in `corpus_search()`, following the exact same pattern as all other `Optional` parameters (important: place it before `ctx`).

2. Locate the `engine.hybrid_search()` call inside `corpus_search()`. Add `min_score=min_score if min_score is not None else 0.0` as a keyword argument.

3. For the MCP empty-results response when `min_score` is set and non-zero (Claude's discretion, from RESEARCH.md recommendation): when `not results and min_score` (truthy, i.e. non-None and non-zero), include `"filtered_by_min_score": True` in the return dict alongside the existing empty-results structure. This gives MCP clients a programmatic signal.

4. Update the `corpus_search` docstring to document `min_score`.

After both files are updated, run the full suite and linting:

```
uv run pytest tests/api/test_public.py tests/mcp/test_server.py -x -v
uv run pytest -x -q
uv run ruff check .
uv run mypy src/
```

All must pass. Commit: `feat(14-02): add sort_by + min_score to Python API and min_score to MCP`.
  </action>
  <verify>
    <automated>uv run pytest tests/api/test_public.py tests/mcp/test_server.py -v && uv run pytest -x -q && uv run ruff check . && uv run mypy src/</automated>
    <manual>In a Python REPL, `from corpus_analyzer.api.public import search; help(search)` — confirm sort_by and min_score appear in the signature. Confirm `search("q", sort_by="invalid")` raises ValueError.</manual>
    <sampling_rate>run after this task commits</sampling_rate>
  </verify>
  <done>All tests pass. search() accepts sort_by and min_score, raises ValueError on invalid sort_by, translates sort values before forwarding. corpus_search() accepts min_score Optional[float], passes 0.0 when None. ruff and mypy both exit 0.</done>
</task>

</tasks>

<verification>
Full pass:
```
uv run pytest -v
uv run ruff check .
uv run mypy src/
```

Behavioral checks:
1. `from corpus_analyzer.api.public import search` — `search("q", sort_by="score")` calls engine with `sort_by="relevance"`
2. `search("q", sort_by="title")` calls engine with `sort_by="path"`
3. `search("q", sort_by="bad")` raises `ValueError` mentioning allowed values
4. `search("q", min_score=0.02)` calls engine with `min_score=0.02`
5. MCP `corpus_search("q", min_score=0.015)` calls engine with `min_score=0.015`
6. MCP `corpus_search("q", min_score=None)` calls engine with `min_score=0.0`
7. No `Optional[float]` ruff violation — all `# noqa: UP045` comments present
</verification>

<success_criteria>
- PARITY-01 satisfied: `search()` accepts `sort_by` with translation map and ValueError on invalid values
- PARITY-02 satisfied: `search()` accepts `min_score` and forwards it to hybrid_search()
- PARITY-03 satisfied: `corpus_search()` accepts `min_score: Optional[float]`
- All previously-passing tests still green (exact-match assertions updated)
- ruff and mypy both clean
</success_criteria>

<output>
After completion, create `.planning/phases/14-api-mcp-cli-parity/14-02-SUMMARY.md`
</output>
