---
phase: 21-contradiction-detection
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/ztlctl/services/contradiction.py
  - src/ztlctl/controllers/contradiction.py
  - tests/test_contradiction_service.py
  - tests/test_contradiction_controller.py
autonomous: true
requirements: [CNTR-01, CNTR-02]

must_haves:
  truths:
    - "ContradictionService.find_candidates returns scored pairs of notes that may contradict"
    - "Candidate pairs are scoped by shared tags and high cosine similarity (>0.85)"
    - "Heuristic scoring combines cosine similarity (40%), negation density (30%), key_points divergence (30%)"
    - "Results are capped at 20 pairs and sorted by score descending"
  artifacts:
    - path: "src/ztlctl/services/contradiction.py"
      provides: "ContradictionService with find_candidates method"
      exports: ["ContradictionService"]
    - path: "src/ztlctl/controllers/contradiction.py"
      provides: "ContradictionController wrapping service methods"
      exports: ["ContradictionController"]
    - path: "tests/test_contradiction_service.py"
      provides: "Unit tests for candidate discovery and scoring"
    - path: "tests/test_contradiction_controller.py"
      provides: "Controller delegation tests"
  key_links:
    - from: "src/ztlctl/services/contradiction.py"
      to: "src/ztlctl/services/vector.py"
      via: "VectorService.search_similar for cosine similarity"
      pattern: "VectorService.*search_similar"
    - from: "src/ztlctl/services/contradiction.py"
      to: "src/ztlctl/infrastructure/database/schema.py"
      via: "nodes and node_tags tables for tag overlap queries"
      pattern: "select.*node_tags"
    - from: "src/ztlctl/controllers/contradiction.py"
      to: "src/ztlctl/services/contradiction.py"
      via: "ContradictionService instantiation in controller"
      pattern: "ContradictionService\\(self._vault\\)"
---

<objective>
Build the ContradictionService with candidate pair discovery and heuristic scoring, plus the thin ContradictionController wrapper.

Purpose: CNTR-01 (candidate pair discovery) and CNTR-02 (heuristic scoring) are the core analytical engine. Plan 02 wires them into CheckService, ActionRegistry, graph edges, and MCP.
Output: Working ContradictionService and ContradictionController with tests.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/21-contradiction-detection/21-CONTEXT.md

@src/ztlctl/services/base.py
@src/ztlctl/services/vector.py
@src/ztlctl/services/query.py
@src/ztlctl/services/check.py
@src/ztlctl/controllers/recall.py
@src/ztlctl/infrastructure/database/schema.py

<interfaces>
<!-- Key types and contracts the executor needs -->

From src/ztlctl/services/base.py:
```python
class BaseService:
    def __init__(self, vault: Vault) -> None: ...
    _vault: Vault
    def _dispatch_event(self, event_name: str, payload: dict, warnings: list[str]) -> None: ...
```

From src/ztlctl/services/result.py:
```python
class ServiceResult(BaseModel):
    ok: bool
    op: str
    data: dict[str, Any] = {}
    warnings: list[str] = []
    error: ServiceError | None = None
```

From src/ztlctl/services/vector.py:
```python
class VectorService(BaseService):
    def is_available(self) -> bool: ...
    def search_similar(self, query_text: str, *, limit: int = 20) -> list[dict[str, Any]]: ...
    # Returns [{"node_id": str, "distance": float}]  (cosine distance, lower = more similar)
```

From src/ztlctl/controllers/base.py:
```python
class BaseController:
    def __init__(self, vault: Vault) -> None: ...
    _vault: Vault
    def _run_action(self, action_name: str, kwargs: dict, invoke: Callable) -> ServiceResult: ...
```

From src/ztlctl/infrastructure/database/schema.py:
```python
nodes = Table("nodes", ...)  # columns: id, title, note_type, status, ...
node_tags = Table("node_tags", ...)  # columns: node_id, tag
edges = Table("edges", ...)  # columns: source_id, target_id, edge_type, ...
```

From src/ztlctl/domain/content.py:
```python
def parse_frontmatter(raw: str) -> tuple[dict[str, Any], str]: ...
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: ContradictionService with candidate discovery and heuristic scoring</name>
  <files>src/ztlctl/services/contradiction.py, tests/test_contradiction_service.py</files>
  <read_first>
    - src/ztlctl/services/base.py (BaseService pattern)
    - src/ztlctl/services/vector.py (VectorService.search_similar signature and return shape)
    - src/ztlctl/services/check.py (check_alignment as service method pattern)
    - src/ztlctl/services/recall.py (recent service example)
    - src/ztlctl/infrastructure/database/schema.py (nodes, node_tags, edges tables)
    - src/ztlctl/domain/content.py (parse_frontmatter for key_points extraction)
    - src/ztlctl/services/query.py lines 585-600 (key_points extraction from frontmatter)
  </read_first>
  <behavior>
    - Test 1: find_candidates returns empty list when vector search is unavailable (VectorService.is_available() returns False)
    - Test 2: find_candidates returns empty list when no notes share tags
    - Test 3: find_candidates returns scored pairs when two notes share tags and have high cosine similarity
    - Test 4: score_pair returns weighted score combining cosine similarity (40%), negation density (30%), key_points divergence (30%)
    - Test 5: negation density correctly detects keywords: "however", "but", "instead", "on the contrary", "disagree", "not", "shouldn't", "won't"
    - Test 6: key_points divergence detects overlapping topics with different conclusions
    - Test 7: candidates capped at 20 pairs sorted by score descending
    - Test 8: find_candidates gracefully returns empty when vault has no decision notes
  </behavior>
  <action>
    Create `src/ztlctl/services/contradiction.py` with class `ContradictionService(BaseService)`:

    1. **`find_candidates(self, *, similarity_threshold: float = 0.85, max_pairs: int = 20) -> ServiceResult`** (per CNTR-01):
       - If VectorService is unavailable, return ok=True with empty candidates and info warning
       - Query all non-archived notes with `note_type='decision'` or notes that have `key_points` in frontmatter
       - For each candidate note, use VectorService.search_similar with note's `title + body` text to find high-similarity peers
       - Filter: keep only pairs where cosine similarity >= threshold (convert distance to similarity: `1 - distance`)
       - Filter: keep only pairs that share at least one tag (query node_tags for overlap)
       - Score each pair using `_score_pair()` (see below)
       - Sort by score descending, cap at max_pairs
       - Return ServiceResult with `data={"candidates": [...], "count": N}`
       - Each candidate dict: `{"note_a": id, "note_b": id, "title_a": str, "title_b": str, "score": float, "signals": [str]}`

    2. **`_score_pair(self, note_a_content: str, note_b_content: str, cosine_sim: float, key_points_a: list[str], key_points_b: list[str]) -> tuple[float, list[str]]`** (per CNTR-02):
       - cosine_weight = 0.4, negation_weight = 0.3, keypoints_weight = 0.3
       - Cosine component: `cosine_sim * cosine_weight`
       - Negation component: count negation keywords in combined body text, normalize to 0-1 range (cap at 5 keywords = 1.0), multiply by negation_weight. Keywords: "however", "but", "instead", "on the contrary", "disagree", "not", "shouldn't", "won't"
       - Key_points component: find overlapping topic words between key_points_a and key_points_b. If overlap exists but conclusions differ (substring match fails), score = 1.0 * keypoints_weight. If no overlap, score = 0.
       - Return (total_score, signals_list) where signals describe what contributed

    3. **`_extract_note_content(self, conn: Connection, node_id: str) -> tuple[str, list[str]]`**:
       - Read note file from vault, parse_frontmatter, extract key_points list
       - Return (body_text, key_points)

    Use `@traced` decorator on `find_candidates`. Use `trace_span` for sub-stages (vector_search, tag_overlap, scoring).
    Use lazy imports for VectorService inside methods (matching existing cross-service import pattern).

    Create `tests/test_contradiction_service.py`:
    - Use tmp_path vault fixture (follow test_recall.py or test_check.py pattern)
    - Mock VectorService.search_similar to return controlled distance values
    - Create test notes with known tags, key_points, and body content containing negation keywords
    - Verify scoring math explicitly
  </action>
  <verify>
    <automated>uv run pytest tests/test_contradiction_service.py -x -v</automated>
  </verify>
  <acceptance_criteria>
    - ContradictionService.find_candidates returns scored candidate pairs
    - Pairs filtered by shared tags AND high cosine similarity
    - Scoring correctly weights cosine (40%), negation (30%), key_points (30%)
    - Results capped at 20, sorted by score desc
    - Graceful empty return when vector search unavailable
    - All tests pass
  </acceptance_criteria>
  <done>ContradictionService with find_candidates and _score_pair fully tested</done>
</task>

<task type="auto">
  <name>Task 2: ContradictionController wrapper</name>
  <files>src/ztlctl/controllers/contradiction.py, tests/test_contradiction_controller.py</files>
  <read_first>
    - src/ztlctl/controllers/recall.py (exact pattern to follow)
    - src/ztlctl/controllers/check.py (check_alignment method pattern)
    - src/ztlctl/controllers/base.py (BaseController._run_action signature)
  </read_first>
  <action>
    Create `src/ztlctl/controllers/contradiction.py` with class `ContradictionController(BaseController)`:

    1. **`check_contradictions(self, *, similarity_threshold: float = 0.85, max_pairs: int = 20) -> ServiceResult`**:
       - Lazy import ContradictionService
       - Build kwargs dict, define _invoke closure, delegate to self._run_action("check_contradictions", kwargs, _invoke)
       - Follows exact pattern from RecallController methods

    2. **`confirm_contradiction(self, *, note_a: str, note_b: str) -> ServiceResult`**:
       - Lazy import ContradictionService
       - Build kwargs dict, define _invoke closure, delegate to self._run_action("confirm_contradiction", kwargs, _invoke)
       - This calls ContradictionService.confirm_contradiction (to be implemented in Plan 02 with graph edge recording)
       - For now, stub the service method to return ServiceResult(ok=False, op="confirm_contradiction", error=ServiceError(code="NOT_IMPLEMENTED", message="Confirm contradiction not yet wired"))

    Create `tests/test_contradiction_controller.py`:
    - Test that check_contradictions delegates to ContradictionService.find_candidates via _run_action
    - Test that confirm_contradiction delegates correctly
    - Follow test_recall_controller.py pattern if exists, otherwise test_check_controller.py pattern
  </action>
  <verify>
    <automated>uv run pytest tests/test_contradiction_controller.py -x -v</automated>
  </verify>
  <acceptance_criteria>
    - ContradictionController.check_contradictions delegates through _run_action
    - ContradictionController.confirm_contradiction delegates through _run_action
    - Controller follows established BaseController pattern exactly
    - All tests pass, mypy clean, ruff clean
  </acceptance_criteria>
  <done>ContradictionController with check_contradictions and confirm_contradiction methods, fully tested</done>
</task>

</tasks>

<verification>
uv run pytest tests/test_contradiction_service.py tests/test_contradiction_controller.py -x -v
uv run ruff check src/ztlctl/services/contradiction.py src/ztlctl/controllers/contradiction.py
uv run mypy src/ztlctl/services/contradiction.py src/ztlctl/controllers/contradiction.py
</verification>

<success_criteria>
- ContradictionService exists with find_candidates returning scored pairs (CNTR-01)
- Heuristic scoring weights cosine/negation/key_points correctly (CNTR-02)
- ContradictionController wraps service methods through _run_action
- All tests pass, mypy strict clean, ruff clean
</success_criteria>

<output>
After completion, create `.planning/phases/21-contradiction-detection/21-01-SUMMARY.md`
</output>
