Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,9 @@ jobs:
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
timeout-minutes: 20
# 25 (was 20): the handler package alone may take up to its 15m package
# budget on slow nightly runners — see the -timeout note below.
timeout-minutes: 25
needs: [unit-tests]
permissions:
contents: read
Expand Down Expand Up @@ -512,11 +514,17 @@ jobs:
- name: Run integration tests
working-directory: go
run: |
# -timeout is PER PACKAGE. internal/handler legitimately exceeded
# 10m on nightly runners twice (2026-07-26 + 2026-08-02): ~500
# testcontainer starts across the suite under slow shared-runner
# I/O — the "hanging" test in both panic dumps had run for only
# 2s, it was merely the one on the clock when the package budget
# expired. 15m absorbs runner weather; a REAL hang still fails.
go test \
-v \
-tags=integration \
-count=1 \
-timeout=10m \
-timeout=15m \
-coverprofile=coverage-integration.out \
-covermode=atomic \
./...
Expand Down
9 changes: 8 additions & 1 deletion CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,14 @@ else's environment — that led to a fix landing in the repository.
"<type>"}` string map with no confidence field, losing every link in the
cycle; the shipped fix keeps his recover-the-type approach and floor
doctrine, hardened with entry discrimination so prose envelopes still
surface as retryable parse errors.
surface as retryable parse errors. Also PR
[#13](https://github.com/GottZ/ctx/pull/13): Voyage AI rerankers answer in
their OpenAPI dialect (`data` container, `usage.total_tokens`), which the
cohere-shaped client decoded to zero results — every Voyage call failed
into the un-reranked fail-open order; the shipped fix keeps his
prefer-results-fall-back-to-data approach, hardened with a probability→
logit mapping at the wire boundary so Voyage's calibrated [0,1] scores
survive the downstream sigmoid+blend unmangled.

## How to be listed

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ On first boot with an empty table, ctxd seeds it from the effective config snaps

**Synthesis on the pool chain** (054) — query-path synthesis walks the role chain from `context_backends` (priority-ordered, cooldown-sorted; the chain is the ONLY way to a backend, so the trust gate sits structurally before prompt transmission). Transport-class failures advance to the next backend (e.g. the `llama-cpu` sidecar at priority 10: same GGUF, CPU speed, its own per-role timeout); HTTP-500 and attempt timeouts stop the chain — the server *ran* the request, slow-but-alive is not down. The response heartbeat starts whenever synthesis is on (`synthesize != false`), so a CPU-leg answer survives buffering proxies even with rerank off. Since 055 the WHOLE query path resolves through the chain (translate, temporal, query-embed, rerank dispatch, inline backfill) with a real requirement: `max(query sensitivity, sensitivity of the FINAL prompt set)` — measured after rank filtering, so a credentials block on rank 180 that never enters the prompt cannot lock the failover. An empty dream chain (gaming/disabled/trust) skips the cycle BEFORE the block pick — no claim, no cooldown touch, so a gaming session never smears the back-off statistics.

**Rerank sidecar.** Post-RRF rerank (default-on since Wave 3.5, fail-open) via a local **bge-reranker-v2-m3** cross-encoder sidecar (`http://ctx-rerank:8082`, cohere-style `/v1/rerank`, all-local/$0) or LLM-as-judge on the chat model when `_HOST` is empty. The surface-gold counter-probe showed the cross-encoder earns its keep (nDCG@10 +0.164, MRR +0.169) while blend 0.5 keeps it neutral on latent gold — `graph+ce-bw0.5` is the best arm on both gold sets; the ~80–90s query path stays proxy-safe via the body heartbeat.
**Rerank sidecar.** Post-RRF rerank (default-on since Wave 3.5, fail-open) via a local **bge-reranker-v2-m3** cross-encoder sidecar (`http://ctx-rerank:8082`, cohere-style `/v1/rerank`, all-local/$0) or LLM-as-judge on the chat model when `_HOST` is empty. Voyage AI rerankers (`rerank-2.5`/`-lite`) work as a remote alternative: the client accepts their OpenAPI dialect — results under `data` instead of `results`, `usage.total_tokens` instead of `prompt_tokens` — and maps Voyage's calibrated [0,1] relevance through logit() at the wire boundary, so the raw-logit score contract (and the downstream sigmoid+blend) is backend-invariant. Container name and score domain are coupled backend properties; a `data` score outside [0,1] or an entry missing `relevance_score` errors → fail-open to the pre-rerank order. The surface-gold counter-probe showed the cross-encoder earns its keep (nDCG@10 +0.164, MRR +0.169) while blend 0.5 keeps it neutral on latent gold — `graph+ce-bw0.5` is the best arm on both gold sets; the ~80–90s query path stays proxy-safe via the body heartbeat.

## Supporting mechanisms

Expand Down
2 changes: 1 addition & 1 deletion docs/operations.md

Large diffs are not rendered by default.

114 changes: 108 additions & 6 deletions go/internal/rerank/rerank.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@
// 0-1 probability. Score() returns it verbatim; the caller decides whether
// to sigmoid / normalize (the rrf layer does, see RerankCrossEncoder).
//
// Voyage AI (rerank-2.5/-lite) is supported as an alternative backend. Its
// wire format differs in two coupled ways: results live under "data" (OpenAPI
// RerankingObject) and relevance_score is a calibrated [0,1] relevance — not
// a logit. Score() maps those probabilities through the logit function at the
// wire boundary so the return contract above (raw logits) holds for every
// backend and the caller's sigmoid reconstructs the original probability.
//
// Source: https://github.com/GottZ/ctx
package rerank

Expand All @@ -24,7 +31,10 @@ import (
"encoding/json"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"sync"
"time"

"github.com/GottZ/ctx/internal/httpx"
Expand Down Expand Up @@ -56,18 +66,31 @@ type rerankRequest struct {
}

type rerankResult struct {
Index int `json:"index"`
RelevanceScore float64 `json:"relevance_score"`
Index int `json:"index"`
// RelevanceScore is a pointer so an absent field is distinguishable
// from a legitimate 0.0: sibling rerank dialects name this field
// "score" (mixedbread, some gateways), and silently scoring every
// document 0 would neutralize the reranker while telemetry reports a
// successful call. Absent ⇒ error ⇒ caller fails open.
RelevanceScore *float64 `json:"relevance_score"`
}

type rerankResponse struct {
Results []rerankResult `json:"results"`
// Voyage AI returns results under "data" instead of "results"
// (OpenAPI RerankingObject schema). Kept raw and decoded lazily:
// "data" is also the generic OpenAI-style list container, so a
// backend serving valid "results" alongside a non-array "data"
// field must not fail the whole decode over a field we ignore.
Data json.RawMessage `json:"data"`
// Usage is llama.cpp's Jina-style token accounting on the rerank
// response. prompt_tokens covers ALL query+document pairs of the request
// — the MW22/C1 charge dimension of the sidecar call. Absent field ⇒ 0 ⇒
// the call site charges nothing (uncharged, never an estimate).
// Voyage reports total_tokens instead of prompt_tokens.
Usage struct {
PromptTokens int `json:"prompt_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}

Expand Down Expand Up @@ -113,11 +136,42 @@ func Score(ctx context.Context, host, apiKey, model, query string, docs []string
return nil, 0, fmt.Errorf("rerank: %w", httpx.NewStatusError(resp, errBody))
}

// Buffer the body (rerank responses are a few KB — MAX_DOCS entries of
// ~100 bytes; 1 MiB is a generous ceiling) so an unrecognized dialect
// can echo a snippet of what the server actually sent. The original
// Voyage incident hid behind a generic "count mismatch" for six
// production calls because the response was decoded blind.
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, 0, fmt.Errorf("rerank: read body: %w", err)
}
var result rerankResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
if err := json.Unmarshal(raw, &result); err != nil {
return nil, 0, fmt.Errorf("rerank: decode: %w", err)
}

// Voyage AI returns results under "data" (OpenAPI RerankingObject),
// while cohere/llama.cpp uses "results". Prefer "results"; fall back
// to "data" when "results" is absent. The container name is coupled to
// the score domain: "data" backends (Voyage) report calibrated [0,1]
// relevance, "results" backends (cohere/llama.cpp) report raw logits.
entries := result.Results
fromData := false
if len(entries) == 0 && len(result.Data) > 0 {
if err := json.Unmarshal(result.Data, &entries); err != nil {
return nil, 0, fmt.Errorf("rerank: decode data: %w; body: %.256s", err, raw)
}
fromData = true
}
if len(entries) == 0 {
// Neither container decoded any entries: an unknown backend
// dialect, a gateway error object behind HTTP 200, or a renamed
// results field. Name the schema break and echo a body snippet —
// "count mismatch" would misdiagnose this as a counting problem
// (the exact failure mode that masked the Voyage incident).
return nil, 0, fmt.Errorf("rerank: response contains neither \"results\" nor \"data\" entries (unknown backend dialect?); body: %.256s", raw)
}

// Re-align strictly by result.Index into input order. The server sorts
// results by score descending (and may resize to top_n), so array position
// is meaningless — only Index maps a score back to its document. Validate
Expand All @@ -126,20 +180,68 @@ func Score(ctx context.Context, host, apiKey, model, query string, docs []string
scores := make([]float64, len(docs))
seen := make([]bool, len(docs))
got := 0
for _, r := range result.Results {
for _, r := range entries {
if r.Index < 0 || r.Index >= len(docs) {
return nil, 0, fmt.Errorf("rerank: result index %d out of range [0,%d)", r.Index, len(docs))
}
if seen[r.Index] {
return nil, 0, fmt.Errorf("rerank: duplicate result index %d", r.Index)
}
seen[r.Index] = true
scores[r.Index] = r.RelevanceScore
if r.RelevanceScore == nil {
return nil, 0, fmt.Errorf("rerank: result index %d missing relevance_score (unknown score field name?)", r.Index)
}
score := *r.RelevanceScore
if fromData {
// Voyage's calibrated [0,1] relevance would be sigmoided a
// second time by the caller (rrf treats every score as a
// logit), compressing the signal into [0.5,0.73] and letting
// RRF outvote the reranker at blend_weight < 1. Map the
// probability to its logit here so the downstream sigmoid
// reconstructs it exactly. A score outside [0,1] breaks the
// documented Voyage schema — error → caller fails open.
if score < 0 || score > 1 {
return nil, 0, fmt.Errorf("rerank: data score %g at index %d outside [0,1] (voyage schema violation)", score, r.Index)
}
score = probabilityToLogit(score)
}
scores[r.Index] = score
got++
}
if got != len(docs) {
return nil, 0, fmt.Errorf("rerank: result count mismatch: got %d scores for %d documents", got, len(docs))
}

return scores, result.Usage.PromptTokens, nil
// Voyage reports total_tokens; llama.cpp reports prompt_tokens.
promptTokens := result.Usage.PromptTokens
if promptTokens == 0 && result.Usage.TotalTokens > 0 {
promptTokens = result.Usage.TotalTokens
// This flips the MW22 meter semantics for total_tokens-only
// backends from "uncharged" to a real measured charge — make
// the switch findable in the log, once per process (rerank
// runs per query; per-call INFO would be noise).
totalTokensFallbackOnce.Do(func() {
slog.Info("rerank: backend reports usage.total_tokens only; charging it as prompt tokens (previously uncharged)")
})
}

return scores, promptTokens, nil
}

var totalTokensFallbackOnce sync.Once

// probabilityToLogit maps a calibrated probability to its logit,
// logit(p) = ln(p/(1-p)), the exact inverse of the caller's sigmoid.
// p is clamped away from the exact endpoints so 0 and 1 (legitimately
// producible by a quantized probability) yield large finite logits
// instead of ±Inf, which would poison the downstream blend arithmetic.
func probabilityToLogit(p float64) float64 {
const eps = 1e-7
if p < eps {
p = eps
}
if p > 1-eps {
p = 1 - eps
}
return math.Log(p / (1 - p))
}
Loading
Loading