chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
package quality
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"os"
"path/filepath"
"sort"
"time"
"github.com/zzet/gortex/internal/platform"
)
// ConfidenceRecord captures the candidate-score distribution for
// one search call. The distribution shape (top-1 vs top-2 gap,
// std-dev, ratio) tells you how confident the ranker was — a sharp
// top-1 with a long tail is a high-confidence answer; a flat
// distribution is the ranker shrugging.
type ConfidenceRecord struct {
TS time.Time `json:"ts"`
Query string `json:"query"`
Top1 float64 `json:"top1"`
Top2 float64 `json:"top2,omitempty"`
Mean float64 `json:"mean"`
StdDev float64 `json:"std_dev"`
Ratio12 float64 `json:"ratio_top1_top2,omitempty"` // top1 / top2; >>1 = confident
K int `json:"k"` // number of scored candidates
}
// ConfidenceFromScores derives a ConfidenceRecord from a slice of
// per-candidate scores. Negative / zero K returns a zero record so
// the caller can opt out by passing an empty slice.
func ConfidenceFromScores(query string, scores []float64) ConfidenceRecord {
r := ConfidenceRecord{
TS: time.Now().UTC(),
Query: query,
K: len(scores),
}
if len(scores) == 0 {
return r
}
sorted := make([]float64, len(scores))
copy(sorted, scores)
sort.Sort(sort.Reverse(sort.Float64Slice(sorted)))
r.Top1 = sorted[0]
if len(sorted) > 1 {
r.Top2 = sorted[1]
if r.Top2 != 0 {
r.Ratio12 = r.Top1 / r.Top2
}
}
var sum float64
for _, v := range scores {
sum += v
}
r.Mean = sum / float64(len(scores))
var ss float64
for _, v := range scores {
ss += (v - r.Mean) * (v - r.Mean)
}
r.StdDev = math.Sqrt(ss / float64(len(scores)))
return r
}
// ConfidenceTracker appends records to a JSONL log on disk. Safe
// for in-process concurrent appends because the file is opened
// with O_APPEND.
type ConfidenceTracker struct {
Path string
}
// NewConfidenceTracker returns a tracker bound to a log path.
// Empty path is allowed — Record then no-ops without erroring.
func NewConfidenceTracker(path string) *ConfidenceTracker {
return &ConfidenceTracker{Path: path}
}
// DefaultConfidenceLogPath is the canonical persistence location.
// An absolute $XDG_CACHE_HOME is honoured; otherwise the log stays
// under os.UserCacheDir() as before. Returns empty when no cache dir
// can be resolved.
func DefaultConfidenceLogPath() string {
if v := os.Getenv("XDG_CACHE_HOME"); v == "" || !filepath.IsAbs(v) {
if base, err := os.UserCacheDir(); err != nil || base == "" {
return ""
}
}
return filepath.Join(platform.OSCacheDir(), "confidence.jsonl")
}
// Record appends one record to the log. No-op when Path is empty.
func (t *ConfidenceTracker) Record(r ConfidenceRecord) error {
if t.Path == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(t.Path), 0o755); err != nil {
return err
}
f, err := os.OpenFile(t.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
defer f.Close()
body, err := json.Marshal(r)
if err != nil {
return err
}
body = append(body, '\n')
_, err = f.Write(body)
return err
}
// LoadConfidenceLog reads the log at path and returns records with
// ts >= since (zero since returns all). Malformed lines are
// skipped so a previous crash mid-write doesn't break readers.
func LoadConfidenceLog(path string, since time.Time) ([]ConfidenceRecord, error) {
if path == "" {
return nil, nil
}
f, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("open confidence log: %w", err)
}
defer f.Close()
r := bufio.NewReaderSize(f, 64*1024)
out := []ConfidenceRecord{}
for {
line, err := r.ReadBytes('\n')
if len(line) > 0 {
if n := len(line); n > 0 && line[n-1] == '\n' {
line = line[:n-1]
}
if len(line) > 0 {
var rec ConfidenceRecord
if json.Unmarshal(line, &rec) == nil {
if since.IsZero() || !rec.TS.Before(since) {
out = append(out, rec)
}
}
}
}
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return out, fmt.Errorf("read confidence log: %w", err)
}
}
return out, nil
}
// ConfidenceSummary aggregates a slice of records into the headline
// statistics the `gortex eval quality confidence` subcommand emits.
type ConfidenceSummary struct {
Count int `json:"count"`
MedianTop1 float64 `json:"median_top1"`
MedianRatio12 float64 `json:"median_ratio_top1_top2"`
MedianStdDev float64 `json:"median_std_dev"`
LowConfidenceCount int `json:"low_confidence_count"` // records with Ratio12 < 1.25
}
// SummarizeConfidence reduces a slice of records into the summary.
// Median across records; "low confidence" = Ratio12 < 1.25 (the
// ranker barely separated top-1 from top-2).
func SummarizeConfidence(records []ConfidenceRecord) ConfidenceSummary {
s := ConfidenceSummary{Count: len(records)}
if len(records) == 0 {
return s
}
top1s := make([]float64, 0, len(records))
ratios := make([]float64, 0, len(records))
stds := make([]float64, 0, len(records))
for _, r := range records {
top1s = append(top1s, r.Top1)
stds = append(stds, r.StdDev)
if r.Ratio12 > 0 {
ratios = append(ratios, r.Ratio12)
}
if r.Ratio12 > 0 && r.Ratio12 < 1.25 {
s.LowConfidenceCount++
}
}
s.MedianTop1 = medianFloats(top1s)
s.MedianRatio12 = medianFloats(ratios)
s.MedianStdDev = medianFloats(stds)
return s
}
func medianFloats(vs []float64) float64 {
if len(vs) == 0 {
return 0
}
c := make([]float64, len(vs))
copy(c, vs)
sort.Float64s(c)
return c[len(c)/2]
}
+153
View File
@@ -0,0 +1,153 @@
package quality
import (
"math"
"os"
"path/filepath"
"testing"
"time"
)
func TestConfidenceFromScores_Basic(t *testing.T) {
scores := []float64{0.95, 0.7, 0.5, 0.3}
r := ConfidenceFromScores("q", scores)
if r.Top1 != 0.95 || r.Top2 != 0.7 {
t.Errorf("top1/top2 = %.2f/%.2f, want 0.95/0.7", r.Top1, r.Top2)
}
wantRatio := 0.95 / 0.7
if math.Abs(r.Ratio12-wantRatio) > 1e-9 {
t.Errorf("Ratio12 = %.4f, want %.4f", r.Ratio12, wantRatio)
}
if r.K != 4 {
t.Errorf("K = %d, want 4", r.K)
}
}
func TestConfidenceFromScores_Empty(t *testing.T) {
r := ConfidenceFromScores("q", nil)
if r.K != 0 || r.Top1 != 0 || r.Top2 != 0 {
t.Errorf("empty input should yield zero record, got %+v", r)
}
}
func TestConfidenceFromScores_SingleScore(t *testing.T) {
r := ConfidenceFromScores("q", []float64{0.5})
if r.Top1 != 0.5 || r.Top2 != 0 || r.Ratio12 != 0 {
t.Errorf("single-score = %+v, want top1=0.5 others=0", r)
}
}
func TestConfidenceTracker_RoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "conf.jsonl")
tr := NewConfidenceTracker(path)
for i := range 3 {
rec := ConfidenceFromScores(
"q",
[]float64{0.9, 0.8 - 0.1*float64(i), 0.5, 0.4},
)
if err := tr.Record(rec); err != nil {
t.Fatal(err)
}
}
got, err := LoadConfidenceLog(path, time.Time{})
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Errorf("loaded %d records, want 3", len(got))
}
}
func TestConfidenceTracker_EmptyPathNoop(t *testing.T) {
tr := NewConfidenceTracker("")
if err := tr.Record(ConfidenceFromScores("q", []float64{1})); err != nil {
t.Errorf("empty-path tracker should no-op, got %v", err)
}
}
func TestLoadConfidenceLog_FiltersSince(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "conf.jsonl")
tr := NewConfidenceTracker(path)
now := time.Now().UTC()
old := ConfidenceFromScores("old", []float64{0.5})
old.TS = now.Add(-48 * time.Hour)
_ = tr.Record(old)
recent := ConfidenceFromScores("recent", []float64{0.7})
recent.TS = now
_ = tr.Record(recent)
got, _ := LoadConfidenceLog(path, now.Add(-1*time.Hour))
if len(got) != 1 {
t.Errorf("since-filter = %d records, want 1", len(got))
}
if got[0].Query != "recent" {
t.Errorf("kept wrong record: %+v", got[0])
}
}
func TestLoadConfidenceLog_MissingFile(t *testing.T) {
got, err := LoadConfidenceLog(filepath.Join(t.TempDir(), "missing.jsonl"), time.Time{})
if err != nil {
t.Errorf("missing file should not error, got %v", err)
}
if len(got) != 0 {
t.Errorf("missing file should yield empty, got %d", len(got))
}
}
func TestLoadConfidenceLog_TolerateMalformedLines(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "conf.jsonl")
body := `{"ts":"2026-05-18T10:00:00Z","query":"good","top1":0.9,"k":1}` + "\n" +
`{not json}` + "\n" +
`{"ts":"2026-05-18T11:00:00Z","query":"also-good","top1":0.8,"k":1}` + "\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
got, err := LoadConfidenceLog(path, time.Time{})
if err != nil {
t.Fatalf("malformed-line tolerance failed: %v", err)
}
if len(got) != 2 {
t.Errorf("got %d records, want 2 (malformed dropped)", len(got))
}
}
func TestSummarizeConfidence(t *testing.T) {
records := []ConfidenceRecord{
{Top1: 0.9, Top2: 0.7, Ratio12: 1.286, StdDev: 0.2},
{Top1: 0.5, Top2: 0.45, Ratio12: 1.11, StdDev: 0.1}, // low confidence
{Top1: 0.8, Top2: 0.3, Ratio12: 2.67, StdDev: 0.3},
}
s := SummarizeConfidence(records)
if s.Count != 3 {
t.Errorf("count = %d, want 3", s.Count)
}
if s.LowConfidenceCount != 1 {
t.Errorf("low confidence = %d, want 1 (Ratio12 < 1.25)", s.LowConfidenceCount)
}
if s.MedianTop1 != 0.8 {
t.Errorf("median top1 = %.2f, want 0.8", s.MedianTop1)
}
}
func TestSummarizeConfidence_Empty(t *testing.T) {
s := SummarizeConfidence(nil)
if s.Count != 0 || s.MedianTop1 != 0 {
t.Errorf("empty input should yield zero summary, got %+v", s)
}
}
func TestDefaultConfidenceLogPath(t *testing.T) {
got := DefaultConfidenceLogPath()
if got == "" {
return // UserCacheDir unavailable
}
if filepath.Base(got) != "confidence.jsonl" {
t.Errorf("default path basename = %q, want confidence.jsonl", filepath.Base(got))
}
}
+156
View File
@@ -0,0 +1,156 @@
// Package quality provides measurement infrastructure for the
// retrieval pipeline: embedder drift detection, retrieval-confidence
// tracking, query-log replay (top-k churn between two ranker
// configurations), and weight-tuning analysis.
//
// All four are surface-level analyzers — they read substrate already
// shipped (savings store, search index, rerank pipeline) and produce
// markdown / JSON artifacts. No new state in the hot path.
package quality
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/zzet/gortex/internal/platform"
)
// EmbedderFingerprint captures the identity of the active embedder
// at one point in time. Persisting it lets the drift detector flag
// silent provider / model / dimension changes that would otherwise
// produce confusing recall regressions.
type EmbedderFingerprint struct {
Provider string `json:"provider"`
Model string `json:"model"`
ModelRevision string `json:"model_revision,omitempty"`
EmbeddingDim int `json:"embedding_dim"`
SampleVecSHA256 string `json:"sample_vec_sha256,omitempty"`
RecordedAt time.Time `json:"recorded_at"`
}
// DriftWarning is the structured signal the detector emits when the
// current fingerprint differs from the stored one. Empty Changes
// slice means no drift.
type DriftWarning struct {
Previous EmbedderFingerprint `json:"previous"`
Current EmbedderFingerprint `json:"current"`
Changes []string `json:"changes"`
}
// HasDrift reports whether the warning is non-empty — convenience
// for callers that just want a boolean (e.g. CI gates).
func (w DriftWarning) HasDrift() bool { return len(w.Changes) > 0 }
// DefaultFingerprintPath returns the canonical persistence location.
// An absolute $XDG_CACHE_HOME is honoured; otherwise the file stays
// under os.UserCacheDir() as before. Returns empty when the cache dir
// is unavailable; callers should treat empty as "don't persist, just
// compare in-memory".
func DefaultFingerprintPath() string {
if v := os.Getenv("XDG_CACHE_HOME"); v == "" || !filepath.IsAbs(v) {
if base, err := os.UserCacheDir(); err != nil || base == "" {
return ""
}
}
return filepath.Join(platform.OSCacheDir(), "embedding-fingerprint.json")
}
// DriftDetector wraps the fingerprint persistence and the
// comparison logic. Concurrency: not safe — call from one goroutine
// per detector instance.
type DriftDetector struct {
Path string
}
// NewDriftDetector returns a detector bound to a persistence path.
// Empty path is allowed — the detector still compares in-memory but
// never writes to disk.
func NewDriftDetector(path string) *DriftDetector {
return &DriftDetector{Path: path}
}
// LoadPrevious reads the most recent fingerprint, or returns
// (zero-value, nil) when none exists. Real I/O errors propagate so a
// permission problem surfaces.
func (d *DriftDetector) LoadPrevious() (EmbedderFingerprint, error) {
if d.Path == "" {
return EmbedderFingerprint{}, nil
}
raw, err := os.ReadFile(d.Path)
if errors.Is(err, os.ErrNotExist) {
return EmbedderFingerprint{}, nil
}
if err != nil {
return EmbedderFingerprint{}, fmt.Errorf("read fingerprint: %w", err)
}
var fp EmbedderFingerprint
if err := json.Unmarshal(raw, &fp); err != nil {
return EmbedderFingerprint{}, fmt.Errorf("parse fingerprint: %w", err)
}
return fp, nil
}
// Save writes the current fingerprint atomically (temp + rename).
// Silently no-ops when the detector has no persistence path.
func (d *DriftDetector) Save(fp EmbedderFingerprint) error {
if d.Path == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(d.Path), 0o755); err != nil {
return err
}
body, err := json.MarshalIndent(fp, "", " ")
if err != nil {
return err
}
tmp := d.Path + ".tmp"
if err := os.WriteFile(tmp, body, 0o644); err != nil {
return err
}
return os.Rename(tmp, d.Path)
}
// Compare returns the drift warning for current vs the stored
// fingerprint. The persistence file is NOT updated by Compare —
// callers decide when to promote (e.g. only after operator
// confirmation, or always in CI).
func (d *DriftDetector) Compare(current EmbedderFingerprint) (DriftWarning, error) {
prev, err := d.LoadPrevious()
if err != nil {
return DriftWarning{}, err
}
return DiffFingerprints(prev, current), nil
}
// DiffFingerprints is the pure comparison logic — exported so tests
// and standalone consumers can use it without touching disk.
func DiffFingerprints(prev, current EmbedderFingerprint) DriftWarning {
w := DriftWarning{Previous: prev, Current: current}
if prev == (EmbedderFingerprint{}) {
// No previous record — first run; not drift.
return w
}
if prev.Provider != current.Provider {
w.Changes = append(w.Changes, fmt.Sprintf("provider: %q → %q", prev.Provider, current.Provider))
}
if prev.Model != current.Model {
w.Changes = append(w.Changes, fmt.Sprintf("model: %q → %q", prev.Model, current.Model))
}
if prev.ModelRevision != current.ModelRevision && (prev.ModelRevision != "" || current.ModelRevision != "") {
w.Changes = append(w.Changes, fmt.Sprintf("model_revision: %q → %q", prev.ModelRevision, current.ModelRevision))
}
if prev.EmbeddingDim != current.EmbeddingDim {
w.Changes = append(w.Changes, fmt.Sprintf("embedding_dim: %d → %d", prev.EmbeddingDim, current.EmbeddingDim))
}
if prev.SampleVecSHA256 != current.SampleVecSHA256 && (prev.SampleVecSHA256 != "" || current.SampleVecSHA256 != "") {
// Vector-shape change without dim change usually means the
// model was re-quantized or the input pre-processor
// changed. Worth flagging.
w.Changes = append(w.Changes, "sample_vec_sha256 changed (re-quantized or pre-processor swap?)")
}
return w
}
+168
View File
@@ -0,0 +1,168 @@
package quality
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestDiffFingerprints_NoPreviousIsNoDrift(t *testing.T) {
cur := EmbedderFingerprint{Provider: "hugot", Model: "MiniLM-L6-v2", EmbeddingDim: 384}
w := DiffFingerprints(EmbedderFingerprint{}, cur)
if w.HasDrift() {
t.Errorf("no previous fingerprint should not flag drift; got %v", w.Changes)
}
}
func TestDiffFingerprints_ProviderChangeFlagged(t *testing.T) {
prev := EmbedderFingerprint{Provider: "hugot", Model: "MiniLM-L6-v2", EmbeddingDim: 384}
cur := EmbedderFingerprint{Provider: "openai", Model: "text-embedding-3-small", EmbeddingDim: 384}
w := DiffFingerprints(prev, cur)
if !w.HasDrift() {
t.Fatal("provider change should flag drift")
}
hasProvider := false
hasModel := false
for _, c := range w.Changes {
if contains(c, "provider:") {
hasProvider = true
}
if contains(c, "model:") {
hasModel = true
}
}
if !hasProvider || !hasModel {
t.Errorf("expected provider + model in changes, got %v", w.Changes)
}
}
func TestDiffFingerprints_DimChangeFlagged(t *testing.T) {
prev := EmbedderFingerprint{Provider: "hugot", Model: "MiniLM-L6-v2", EmbeddingDim: 384}
cur := EmbedderFingerprint{Provider: "hugot", Model: "MiniLM-L6-v2", EmbeddingDim: 768}
w := DiffFingerprints(prev, cur)
if !w.HasDrift() {
t.Fatal("dim change should flag drift")
}
}
func TestDiffFingerprints_IdenticalIsNoDrift(t *testing.T) {
fp := EmbedderFingerprint{Provider: "hugot", Model: "MiniLM-L6-v2", EmbeddingDim: 384, SampleVecSHA256: "abc"}
w := DiffFingerprints(fp, fp)
if w.HasDrift() {
t.Errorf("identical fingerprints should not flag drift; got %v", w.Changes)
}
}
func TestDiffFingerprints_SampleVecChangeFlagged(t *testing.T) {
prev := EmbedderFingerprint{Provider: "hugot", Model: "M", EmbeddingDim: 384, SampleVecSHA256: "abc"}
cur := EmbedderFingerprint{Provider: "hugot", Model: "M", EmbeddingDim: 384, SampleVecSHA256: "xyz"}
w := DiffFingerprints(prev, cur)
if !w.HasDrift() {
t.Fatal("sample-vec change should flag drift")
}
}
func TestDriftDetector_RoundTripFingerprint(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "fp.json")
d := NewDriftDetector(path)
want := EmbedderFingerprint{
Provider: "hugot",
Model: "MiniLM-L6-v2",
ModelRevision: "fp32",
EmbeddingDim: 384,
SampleVecSHA256: "deadbeef",
RecordedAt: time.Now().UTC().Round(time.Second),
}
if err := d.Save(want); err != nil {
t.Fatalf("Save: %v", err)
}
got, err := d.LoadPrevious()
if err != nil {
t.Fatalf("LoadPrevious: %v", err)
}
if got.Provider != want.Provider || got.EmbeddingDim != want.EmbeddingDim {
t.Errorf("round-trip lost data: got %+v want %+v", got, want)
}
}
func TestDriftDetector_LoadMissingReturnsZero(t *testing.T) {
d := NewDriftDetector(filepath.Join(t.TempDir(), "missing.json"))
got, err := d.LoadPrevious()
if err != nil {
t.Errorf("missing file should not error, got %v", err)
}
if got != (EmbedderFingerprint{}) {
t.Errorf("missing file should yield zero, got %+v", got)
}
}
func TestDriftDetector_EmptyPathSilent(t *testing.T) {
d := NewDriftDetector("")
if err := d.Save(EmbedderFingerprint{Provider: "x"}); err != nil {
t.Errorf("empty path Save should be no-op, got %v", err)
}
got, err := d.LoadPrevious()
if err != nil || got != (EmbedderFingerprint{}) {
t.Errorf("empty path LoadPrevious should return zero / nil, got %+v / %v", got, err)
}
}
func TestDriftDetector_Compare_EndToEnd(t *testing.T) {
dir := t.TempDir()
d := NewDriftDetector(filepath.Join(dir, "fp.json"))
// First compare: no previous record → no drift.
cur := EmbedderFingerprint{Provider: "hugot", Model: "A", EmbeddingDim: 384}
w, err := d.Compare(cur)
if err != nil {
t.Fatal(err)
}
if w.HasDrift() {
t.Error("first comparison should report no drift")
}
_ = d.Save(cur)
// Second compare with same fingerprint: still no drift.
w, _ = d.Compare(cur)
if w.HasDrift() {
t.Errorf("identical fingerprint should not drift, got %v", w.Changes)
}
// Change the model: should drift.
cur.Model = "B"
w, _ = d.Compare(cur)
if !w.HasDrift() {
t.Error("model change should drift")
}
}
func TestDefaultFingerprintPath(t *testing.T) {
got := DefaultFingerprintPath()
if got == "" {
// Acceptable when UserCacheDir is unavailable; just don't panic.
return
}
if filepath.Base(got) != "embedding-fingerprint.json" {
t.Errorf("default path basename = %q, want embedding-fingerprint.json", filepath.Base(got))
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || stringsHasSubstring(s, sub))
}
func stringsHasSubstring(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
// Quiet "imported and not used" when the harness iterates the
// fingerprint file directly without going through DriftDetector.
var _ = os.Stat
+211
View File
@@ -0,0 +1,211 @@
package quality
import (
"fmt"
"sort"
)
// ReplayQuery is one (query, expected) pair sourced from a query
// log. The expected list is optional — when present the replay
// computes recall@k against it; when absent the replay only
// produces ranking-delta metrics (Kendall τ, top-k churn) between
// baseline and candidate.
type ReplayQuery struct {
Query string `json:"query"`
Expected []string `json:"expected,omitempty"`
}
// RankerFunc is the contract a candidate / baseline implementation
// satisfies. Returns the ordered list of result IDs (file paths /
// symbol IDs — what shape the caller uses) for one query.
type RankerFunc func(query string, topK int) []string
// PerQueryDelta is one query's outcome from a replay run.
type PerQueryDelta struct {
Query string `json:"query"`
Baseline []string `json:"baseline"`
Candidate []string `json:"candidate"`
Kendall float64 `json:"kendall_tau"` // -1..+1; 1 = identical order
Top1Changed bool `json:"top1_changed"`
Top5Changes int `json:"top5_changes"` // |baseline[:5] △ candidate[:5]|
RecallBase float64 `json:"recall_baseline,omitempty"`
RecallCand float64 `json:"recall_candidate,omitempty"`
}
// ReplayResult is the aggregate output of a replay run.
type ReplayResult struct {
PerQuery []PerQueryDelta `json:"per_query"`
MeanKendall float64 `json:"mean_kendall_tau"`
Top1ChurnPct float64 `json:"top1_churn_pct"` // % of queries with top1 changed
MeanTop5Churn float64 `json:"mean_top5_changes"`
RecallDelta float64 `json:"recall_delta"` // candidate - baseline mean recall (when ground truth exists)
}
// Replay walks the query log, scores each query against baseline +
// candidate, and aggregates the deltas. K is the top-K depth for
// the comparison (10 is the standard NDCG@10 / top-10 churn shape).
func Replay(queries []ReplayQuery, baseline, candidate RankerFunc, k int) (ReplayResult, error) {
if baseline == nil || candidate == nil {
return ReplayResult{}, fmt.Errorf("baseline and candidate rankers are required")
}
if k <= 0 {
k = 10
}
out := ReplayResult{PerQuery: make([]PerQueryDelta, 0, len(queries))}
var totKendall, totTop5 float64
var top1Changes, withGT int
var totRecallBase, totRecallCand float64
for _, q := range queries {
b := baseline(q.Query, k)
c := candidate(q.Query, k)
row := PerQueryDelta{
Query: q.Query,
Baseline: truncate(b, k),
Candidate: truncate(c, k),
Kendall: kendallTauTopK(b, c, k),
Top1Changed: top1Differs(b, c),
Top5Changes: setSymDiffSize(prefix(b, 5), prefix(c, 5)),
}
if len(q.Expected) > 0 {
row.RecallBase = recallAtK(b, q.Expected, k)
row.RecallCand = recallAtK(c, q.Expected, k)
totRecallBase += row.RecallBase
totRecallCand += row.RecallCand
withGT++
}
totKendall += row.Kendall
totTop5 += float64(row.Top5Changes)
if row.Top1Changed {
top1Changes++
}
out.PerQuery = append(out.PerQuery, row)
}
if n := len(queries); n > 0 {
out.MeanKendall = totKendall / float64(n)
out.MeanTop5Churn = totTop5 / float64(n)
out.Top1ChurnPct = float64(top1Changes) / float64(n) * 100.0
}
if withGT > 0 {
out.RecallDelta = (totRecallCand - totRecallBase) / float64(withGT)
}
return out, nil
}
// --- ranking-delta math ---------------------------------------------
// kendallTauTopK computes Kendall's τ over the top-K shared
// elements of two ranked lists. Returns 1 when both lists are
// empty or share fewer than 2 elements (τ is undefined; treat as
// "no measurable disagreement").
func kendallTauTopK(a, b []string, k int) float64 {
aPref := prefix(a, k)
bPref := prefix(b, k)
rankA := indexMap(aPref)
rankB := indexMap(bPref)
// Intersection of the two prefixes.
var shared []string
for _, id := range aPref {
if _, ok := rankB[id]; ok {
shared = append(shared, id)
}
}
n := len(shared)
if n < 2 {
return 1
}
var concordant, discordant int
for i := range n {
for j := i + 1; j < n; j++ {
a, b := shared[i], shared[j]
aOrder := rankA[a] < rankA[b]
bOrder := rankB[a] < rankB[b]
if aOrder == bOrder {
concordant++
} else {
discordant++
}
}
}
pairs := concordant + discordant
if pairs == 0 {
return 1
}
return float64(concordant-discordant) / float64(pairs)
}
func top1Differs(a, b []string) bool {
if len(a) == 0 && len(b) == 0 {
return false
}
if len(a) == 0 || len(b) == 0 {
return true
}
return a[0] != b[0]
}
func setSymDiffSize(a, b []string) int {
as := map[string]struct{}{}
for _, x := range a {
as[x] = struct{}{}
}
bs := map[string]struct{}{}
for _, x := range b {
bs[x] = struct{}{}
}
count := 0
for x := range as {
if _, ok := bs[x]; !ok {
count++
}
}
for x := range bs {
if _, ok := as[x]; !ok {
count++
}
}
return count
}
func recallAtK(returned, expected []string, k int) float64 {
if len(expected) == 0 {
return 0
}
expSet := map[string]bool{}
for _, e := range expected {
expSet[e] = true
}
hits := 0
limit := min(k, len(returned))
for i := range limit {
if expSet[returned[i]] {
hits++
}
}
return float64(hits) / float64(len(expected))
}
func prefix(s []string, k int) []string {
if k <= 0 || len(s) <= k {
return s
}
return s[:k]
}
func truncate(s []string, k int) []string {
out := make([]string, 0, k)
out = append(out, prefix(s, k)...)
return out
}
func indexMap(s []string) map[string]int {
m := make(map[string]int, len(s))
for i, v := range s {
if _, ok := m[v]; !ok {
m[v] = i
}
}
return m
}
// Used only to keep imports stable when iterating.
var _ = sort.Sort
+179
View File
@@ -0,0 +1,179 @@
package quality
import (
"math"
"strings"
"testing"
)
// Test rankers: stub ranker funcs that return fixed lists per query
// — keeps the replay tests deterministic and avoids pulling in the
// real engine.
func staticRanker(byQuery map[string][]string) RankerFunc {
return func(q string, topK int) []string {
out := byQuery[q]
if topK > 0 && len(out) > topK {
return out[:topK]
}
return out
}
}
func TestReplay_IdenticalRankersZeroChurn(t *testing.T) {
baseline := staticRanker(map[string][]string{"q1": {"a", "b", "c"}})
candidate := baseline
queries := []ReplayQuery{{Query: "q1"}}
got, err := Replay(queries, baseline, candidate, 10)
if err != nil {
t.Fatal(err)
}
if got.Top1ChurnPct != 0 {
t.Errorf("identical rankers should have 0%% top1 churn, got %.2f%%", got.Top1ChurnPct)
}
if got.MeanKendall < 0.99 {
t.Errorf("identical rankers should have kendall ≈ 1, got %.4f", got.MeanKendall)
}
if got.MeanTop5Churn != 0 {
t.Errorf("identical rankers should have 0 top5 changes, got %.2f", got.MeanTop5Churn)
}
}
func TestReplay_OppositeRankersHighChurn(t *testing.T) {
baseline := staticRanker(map[string][]string{"q1": {"a", "b", "c", "d", "e"}})
candidate := staticRanker(map[string][]string{"q1": {"e", "d", "c", "b", "a"}})
queries := []ReplayQuery{{Query: "q1"}}
got, err := Replay(queries, baseline, candidate, 10)
if err != nil {
t.Fatal(err)
}
if got.Top1ChurnPct != 100 {
t.Errorf("reversed ranker should have 100%% top1 churn, got %.2f%%", got.Top1ChurnPct)
}
// Kendall τ for reversed order over 5 elements: all pairs are
// discordant → τ = -1.
if got.MeanKendall > -0.99 {
t.Errorf("reversed rankers should have kendall ≈ -1, got %.4f", got.MeanKendall)
}
}
func TestReplay_RecallDelta(t *testing.T) {
baseline := staticRanker(map[string][]string{"q1": {"miss1", "miss2", "a"}})
candidate := staticRanker(map[string][]string{"q1": {"a", "miss1", "miss2"}})
queries := []ReplayQuery{
{Query: "q1", Expected: []string{"a"}},
}
got, err := Replay(queries, baseline, candidate, 10)
if err != nil {
t.Fatal(err)
}
// Both find "a" within top 10 → both recall = 1.0 → delta = 0.
if math.Abs(got.RecallDelta) > 0.01 {
t.Errorf("both-find recall delta = %.4f, want 0", got.RecallDelta)
}
}
func TestReplay_RecallDelta_CandidateBetter(t *testing.T) {
// Baseline finds nothing in top-K, candidate finds the expected.
baseline := staticRanker(map[string][]string{"q1": {"x", "y", "z"}})
candidate := staticRanker(map[string][]string{"q1": {"a"}})
queries := []ReplayQuery{{Query: "q1", Expected: []string{"a"}}}
got, _ := Replay(queries, baseline, candidate, 10)
if got.RecallDelta <= 0 {
t.Errorf("candidate-better should yield positive recall delta, got %.4f", got.RecallDelta)
}
}
func TestReplay_NilRankerErrors(t *testing.T) {
_, err := Replay(nil, nil, staticRanker(nil), 10)
if err == nil {
t.Error("nil baseline should error")
}
}
func TestKendallTauTopK_PerfectMatch(t *testing.T) {
tau := kendallTauTopK([]string{"a", "b", "c"}, []string{"a", "b", "c"}, 10)
if tau != 1 {
t.Errorf("identical lists τ = %.4f, want 1", tau)
}
}
func TestKendallTauTopK_FullyReversed(t *testing.T) {
tau := kendallTauTopK([]string{"a", "b", "c"}, []string{"c", "b", "a"}, 10)
if tau != -1 {
t.Errorf("reversed lists τ = %.4f, want -1", tau)
}
}
func TestKendallTauTopK_PartialOverlap(t *testing.T) {
// Only "b" and "c" overlap; in baseline they're in order (b,c);
// in candidate they're reversed (c,b). Single discordant pair
// out of 1 total → τ = -1.
tau := kendallTauTopK(
[]string{"a", "b", "c", "d"},
[]string{"e", "c", "b", "f"},
10,
)
if tau != -1 {
t.Errorf("partial-overlap τ = %.4f, want -1", tau)
}
}
func TestKendallTauTopK_TooFewSharedReturnsOne(t *testing.T) {
// Less than 2 shared elements → τ undefined; we return 1 to
// mean "no measurable disagreement".
tau := kendallTauTopK([]string{"a", "b"}, []string{"c", "d"}, 10)
if tau != 1 {
t.Errorf("disjoint lists τ = %.4f, want 1 (no measurable disagreement)", tau)
}
}
func TestSetSymDiffSize(t *testing.T) {
if got := setSymDiffSize([]string{"a", "b"}, []string{"b", "c"}); got != 2 {
t.Errorf("symdiff = %d, want 2 (a and c are unique)", got)
}
if got := setSymDiffSize([]string{"a"}, []string{"a"}); got != 0 {
t.Errorf("symdiff identical = %d, want 0", got)
}
}
func TestRecallAtK_BoundaryCases(t *testing.T) {
// No expected → 0.
if got := recallAtK([]string{"a"}, nil, 10); got != 0 {
t.Errorf("empty expected = %.4f, want 0", got)
}
// All expected found.
if got := recallAtK([]string{"a", "b"}, []string{"a", "b"}, 10); got != 1 {
t.Errorf("full recall = %.4f, want 1", got)
}
// Half found.
if got := recallAtK([]string{"a"}, []string{"a", "b"}, 10); got != 0.5 {
t.Errorf("half recall = %.4f, want 0.5", got)
}
// Cut by k.
if got := recallAtK([]string{"x", "y", "a"}, []string{"a"}, 2); got != 0 {
t.Errorf("k-cut recall = %.4f, want 0 (a is outside top-2)", got)
}
}
func TestTop1Differs(t *testing.T) {
if !top1Differs([]string{"a"}, []string{"b"}) {
t.Error("different top1 should report changed")
}
if top1Differs([]string{"a"}, []string{"a"}) {
t.Error("same top1 should not report changed")
}
if !top1Differs([]string{}, []string{"a"}) {
t.Error("empty vs non-empty top1 should report changed")
}
if top1Differs(nil, nil) {
t.Error("both empty should not report changed")
}
}
// Quiet "imported and not used" if a future helper needs it.
var _ = strings.HasPrefix
+126
View File
@@ -0,0 +1,126 @@
package quality
import (
"fmt"
"sort"
"strings"
)
// SignalFeedback ties one rerank signal to its observed effect on
// agent-confirmed-useful results. Source: the existing `feedback`
// tool's per-symbol useful / not-needed / missing counters.
type SignalFeedback struct {
Signal string `json:"signal"`
Weight float64 `json:"current_weight"`
UsefulHits int `json:"useful_hits"`
MissedHits int `json:"missed_hits"`
// SuggestedWeight is the tuner's recommendation; the operator
// decides whether to apply it. >Weight = the signal is producing
// useful results and should be amplified; <Weight = it's
// adding noise.
SuggestedWeight float64 `json:"suggested_weight"`
Reasoning string `json:"reasoning"`
}
// SuggestWeights consumes the per-signal feedback rows + a global
// nudge factor and emits a per-signal suggestion. Pure analysis —
// the rerank pipeline isn't mutated; the caller applies via
// `.gortex.yaml::search.weights`.
//
// Algorithm:
// - signals with useful_hits > missed_hits get nudged UP by
// min(0.1, (useful - missed) / 100 * nudge)
// - signals with missed_hits > useful_hits get nudged DOWN by
// the same formula
// - signals with neither (no data) keep their current weight and
// report "insufficient data"
//
// Nudge is the per-call cap; 1.0 means "max ±0.1 per call",
// 0.5 means "half of that". Keeping nudges small makes tuning a
// gradient process rather than a wholesale weight swap.
func SuggestWeights(rows []SignalFeedback, nudge float64) []SignalFeedback {
if nudge <= 0 {
nudge = 1.0
}
out := make([]SignalFeedback, 0, len(rows))
for _, r := range rows {
delta := 0.0
reason := ""
switch {
case r.UsefulHits == 0 && r.MissedHits == 0:
reason = "insufficient data (no useful / missed records this window)"
case r.UsefulHits > r.MissedHits:
diff := float64(r.UsefulHits - r.MissedHits)
delta = clamp(diff/100.0*nudge, 0, 0.1)
reason = fmt.Sprintf("useful_hits=%d > missed=%d → nudge up by %.3f",
r.UsefulHits, r.MissedHits, delta)
case r.MissedHits > r.UsefulHits:
diff := float64(r.MissedHits - r.UsefulHits)
delta = -clamp(diff/100.0*nudge, 0, 0.1)
reason = fmt.Sprintf("missed_hits=%d > useful=%d → nudge down by %.3f",
r.MissedHits, r.UsefulHits, -delta)
default:
reason = fmt.Sprintf("useful_hits == missed_hits (%d) → no change", r.UsefulHits)
}
r.SuggestedWeight = r.Weight + delta
if r.SuggestedWeight < 0 {
r.SuggestedWeight = 0
}
r.Reasoning = reason
out = append(out, r)
}
// Stable display order: biggest absolute suggested change first
// so the operator sees the action items at the top.
sort.SliceStable(out, func(i, j int) bool {
return absVal(out[i].SuggestedWeight-out[i].Weight) > absVal(out[j].SuggestedWeight-out[j].Weight)
})
return out
}
// RenderTuningMarkdown produces the operator-facing report. Columns:
// signal, current weight, useful / missed counts, suggested weight,
// reasoning. Tail summary: count of nudges with abs(delta) > 0.05
// so the operator knows whether anything substantial is on the
// table.
func RenderTuningMarkdown(rows []SignalFeedback) string {
var b strings.Builder
fmt.Fprintln(&b, "# Rerank weight-tuning suggestion")
fmt.Fprintln(&b)
fmt.Fprintln(&b, "_Each row's `suggested_weight` is a calculated nudge from the current `.gortex.yaml::search.weights` value, based on the feedback log. **Operator decides whether to apply** — no automatic mutation._")
fmt.Fprintln(&b)
fmt.Fprintln(&b, "| signal | current | useful | missed | suggested | Δ | reasoning |")
fmt.Fprintln(&b, "|--------|--------:|-------:|-------:|----------:|--:|-----------|")
substantial := 0
for _, r := range rows {
delta := r.SuggestedWeight - r.Weight
if absVal(delta) >= 0.05 {
substantial++
}
fmt.Fprintf(&b, "| %s | %.3f | %d | %d | %.3f | %+.3f | %s |\n",
r.Signal, r.Weight, r.UsefulHits, r.MissedHits,
r.SuggestedWeight, delta, r.Reasoning)
}
fmt.Fprintln(&b)
fmt.Fprintf(&b, "**Substantial nudges (|Δ| ≥ 0.05): %d / %d signals.**\n",
substantial, len(rows))
return b.String()
}
// --- math helpers ---------------------------------------------------
func clamp(v, lo, hi float64) float64 {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func absVal(v float64) float64 {
if v < 0 {
return -v
}
return v
}
+119
View File
@@ -0,0 +1,119 @@
package quality
import (
"strings"
"testing"
)
func TestSuggestWeights_UsefulPushesUp(t *testing.T) {
in := []SignalFeedback{
{Signal: "bm25", Weight: 1.0, UsefulHits: 80, MissedHits: 20},
}
out := SuggestWeights(in, 1.0)
if len(out) != 1 {
t.Fatalf("got %d rows, want 1", len(out))
}
if out[0].SuggestedWeight <= out[0].Weight {
t.Errorf("useful > missed should push weight UP; got %.3f → %.3f", out[0].Weight, out[0].SuggestedWeight)
}
}
func TestSuggestWeights_MissedPushesDown(t *testing.T) {
in := []SignalFeedback{
{Signal: "churn", Weight: 0.5, UsefulHits: 5, MissedHits: 50},
}
out := SuggestWeights(in, 1.0)
if out[0].SuggestedWeight >= out[0].Weight {
t.Errorf("missed > useful should push weight DOWN; got %.3f → %.3f", out[0].Weight, out[0].SuggestedWeight)
}
}
func TestSuggestWeights_NoDataKeepsWeight(t *testing.T) {
in := []SignalFeedback{
{Signal: "minhash", Weight: 0.3, UsefulHits: 0, MissedHits: 0},
}
out := SuggestWeights(in, 1.0)
if out[0].SuggestedWeight != out[0].Weight {
t.Errorf("no-data should keep weight unchanged; got %.3f → %.3f", out[0].Weight, out[0].SuggestedWeight)
}
if !strings.Contains(out[0].Reasoning, "insufficient data") {
t.Errorf("no-data reasoning should mention insufficient data; got %q", out[0].Reasoning)
}
}
func TestSuggestWeights_TiePicksNoChange(t *testing.T) {
in := []SignalFeedback{
{Signal: "fan_in", Weight: 0.6, UsefulHits: 10, MissedHits: 10},
}
out := SuggestWeights(in, 1.0)
if out[0].SuggestedWeight != out[0].Weight {
t.Errorf("tie should keep weight; got %.3f → %.3f", out[0].Weight, out[0].SuggestedWeight)
}
}
func TestSuggestWeights_NudgeCapped(t *testing.T) {
// Even with absurdly high useful counts, the nudge per call is
// capped at 0.1 so a single run can't wildly swing weights.
in := []SignalFeedback{
{Signal: "fan_in", Weight: 0.5, UsefulHits: 1_000_000, MissedHits: 0},
}
out := SuggestWeights(in, 1.0)
delta := out[0].SuggestedWeight - out[0].Weight
if delta > 0.101 {
t.Errorf("nudge should be capped at 0.1, got %+.3f", delta)
}
}
func TestSuggestWeights_DownNudgeCanNotGoNegative(t *testing.T) {
in := []SignalFeedback{
{Signal: "rare", Weight: 0.05, UsefulHits: 0, MissedHits: 1_000_000},
}
out := SuggestWeights(in, 1.0)
if out[0].SuggestedWeight < 0 {
t.Errorf("suggested weight should never go negative, got %.3f", out[0].SuggestedWeight)
}
}
func TestSuggestWeights_SortedByAbsoluteDelta(t *testing.T) {
in := []SignalFeedback{
{Signal: "small_change", Weight: 1.0, UsefulHits: 5, MissedHits: 4},
{Signal: "big_change", Weight: 1.0, UsefulHits: 90, MissedHits: 10},
{Signal: "no_change", Weight: 1.0, UsefulHits: 0, MissedHits: 0},
}
out := SuggestWeights(in, 1.0)
if out[0].Signal != "big_change" {
t.Errorf("first row should be the biggest change; got %s", out[0].Signal)
}
}
func TestRenderTuningMarkdown_HasHeaderAndRows(t *testing.T) {
rows := []SignalFeedback{
{Signal: "bm25", Weight: 1.0, UsefulHits: 80, MissedHits: 20, SuggestedWeight: 1.06, Reasoning: "useful > missed"},
}
md := RenderTuningMarkdown(rows)
for _, want := range []string{
"# Rerank weight-tuning suggestion",
"| bm25 |",
"1.000",
"1.060",
"useful > missed",
"**Substantial nudges",
} {
if !strings.Contains(md, want) {
t.Errorf("markdown missing %q\n----\n%s", want, md)
}
}
}
func TestRenderTuningMarkdown_SubstantialCount(t *testing.T) {
rows := []SignalFeedback{
{Signal: "tiny", Weight: 1.0, SuggestedWeight: 1.01}, // not substantial
{Signal: "big1", Weight: 1.0, SuggestedWeight: 1.10}, // substantial
{Signal: "big2", Weight: 1.0, SuggestedWeight: 0.90}, // substantial (negative)
{Signal: "nothing", Weight: 1.0, SuggestedWeight: 1.0}, // not substantial
}
md := RenderTuningMarkdown(rows)
if !strings.Contains(md, "Substantial nudges (|Δ| ≥ 0.05): 2 / 4") {
t.Errorf("substantial count wrong:\n%s", md)
}
}