Writing Benchmark Fixtures

May 23, 2026 · View on GitHub

Fixtures live in internal/store/testdata/bench/. One JSONL file per content type, one JSON object per line.

Schema

Each line is a JSON object with this structure:

{
  "id": "md_001",
  "content_type": "markdown",
  "haystack": "# Heading\n\nContent to index...",
  "source_label": "bench-markdown-001",
  "source_kind": "durable",
  "cases": [
    {
      "case_id": "md_001_q1",
      "query": "search query terms",
      "needles": ["substring that must appear in results"],
      "expected_layer": "rrf(porter+trigram)",
      "expected_rank_ceiling": 3
    }
  ]
}

Fields:

FieldRequiredDescription
idYesUnique entry ID. Convention: {type_prefix}_{NNN} (e.g., md_001, json_005).
content_typeYesOne of: markdown, json, plaintext, transcript, curated. Determines which indexing method is used.
haystackYesThe content to index. Should be realistic — representative of actual tool output, docs, API responses, logs, or knowledge entries.
source_labelYesLabel for the indexed source. Convention: bench-{type}-{NNN}.
source_kindYesOne of: durable, ephemeral, session. Affects search visibility — benchmarks use benchSearchOpts() which includes all three kinds.
casesYesArray of test cases (queries to run against this haystack). Aim for 3-5 cases per entry.

Case fields:

FieldRequiredDescription
case_idYesUnique case ID. Convention: {entry_id}_q{N} for positive, {entry_id}_q_neg for negative.
queryYesSearch query to run.
needlesYesSubstrings that must appear in search results. Empty [] = negative case (expects zero results).
expected_layerYesWhich search layer should resolve this query (see below).
expected_rank_ceilingYesMaximum acceptable rank for the first relevant result. Set to 0 to skip rank check.

Content Types and Indexing Methods

content_typeIndex methodWhen to use
markdownIndex() with auto-detectDocumentation, READMEs, design docs. Heading-aware chunking preserves H1-H4 context.
jsonIndexJSON()API responses, config files, structured data. Recursive key-path chunking.
plaintextIndexPlainText()Logs, test output, build output. Paragraph-split chunking.
transcriptIndexChunked()Conversation transcripts in Human:/Assistant: format.
curatedIndex() with auto-detectADRs, conventions, review findings. User-curated knowledge.

Expected Match Layers

capy's search uses two-layer RRF (Reciprocal Rank Fusion) — Porter stemming and trigram substring run in parallel and results are fused. Most queries resolve via both layers simultaneously.

Query typeexpected_layerExample
Exact terms present in contentrrf(porter+trigram)"database connection pooling"
Deliberate typos/misspellingsfuzzy+rrf(porter+trigram)"cnofiguration poolign"
Only trigram can match (substrings)trigramRare — only when Porter stemming cannot match
Negative (should return nothing)noneFabricated terms

Single-layer values like porter are only correct when the other layer genuinely cannot match, which is rare.

Needles

Needles are substrings checked against search result content. A result is "relevant" if it contains ANY needle (for R@K, MRR, NDCG). Context Recall is fractional: distinct needles found / total needles.

Good needles:

  • Specific strings that appear verbatim in the haystack: `maxIdleConns` defaults to 10
  • Structural context (headings, JSON keys): "## Connection Pool Settings"
  • Unique identifiers unlikely to appear in other entries

Bad needles:

  • Generic terms that appear in many entries (causes false relevance)
  • Substrings of common words (triggers unintended matches)

Negative Cases

Negative cases have needles: [] and must return zero results. Any non-zero result is a failure.

The fuzzy corrector and trigram index are aggressive. Real English words — even domain-unrelated ones like "pipeline", "simulation", or 3-letter words like "tun" — will fuzzy-match or trigram-match into a software corpus. Use fabricated or ultra-domain-specific terms (e.g., paleontology Latin nomenclature: "anomalocaris opabinia hallucigenia") that share no substrings with the corpus vocabulary.

JSON Rank Ceilings

The JSON chunker splits content along key-path boundaries. Deeply nested structures (e.g., metrics.http.p99_latency_ms) may spread across multiple chunks, making rank predictions unreliable. Use generous rank ceilings (5-10) for JSON fixtures, or set expected_rank_ceiling to 0 to skip the check entirely.

Corpus Density

Aim for at least 10 entries per content type with 3-5 cases each. BM25 IDF calculations need enough documents for term frequencies to be meaningful — too few entries and every term gets high IDF regardless of actual distinctiveness.

Dataset Hash

All fixture files are hashed together into a single manifest hash embedded in the JSON report. qualstat refuses to compare reports with different hashes. This means any fixture change — even a single character — invalidates all existing reports. After modifying fixtures, re-run make bench on both branches before comparing.