# Issues #551, #563, #561 Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Fix ort compilation error (#551), eliminate double file I/O during indexing (#563/RM-5), and add incremental HNSW updates for watch mode (#561/RM-2).

**Architecture:** Three independent fixes. #551 is a type signature change in two files. #563 threads ChunkTypeRef through the pipeline (calls already handled by store_stage). #561 adds insert_batch to the watch loop with periodic full rebuilds.

**Tech Stack:** Rust, ort 2.0.0-rc.11+, hnsw_rs, rayon, sqlx

---

## Task 1: Fix #551 — ort::Error<T> generic type mismatch

**Files:**
- Modify: `src/embedder.rs:42-46`
- Modify: `src/reranker.rs:33-36` (identical `From<ort::Error>` impl)

**Problem:** `ort` changed `Error` to `Error<T>` in a newer RC. Our `From<ort::Error>` resolves to `From<ort::Error<()>>`, but `with_execution_providers()` returns `Result<_, ort::Error<SessionBuilder>>`. Users who resolve ort 2.0.0-rc.12 (allowed by our `2.0.0-rc.11` semver range) get a compilation error. Both `embedder.rs` and `reranker.rs` have this impl.

**Step 1: Fix the From impl in embedder.rs**

Replace (line ~42):
```rust
impl From<ort::Error> for EmbedderError {
    fn from(e: ort::Error) -> Self {
        EmbedderError::InferenceFailed(e.to_string())
    }
}
```

With:
```rust
impl<T: std::fmt::Debug> From<ort::Error<T>> for EmbedderError {
    fn from(e: ort::Error<T>) -> Self {
        EmbedderError::InferenceFailed(e.to_string())
    }
}
```

If `ort::Error` is not yet generic in rc.11 (only in rc.12), use a different approach — add `.map_err(|e| EmbedderError::InferenceFailed(e.to_string()))` at the call sites instead of relying on `?` with `From`.

**Step 2: Fix the From impl in reranker.rs**

Apply the same change to `src/reranker.rs:33-36` — identical `From<ort::Error>` pattern for `RerankerError`.

**Step 3: Build and test**
```bash
cargo build --features gpu-index 2>&1 | grep -i error
cargo test --features gpu-index 2>&1 | tail -5
```

**Step 4: Commit**
```bash
cargo fmt && git add src/embedder.rs src/reranker.rs
git commit -m "fix: handle ort::Error<T> generic type parameter (#551)"
```

---

## Task 2: Fix #563/RM-5 — Eliminate double I/O in indexing pipeline

**Files:**
- Modify: `src/cli/pipeline.rs` (~lines 96-99 ParsedBatch, ~267 parser_stage, ~561 store_stage)
- Modify: `src/cli/commands/index.rs` (~lines 193-241 extract_relationships)

**Problem:** `extract_relationships()` in index.rs re-reads and re-parses every file to extract calls and type edges. However, the pipeline's `store_stage` (pipeline.rs:561-637) ALREADY extracts calls via `parser.extract_calls_from_chunk()` and stores them with `upsert_chunks_and_calls()`. Only **type edges** (`ChunkTypeRef`) are missing from the pipeline flow. The double I/O is entirely for type edge extraction.

**Architecture:** Change the parser stage to use `parse_file_all()` instead of `parse_file()`. This returns `(chunks, calls, type_refs)` in one pass. Thread only `type_refs` through the pipeline (calls are already handled). Store type edges in writer stage. Remove `extract_relationships()` from index.rs.

### Step 1: Extend ParsedBatch to carry type refs

In `src/cli/pipeline.rs`, find the `ParsedBatch` struct (~line 96) and add a field:

```rust
struct ParsedBatch {
    chunks: Vec<Chunk>,
    type_refs: Vec<crate::ChunkTypeRef>,  // NEW — calls already handled by store_stage
    file_mtimes: HashMap<PathBuf, i64>,
}
```

### Step 2: Change parser stage to use parse_file_all

In `parser_stage`, change from `parse_file` to `parse_file_all`:

```rust
// Before:
match parser.parse_file(&abs_path) {
    Ok(mut chunks) => { ... }
}

// After:
match parser.parse_file_all(&abs_path) {
    Ok((mut chunks, _calls, type_refs)) => {
        // _calls ignored here — store_stage extracts calls from chunks directly
        // ... existing chunk processing (path rewrite, id fixup, etc.) ...
        all_type_refs.extend(type_refs);
    }
}
```

Accumulate `all_type_refs` per batch, include in `ParsedBatch`.

### Step 3: Store type edges in writer stage

In the writer stage, after `upsert_chunks_and_calls()` (which already handles calls), add:

```rust
if !batch.type_refs.is_empty() {
    store.upsert_type_edges(&batch.type_refs)?;
}
```

Verify `upsert_type_edges` accepts `&[ChunkTypeRef]`. Check that ChunkTypeRef uses the correct (relative) file paths — the pipeline rewrites chunk paths to be relative, so verify type_refs match.

### Step 4: Remove extract_relationships from index.rs

In `cmd_index` (index.rs), remove the `extract_relationships()` call (~line 193-241). The pipeline now handles both calls (via store_stage) and type edges (via the new type_refs flow). If `extract_relationships` has no other callers, remove the function entirely.

### Step 5: Build and test
```bash
cargo build --features gpu-index 2>&1 | grep -i warning
cargo test --features gpu-index 2>&1 | grep "^test result:"
```

Verify with a real index:
```bash
cqs index --force
cqs callers some_function  # calls should still work (store_stage unchanged)
cqs deps SomeType          # type edges should now work via pipeline
```

### Step 6: Commit
```bash
cargo fmt && git add src/cli/pipeline.rs src/cli/commands/index.rs
git commit -m "perf: eliminate double I/O in indexing pipeline (#563)

parse_file_all() in parser stage extracts type_refs in the same pass
as chunks. Type edges stored in writer stage. Calls were already
handled by store_stage. extract_relationships() removed — no longer
re-reads every file."
```

---

## Task 3: Fix #561/RM-2 — Incremental HNSW updates in watch mode

**Files:**
- Modify: `src/cli/watch.rs` (~lines 335-353, 385-437)
- Modify: `src/hnsw/mod.rs` (insert_batch already exists at line ~180)

**Problem:** `cqs watch` rebuilds the entire HNSW index on every file change — O(total_chunks) for O(1) changes. For 10k chunks, this is 1-2 seconds per save.

**Key facts from code review:**
- `insert_batch` signature: `pub fn insert_batch(&mut self, items: &[(String, &[f32])]) -> Result<usize, HnswError>` — takes (chunk_id, embedding) pairs, assigns sequential HNSW IDs from `id_map.len()`. Only works on `Owned` variant.
- `reindex_files` already returns `content_hashes` but they're UNUSED in watch.rs.
- `build_hnsw_index` builds an Owned index, saves to disk, and returns it. The watch loop currently discards it and rebuilds next cycle.
- `Loaded` variant (from disk via self_cell) is immutable — can't `insert_batch` into it.

**Architecture:** Keep the `Owned` HNSW index in memory across watch cycles. On file change, use `insert_batch` for new/changed chunk embeddings. Periodic full rebuild every N increments to clean orphaned vectors (hnsw_rs has no deletion API). Save to disk on full rebuild and clean shutdown.

### Step 1: Add WatchHnswState to watch loop

```rust
struct WatchHnswState {
    /// Current HNSW index (Owned, mutable) — kept in memory across cycles
    index: Option<HnswIndex>,
    /// Number of incremental inserts since last full rebuild
    incremental_count: usize,
    /// Threshold for triggering full rebuild (default: 100)
    rebuild_threshold: usize,
}
```

Initialize before the watch loop. On first cycle, do a full build via `build_hnsw_index` and keep the returned Owned index.

### Step 2: Get embeddings for changed chunks

After `reindex_files` returns `content_hashes`, query the store for embeddings of those chunks:

```rust
// content_hashes are already returned by reindex_files but currently unused
let changed_embeddings = store.get_embeddings_by_hashes(&content_hashes)?;
```

Check if `get_embeddings_by_hashes` exists or if a similar query is needed. The store has `get_all_embeddings()` — may need a filtered variant that takes content hashes or chunk IDs.

### Step 3: Incremental insert instead of full rebuild

```rust
if let Some(ref mut hnsw) = hnsw_state.index {
    if !changed_embeddings.is_empty() {
        // insert_batch takes (chunk_id: String, embedding: &[f32]) pairs
        let items: Vec<(String, Vec<f32>)> = changed_embeddings;
        let refs: Vec<(String, &[f32])> = items.iter()
            .map(|(id, emb)| (id.clone(), emb.as_slice()))
            .collect();
        hnsw.insert_batch(&refs)?;
        hnsw_state.incremental_count += refs.len();
    }

    // Periodic full rebuild to clean orphans
    if hnsw_state.incremental_count >= hnsw_state.rebuild_threshold {
        hnsw_state.index = Some(build_hnsw_index(&store, &cqs_dir)?);
        hnsw_state.incremental_count = 0;
    }
} else {
    // First run — full build
    hnsw_state.index = Some(build_hnsw_index(&store, &cqs_dir)?);
}
```

### Step 4: Handle orphans and deletions

hnsw_rs has no deletion API. Changed chunks get NEW HNSW IDs (old IDs become orphans pointing to stale chunk IDs). Mitigations already in place:
- Search results are post-filtered against live chunk IDs in SQLite
- Full rebuild every `rebuild_threshold` inserts (default 100)
- Full rebuild on watch startup

No additional code needed — the existing search post-filtering handles stale results.

### Step 5: Save to disk periodically

Save HNSW to disk on full rebuilds (already done by `build_hnsw_index`). For incremental-only cycles, skip disk save — the in-memory Owned index is authoritative. On clean shutdown (Ctrl-C handler), save current state.

### Step 6: Build and test
```bash
cargo build --features gpu-index 2>&1 | grep -i warning
cargo test --features gpu-index -- hnsw 2>&1 | tail -20
```

Manual test:
```bash
# Start watch in one terminal
cqs watch

# In another terminal, edit a file and verify search still works
echo "// test" >> src/lib.rs
sleep 2
cqs "search query"  # should return results without 2s rebuild lag
git checkout -- src/lib.rs
```

### Step 7: Commit
```bash
cargo fmt && git add src/cli/watch.rs src/hnsw/mod.rs
git commit -m "perf: incremental HNSW updates in watch mode (#561)

Watch mode now inserts changed chunks into existing HNSW index
instead of full rebuild. Full rebuild every 100 incremental inserts
to clean orphaned vectors. Reduces per-save latency from O(n) to O(k)
where k = changed chunks."
```

---

## Execution Order

These are independent but have a natural order:

1. **Task 1 (#551)** — trivial fix, ship immediately
2. **Task 2 (#563)** — medium complexity, standalone
3. **Task 3 (#561)** — hardest, benefits from Task 2 being done first (pipeline already carries relationships)

## Risk Assessment

| Task | Risk | Mitigation |
|------|------|------------|
| #551 | Low — type signature change | Build test on both rc.11 and rc.12 |
| #563 | Medium — pipeline restructuring | Extensive integration tests exist; verify callers/callees/deps after index |
| #561 | Medium — new state management in watch | Post-filter results against SQLite; periodic full rebuild as safety net |
