# T8.3 + T8.4: Hybrid Query + Ask (Grounded Answer) — Final Implementation Plan

**Epic:** EPIC 8 — Search Pipelines
**Status:** Phase 1 (search/vsearch) complete; hybrid/ask partially implemented
**Owner:** (assign)
**Last updated:** 2025-12-26

---

## 1) Overview

This plan completes:

### T8.3 — `gno query` (hybrid pipeline)

Deliver a spec-compliant hybrid search command that combines:

- BM25 + vector retrieval (when available)
- optional query expansion (LLM generation)
- RRF fusion
- optional cross-encoder reranking + position-aware blending
- `--explain` to stderr (stable formatting)
- consistent output formatting (`terminal/json/files/csv/md/xml`)
- correct semantics for `--min-score`, `--full`, `--line-numbers`
- **hard guarantee:** scores in output are always in **[0, 1]**, including when reranking/models/vectors are unavailable.

### T8.4 — `gno ask` (grounded answer)

Ship a reliable citations-first ask command that:

- wraps `searchHybrid()` retrieval
- optionally generates a grounded answer when the user explicitly requests it
- **never silently fails**: if the user requests `--answer` and generation is unavailable/fails, command errors clearly (exit 2)
- aligns CLI flags with `spec/cli.md` (`--answer` default false; `--no-answer`; `--max-answer-tokens`)

---

## 2) Current State (What's already implemented)

### Pipeline layer

- **BM25 search**: `src/pipeline/search.ts`
  - `searchBm25(store, query, options)`
  - min-max normalization in `normalizeBm25Scores()` (0..1)
  - supports `--full` + de-dupe by docid
  - supports `--line-numbers` by using raw chunk text and `snippetRange`

- **Vector search**: `src/pipeline/vsearch.ts`
  - `searchVector()` / `searchVectorWithEmbedding()`
  - similarity normalization `1 - distance/2`
  - strict `--lang` filtering (chunk.language must match)
  - supports `--full` + docid de-dupe

- **Hybrid orchestrator (PARTIAL but substantial)**: `src/pipeline/hybrid.ts`
  - `checkBm25Strength()` gap-based metric (0..1)
  - optional expansion via `expandQuery()` (currently not cached)
  - BM25 retrieval for original query + lexical variants
  - vector retrieval for original + semantic variants + HyDE
  - fusion via `rrfFuse()`
  - rerank integration via `rerankCandidates()`
  - builds `SearchResults` with `meta.explain` when requested

- **Expansion**: `src/pipeline/expansion.ts`
  - `expandQuery()` (timeout + graceful degradation)
  - `expandQueryCached()` exists but not wired into hybrid

- **Fusion**: `src/pipeline/fusion.ts`
  - RRF with weights for original/variant/hyde
  - deterministic tie-breaking

- **Rerank**: `src/pipeline/rerank.ts`
  - rerank + blending schedule
  - **BUG:** when reranker is missing/fails, returns raw fusion scores (not normalized 0..1)

- **Explain formatting**: `src/pipeline/explain.ts`
  - `formatExplain()`, `formatResultExplain()`, builders

### CLI layer

- **`gno query` command exists**:
  - `src/cli/program.ts` wiring for `query`
  - `src/cli/commands/query.ts` exists but uses bespoke formatting and diverges from search/vsearch
  - missing `.option('--line-numbers')` and missing validation (empty query, minScore range) in query wiring

- **`gno ask` exists (SKELETON)**:
  - `src/cli/commands/ask.ts` calls `searchHybrid()`
  - grounded answer generation exists but can silently fail
  - CLI flags currently do not match spec defaults (`--[no-]answer` default true; `--max-tokens` mismatch)

- Shared formatter exists and is correct for search/vsearch:
  - `src/cli/format/search-results.ts` provides `formatSearchResults()` (supports full/lineNumbers and correct `--files` line protocol)

### Store layer

- Adapter now returns raw bm25() and surfaces FTS syntax errors as `INVALID_INPUT` (per recent integration changes).

---

## 3) Task Breakdown

### T8.3.1 — Fix hybrid score normalization invariants (rerank fallback)

**Problem**
Hybrid search must output scores in **[0,1]**. Today, if reranking is unavailable or fails, `rerankCandidates()` returns `blendedScore = fusionScore` (RRF score ≈ 0.0x), which:

- violates the output schema range expectations and UX semantics
- breaks `--min-score`
- can produce near-zero and even negative values (depending on normalization window + remaining candidates)

**Implementation details**
Modify `src/pipeline/rerank.ts` so that:

- fusion scores are min-max normalized to `[0,1]` across the **entire candidate set** (not only `toRerank`)
- fallback paths (no reranker / rerank failure) return normalized fusion scores
- remaining (non-reranked) candidates use normalized fusion with a penalty and clamping

#### Exact code change (replace the relevant block in `rerankCandidates()`)

Locate `rerankCandidates()` and apply the following patch pattern:

```ts
// Add this helper after you compute schedule/maxCandidates (near top of function)
// Normalize fusion scores to 0-1 range across ALL candidates for stability.
const fusionScoresAll = candidates.map((c) => c.fusionScore);
const minFusionAll = Math.min(...fusionScoresAll);
const maxFusionAll = Math.max(...fusionScoresAll);
const fusionRangeAll = maxFusionAll - minFusionAll;

function normalizeFusionScore(score: number): number {
  if (fusionRangeAll < 1e-9) {
    return 1; // tie for best
  }
  const v = (score - minFusionAll) / fusionRangeAll;
  return Math.max(0, Math.min(1, v));
}
```

Then replace the **no reranker** early return:

```ts
// OLD (buggy)
if (!rerankPort) {
  return {
    candidates: candidates.map((c, _i) => ({
      ...c,
      rerankScore: null,
      blendedScore: c.fusionScore,
    })),
    reranked: false,
  };
}
```

with:

```ts
// NEW (normalized)
if (!rerankPort) {
  return {
    candidates: candidates.map((c) => ({
      ...c,
      rerankScore: null,
      blendedScore: normalizeFusionScore(c.fusionScore),
    })),
    reranked: false,
  };
}
```

Then replace the **rerank failure** fallback:

```ts
// OLD (buggy)
if (!rerankResult.ok) {
  return {
    candidates: candidates.map((c) => ({
      ...c,
      rerankScore: null,
      blendedScore: c.fusionScore,
    })),
    reranked: false,
  };
}
```

with:

```ts
// NEW (normalized)
if (!rerankResult.ok) {
  return {
    candidates: candidates.map((c) => ({
      ...c,
      rerankScore: null,
      blendedScore: normalizeFusionScore(c.fusionScore),
    })),
    reranked: false,
  };
}
```

Finally, in the **success path**, remove the existing normalization based on `toRerank` only, and use the global `normalizeFusionScore()` for both reranked + remaining. Update remaining penalty to clamp:

```ts
// Remaining candidates: penalize but never go below 0
...remaining.map((c) => {
  const base = normalizeFusionScore(c.fusionScore);
  return {
    ...c,
    rerankScore: null,
    blendedScore: Math.max(0, Math.min(1, base * 0.5)),
  };
})
```

**Acceptance criteria**

- `SearchResult.score` produced by hybrid is always `0 <= score <= 1` across:
  - reranker missing
  - reranker failure
  - vectors unavailable
  - expansion missing
- Existing schema validation tests for search results continue to pass.
- `--min-score` becomes meaningful for `gno query`.

**Dependencies**

- None (should be done first because it unblocks correctness and tests).

---

### T8.3.2 — Apply `--min-score` consistently in hybrid pipeline

**Problem**
`searchBm25()` filters by `minScore` after normalization. Hybrid currently may not filter consistently by `options.minScore`, making CLI behavior unpredictable and docs misleading.

**Implementation details**

- In `src/pipeline/hybrid.ts`, apply `options.minScore` to final scored results.
- Prefer filtering at the candidate stage to avoid extra DB work:

Suggested approach:

1. After `rerankCandidates()` returns, filter candidates by `blendedScore >= minScore`.
2. Only build `SearchResult[]` from the filtered candidates (still capped by limit).

**Acceptance criteria**

- `gno query "<q>" --min-score 0.9` yields a strict subset of results compared to no filter.
- Works the same regardless of reranking availability.

**Dependencies**

- Depends on **T8.3.1** (so `blendedScore` is consistently 0..1).

---

### T8.3.3 — Implement `--full` support in hybrid results (de-dupe + fetch)

**Problem**
Hybrid results currently return chunk snippets only. Spec requires `gno query --full` to return full mirror content and avoid duplicating the same doc many times.

**Implementation details**
In `src/pipeline/hybrid.ts` while building results:

- If `options.full`:
  - de-dupe by `docid` (keep best scoring candidate per doc)
  - fetch full content via `store.getContent(mirrorHash)` when available
  - set `snippetRange: undefined` when full content is used
- If full content is unavailable (missing mirror), fall back to chunk snippet + range.

Notes:

- Keep `source` metadata unchanged.
- Ensure `formatSearchResults()` can still apply line numbers:
  - for full content it will start at line 1 (already implemented in formatter)

**Acceptance criteria**

- `gno query "<q>" --full --json` produces at most one result per docid.
- When full content is used, `snippetRange` is omitted/undefined.

**Dependencies**

- Should follow **T8.3.2** (to avoid fetching full content for candidates that will be filtered out).

---

### T8.3.4 — Make `gno query` formatting consistent with `search`/`vsearch`

**Problem**
`src/cli/commands/query.ts` has bespoke formatters:

- `--files` is wrong (paths only instead of line protocol)
- line numbers are not supported consistently
- diverges from shared `formatSearchResults()` implementation

**Implementation details**

1. Update `src/cli/program.ts` query command to:
   - add `.option('--line-numbers', ...)`
   - validate empty query (`queryText.trim()`)
   - validate `--min-score` in range [0,1] (same as search/vsearch)
2. Update `src/cli/commands/query.ts`:
   - remove bespoke terminal/md/csv/xml/files formatters
   - reuse `formatSearchResults()` from `src/cli/format/search-results.ts`
   - keep JSON output via formatter as well (consistent)
3. Keep explain printing but use pipeline explain formatters:
   - `formatExplain()` + `formatResultExplain()`

**Acceptance criteria**

- `gno query --files` emits `#docid,0.xxxx,gno://...`
- `gno query --line-numbers` works for terminal/md/xml
- Output formats match behavior of `gno search` / `gno vsearch`

**Dependencies**

- Depends on **T8.3.3** only insofar as `--full` now works end-to-end.

---

### T8.3.5 — Explain output: single source of truth

**Problem**
Explain formatting is duplicated between pipeline and CLI, causing drift.

**Implementation details**

- In `src/cli/commands/query.ts`, replace any manual explain formatting with:

```ts
import { formatExplain, formatResultExplain } from "../../pipeline/explain";

if (data.meta.explain) {
  process.stderr.write(`${formatExplain(data.meta.explain.lines)}\n`);
  process.stderr.write(`${formatResultExplain(data.meta.explain.results)}\n`);
}
```

**Acceptance criteria**

- `gno query --explain` matches `spec/cli.md` explain examples.
- Output stable across releases.

**Dependencies**

- None (but easiest after T8.3.4 refactor).

---

### T8.4.1 — Align `gno ask` flags with spec and correct defaults

**Problem**
Current wiring deviates from `spec/cli.md`:

- `--[no-]answer` default true (should be default false)
- option name mismatch: `--max-tokens` vs `--max-answer-tokens`

**Implementation details**

- In `src/cli/program.ts` ask command:
  - replace `.option('--[no-]answer', ..., true)` with:
    - `.option('--answer', 'Generate short grounded answer')`
    - `.option('--no-answer', 'Force retrieval-only output')`
  - replace `--max-tokens` with `--max-answer-tokens <num>`
  - update passing options into `ask()`:
    - `answer: Boolean(cmdOpts.answer)`
    - `noAnswer: Boolean(cmdOpts.noAnswer)`
    - `maxAnswerTokens: parsePositiveInt('max-answer-tokens', cmdOpts.maxAnswerTokens)`

Optional: support `--max-tokens` as a deprecated alias for backward compatibility.

**Acceptance criteria**

- `gno ask "<q>"` does retrieval-only output by default.
- `gno ask "<q>" --answer` requests generation.

**Dependencies**

- None.

---

### T8.4.2 — Eliminate silent failures when `--answer` is requested

**Problem**
Ask currently can "just not produce an answer" when requested because:

- gen model couldn't load
- generation failed
- results empty
  …and it returns success anyway. This breaks trust.

**Implementation details**
In `src/cli/commands/ask.ts`:

- Define clear behavior:
  - If `--answer` explicitly set and `--no-answer` not set:
    - If `genPort` is unavailable → return error (exit 2) with a fix suggestion (`gno models pull` or configure preset).
    - If `generate()` fails → return error (exit 2).
  - If results are empty → do **not** attempt generation; return success with "no relevant sources found" behavior.
- Make answer generation return structured failure instead of `null` on gen failure when `--answer` requested.

**Acceptance criteria**

- `gno ask "<q>" --answer` without generation model returns exit 2 with clear error.
- `gno ask "<q>"` (no answer requested) never fails due to missing gen model.

**Dependencies**

- Depends on **T8.4.1** (correct flag wiring).

---

### T8.4.3 — Bound grounded answer context size + citation integrity

**Problem**
Unbounded snippet context can blow up prompt size and latency. Citations must correspond to included context blocks.

**Implementation details**

- In `generateGroundedAnswer()`:
  - cap each snippet to a max char length (e.g. 1200–2000 chars)
  - cap number of sources (already at top 5)
  - ensure citations array matches the numbered context blocks exactly

**Acceptance criteria**

- Answer generation does not exceed safe prompt size for typical models.
- Citations always refer to sources included in the context.

**Dependencies**

- None (but do after T8.4.2 since it touches same code).

---

## 4) File Changes Summary

| File                                         | Type   | Change                                                                                       |
| -------------------------------------------- | ------ | -------------------------------------------------------------------------------------------- |
| `src/pipeline/rerank.ts`                     | modify | **Normalize fusion scores globally**; ensure fallback paths output `[0,1]`                   |
| `src/pipeline/hybrid.ts`                     | modify | Apply `minScore`; implement `--full` de-dupe + full content fetch                            |
| `src/cli/program.ts`                         | modify | Add query `--line-numbers` + validation; fix ask flags to match spec                         |
| `src/cli/commands/query.ts`                  | modify | Replace bespoke formatting with `formatSearchResults`; use pipeline explain formatters       |
| `src/cli/commands/ask.ts`                    | modify | Fix default behavior; fail loudly when `--answer` requested and gen unavailable; cap context |
| `test/cli/query-smoke.test.ts`               | create | Deterministic smoke tests for query formatting + explain + schema                            |
| `test/cli/ask-smoke.test.ts`                 | create | Deterministic smoke tests for ask default + `--answer` failure behavior                      |
| `test/pipeline/rerank-normalization.test.ts` | create | Unit tests enforcing blended score invariants in all rerank paths                            |

---

## 5) Test Plan

### A) CLI smoke tests (deterministic)

Create `test/cli/query-smoke.test.ts`:

- Setup fixture corpus (reuse harness pattern from `search-smoke.test.ts`)
- Tests:
  1. `gno query "markdown" --json` validates against `search-results.schema.json`
  2. Scores are all in `[0,1]`
  3. `gno query "markdown" --files` matches line protocol `^#[a-f0-9]+,\d+\.\d{4},gno://`
  4. `gno query "markdown" --line-numbers` contains line prefixes `\d+:`
  5. `gno query "markdown" --explain` writes `[explain]` lines to stderr and still returns valid stdout

Create `test/cli/ask-smoke.test.ts`:

- Tests:
  1. `gno ask "markdown" --json` validates against `ask.schema.json`
  2. default behavior: no `answer` field unless `--answer`
  3. `gno ask "markdown" --answer` without models fails with exit code 2 and helpful message

### B) Pipeline unit tests (fast + surgical)

Create `test/pipeline/rerank-normalization.test.ts`:

- Build synthetic candidates with varying fusionScore (including small numbers)
- Case 1: `rerankPort = null` → all `blendedScore` in [0,1]
- Case 2: rerank returns error → all `blendedScore` in [0,1]
- Case 3: rerank returns scores → blended scores in [0,1] and stable ordering

(If you want extra coverage: include "remaining candidates" having fusionScore outside the `toRerank` range to ensure clamping works.)

---

## 6) Definition of Done (Checklist)

### T8.3 — Query

- [ ] Hybrid rerank fallback always returns normalized scores in `[0,1]`
- [ ] `--min-score` filters hybrid results correctly
- [ ] `--full` returns full mirror content and de-dupes per docid
- [ ] `--line-numbers` works for `gno query`
- [ ] `--files` uses line protocol (not paths)
- [ ] `--explain` uses pipeline explain formatting (no drift)
- [ ] `test/cli/query-smoke.test.ts` passing in CI
- [ ] `test/pipeline/rerank-normalization.test.ts` passing

### T8.4 — Ask

- [ ] CLI flags match `spec/cli.md` (`--answer` default false; `--no-answer`; `--max-answer-tokens`)
- [ ] `--answer` never silently fails: missing model or gen failure returns exit 2 + message
- [ ] Answer prompt context is bounded; citations stable
- [ ] `test/cli/ask-smoke.test.ts` passing in CI

### Quality gates

- [ ] `bun test` passes
- [ ] Output JSON validates against existing schemas
- [ ] No regression in Phase 1 smoke tests

---

## 7) Risk Assessment

| Risk                                                               |   Likelihood |                             Impact | Mitigation                                                      |
| ------------------------------------------------------------------ | -----------: | ---------------------------------: | --------------------------------------------------------------- |
| Hybrid scores out of [0,1] when rerank missing/fails               | High (today) | High (schema + UX + `--min-score`) | T8.3.1 normalization fix + tests                                |
| Query CLI output diverges from search/vsearch formatting contracts |       Medium |                             Medium | Reuse `formatSearchResults` everywhere                          |
| Ask flag defaults mismatching spec causes UX confusion             |         High |                             Medium | Fix wiring + docs + tests                                       |
| Answer generation "silent failure" breaks user trust               |         High |                               High | Make `--answer` fail loudly                                     |
| Performance: `store.listDocuments()` every query scales poorly     |       Medium |         Medium/High on big corpora | Later optimization: fetch doc metadata by mirrorHash set only   |
| LLM availability & model loading time                              |         High |                             Medium | clear errors; degrade gracefully except for explicit `--answer` |

---

## 8) Overall Assessment

**Needs Work (not a major rethink).**

The architecture is mostly fine: separate pipeline stages, sensible types, and degradation paths. The biggest correctness issue is **hybrid score normalization when reranking is unavailable**; once that's fixed and the query CLI reuses the shared formatter, the implementation will be solid.

Implementation order:

1. T8.3.1 (rerank normalization) - unblocks everything
2. T8.3.2 (min-score) - depends on T8.3.1
3. T8.3.3 (--full) - depends on T8.3.2
4. T8.3.4 (formatting consistency) - can be parallel with T8.3.3
5. T8.3.5 (explain consolidation) - after T8.3.4
6. T8.4.1 (ask flags) - independent
7. T8.4.2 (silent failure fix) - depends on T8.4.1
8. T8.4.3 (context bounds) - after T8.4.2
