rerankgate 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rerankgate/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """rerankgate — agentic RAG quality gate.
2
+
3
+ Drop-in retrieval pipeline with built-in quality gates:
4
+ hybrid search (BM25 + vector) -> rerank -> sufficiency gate ->
5
+ query-rewrite retry or abstain. Local-first; the sufficiency gate
6
+ works without any LLM call by default.
7
+ """
8
+
9
+ from rerankgate.config import GateConfig, PipelineConfig, RetryConfig
10
+ from rerankgate.documents import Document, chunk_text
11
+ from rerankgate.gate import GateDecision, SufficiencyGate
12
+ from rerankgate.pipeline import AnswerResult, RAGPipeline, Verdict
13
+ from rerankgate.retrieval import HybridRetriever, ScoredChunk
14
+
15
+ __all__ = [
16
+ "AnswerResult",
17
+ "Document",
18
+ "GateConfig",
19
+ "GateDecision",
20
+ "HybridRetriever",
21
+ "PipelineConfig",
22
+ "RAGPipeline",
23
+ "RetryConfig",
24
+ "ScoredChunk",
25
+ "SufficiencyGate",
26
+ "Verdict",
27
+ "chunk_text",
28
+ ]
29
+
30
+ __version__ = "0.2.0"
rerankgate/bm25.py ADDED
@@ -0,0 +1,70 @@
1
+ """Pure-stdlib BM25 (Okapi) index over chunks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections import Counter
7
+
8
+ from rerankgate.documents import Chunk
9
+ from rerankgate.tokenize import tokenize
10
+
11
+
12
+ class BM25Index:
13
+ """In-memory BM25 index. Deterministic; rebuild from chunks."""
14
+
15
+ def __init__(self, k1: float = 1.5, b: float = 0.75) -> None:
16
+ self.k1 = k1
17
+ self.b = b
18
+ self._chunks: list[Chunk] = []
19
+ self._doc_freq: Counter[str] = Counter()
20
+ self._term_freqs: list[Counter[str]] = []
21
+ self._doc_lens: list[int] = []
22
+ self._avg_len: float = 0.0
23
+
24
+ def __len__(self) -> int:
25
+ return len(self._chunks)
26
+
27
+ def add(self, chunks: list[Chunk]) -> None:
28
+ for ch in chunks:
29
+ toks = tokenize(ch.text)
30
+ tf = Counter(toks)
31
+ self._chunks.append(ch)
32
+ self._term_freqs.append(tf)
33
+ self._doc_lens.append(len(toks))
34
+ for term in tf:
35
+ self._doc_freq[term] += 1
36
+ total = sum(self._doc_lens)
37
+ self._avg_len = total / len(self._doc_lens) if self._doc_lens else 0.0
38
+
39
+ def _idf(self, term: str) -> float:
40
+ n = len(self._chunks)
41
+ df = self._doc_freq.get(term, 0)
42
+ # Standard BM25 IDF with +1 smoothing floor; unseen terms get ~0.
43
+ return math.log(1 + (n - df + 0.5) / (df + 0.5)) if df else 0.0
44
+
45
+ def search(self, query: str, k: int = 100) -> list[tuple[Chunk, float]]:
46
+ """Return top-k (chunk, score) pairs, highest score first."""
47
+ q_terms = tokenize(query)
48
+ if not q_terms or not self._chunks:
49
+ return []
50
+ idfs = {t: self._idf(t) for t in set(q_terms)}
51
+ scored: list[tuple[Chunk, float]] = []
52
+ for i, ch in enumerate(self._chunks):
53
+ tf = self._term_freqs[i]
54
+ dl = self._doc_lens[i]
55
+ norm = 1 - self.b + self.b * (dl / self._avg_len) if self._avg_len else 1.0
56
+ score = 0.0
57
+ for t in q_terms:
58
+ f = tf.get(t, 0)
59
+ if f and idfs[t]:
60
+ score += idfs[t] * (f * (self.k1 + 1)) / (f + self.k1 * norm)
61
+ if score > 0:
62
+ scored.append((ch, score))
63
+ scored.sort(key=lambda p: p[1], reverse=True)
64
+ return scored[:k]
65
+
66
+ def term_stats(self, chunk_idx: int) -> Counter[str]:
67
+ return self._term_freqs[chunk_idx]
68
+
69
+ def chunks(self) -> list[Chunk]:
70
+ return list(self._chunks)
rerankgate/cli.py ADDED
@@ -0,0 +1,142 @@
1
+ """rerankgate CLI: ingest | query | eval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from rerankgate.config import PipelineConfig
10
+ from rerankgate.documents import Document
11
+ from rerankgate.metrics import EvalItem
12
+ from rerankgate.pipeline import RAGPipeline
13
+
14
+
15
+ def _pipeline_for(args: argparse.Namespace, *, for_ingest: bool = False) -> RAGPipeline:
16
+ cfg = PipelineConfig()
17
+ if args.gate_preset:
18
+ cfg.gate_preset = args.gate_preset
19
+ if args.max_retries is not None:
20
+ cfg.retry.max_retries = args.max_retries
21
+ if args.no_vectors:
22
+ cfg.use_vectors = False
23
+ if for_ingest:
24
+ return RAGPipeline(config=cfg)
25
+ return RAGPipeline.load(args.store, config=cfg)
26
+
27
+
28
+ def cmd_ingest(args: argparse.Namespace) -> int:
29
+ docs: list[Document] = []
30
+ with open(args.input, encoding="utf-8") as f:
31
+ for i, line in enumerate(f):
32
+ line = line.strip()
33
+ if not line:
34
+ continue
35
+ obj = json.loads(line)
36
+ text = obj.get("text", "")
37
+ meta = {k: v for k, v in obj.items() if k not in ("id", "text")}
38
+ docs.append(Document(id=str(obj.get("id", f"doc-{i}")), text=text, metadata=meta))
39
+ pipe = _pipeline_for(args, for_ingest=True)
40
+ n_chunks = pipe.ingest(docs)
41
+ pipe.save(args.store)
42
+ print(f"ingested {len(docs)} documents -> {n_chunks} chunks -> {args.store}")
43
+ if pipe.degraded_reason:
44
+ print("note: BM25-only mode (no embeddings installed)")
45
+ return 0
46
+
47
+
48
+ def cmd_query(args: argparse.Namespace) -> int:
49
+ pipe = _pipeline_for(args)
50
+ result = pipe.ask(args.query)
51
+ out = result.to_dict()
52
+ if args.json:
53
+ print(json.dumps(out, indent=2))
54
+ return 0
55
+ print(f"verdict: {out['verdict']}")
56
+ print(
57
+ f"gate: {'PASS' if out['gate']['passed'] else 'FAIL'} "
58
+ f"(score {out['gate']['score']} vs threshold {out['gate']['threshold']})"
59
+ )
60
+ print(f"reasons: {', '.join(out['gate']['reasons'])}")
61
+ print(
62
+ f"attempts: {out['trace']['attempts']} "
63
+ f"modes: {','.join(out['trace']['modes'])} "
64
+ f"total: {out['trace']['total_ms']}ms"
65
+ )
66
+ if out["trace"]["degraded"]:
67
+ print("degraded: BM25-only (embeddings unavailable)")
68
+ print("evidence:")
69
+ for ev in out["evidence"][:10]:
70
+ snippet = ev["text"][:120].replace("\n", " ")
71
+ print(f" [{ev['score']:.3f}] {ev['chunk_id']}: {snippet}...")
72
+ return 0
73
+
74
+
75
+ def cmd_eval(args: argparse.Namespace) -> int:
76
+ pipe = _pipeline_for(args)
77
+ items: list[EvalItem] = []
78
+ with open(args.qrels, encoding="utf-8") as f:
79
+ for line in f:
80
+ line = line.strip()
81
+ if not line:
82
+ continue
83
+ obj = json.loads(line)
84
+ items.append(
85
+ EvalItem(
86
+ query=obj["query"],
87
+ relevant_ids=list(obj.get("relevant_ids", [])),
88
+ answerable=bool(obj.get("answerable", True)),
89
+ )
90
+ )
91
+ report = pipe.evaluate(items)
92
+ if args.summary:
93
+ report = {k: v for k, v in report.items() if k != "per_query"}
94
+ print(json.dumps(report, indent=2))
95
+ return 0
96
+
97
+
98
+ def build_parser() -> argparse.ArgumentParser:
99
+ import rerankgate
100
+
101
+ p = argparse.ArgumentParser(prog="rerankgate", description="Agentic RAG quality gate")
102
+ p.add_argument("--version", action="version", version=f"%(prog)s {rerankgate.__version__}")
103
+ sub = p.add_subparsers(dest="command", required=True)
104
+
105
+ def _common(sp: argparse.ArgumentParser) -> None:
106
+ sp.add_argument("--gate-preset", choices=["strict", "balanced", "lenient"], default=None)
107
+ sp.add_argument("--max-retries", type=int, default=None)
108
+ sp.add_argument("--no-vectors", action="store_true", help="force BM25-only mode")
109
+
110
+ pi = sub.add_parser("ingest", help="ingest a JSONL corpus into a store")
111
+ pi.add_argument("--input", required=True, help="JSONL with {id, text, ...metadata}")
112
+ pi.add_argument("--store", required=True, help="output store directory")
113
+ _common(pi)
114
+ pi.set_defaults(func=cmd_ingest)
115
+
116
+ pq = sub.add_parser("query", help="ask a question against a store")
117
+ pq.add_argument("--store", required=True)
118
+ pq.add_argument("query")
119
+ pq.add_argument("--json", action="store_true")
120
+ _common(pq)
121
+ pq.set_defaults(func=cmd_query)
122
+
123
+ pe = sub.add_parser("eval", help="evaluate retrieval + gate on labeled qrels")
124
+ pe.add_argument("--store", required=True)
125
+ pe.add_argument("--qrels", required=True, help="JSONL with {query, relevant_ids, answerable}")
126
+ pe.add_argument(
127
+ "--summary",
128
+ action="store_true",
129
+ help="print aggregate metrics only, without the per-query detail",
130
+ )
131
+ _common(pe)
132
+ pe.set_defaults(func=cmd_eval)
133
+ return p
134
+
135
+
136
+ def main(argv: list[str] | None = None) -> int:
137
+ args = build_parser().parse_args(argv)
138
+ return args.func(args)
139
+
140
+
141
+ if __name__ == "__main__":
142
+ sys.exit(main())
rerankgate/config.py ADDED
@@ -0,0 +1,77 @@
1
+ """Configuration dataclasses for rerankgate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class RetryConfig:
10
+ """Bounds for the query-rewrite retry loop."""
11
+
12
+ max_retries: int = 2
13
+ # Stop early if the best gate score does not improve by at least this much.
14
+ min_improvement: float = 0.01
15
+ # How many expansion terms the RM3-style rewriter may add per retry.
16
+ expansion_terms: int = 5
17
+
18
+
19
+ @dataclass
20
+ class GateConfig:
21
+ """Tunable knobs for the heuristic sufficiency gate.
22
+
23
+ The gate score is a fixed weighted sum of interpretable features
24
+ (see gate.py). Nothing is learned, so there is nothing to overfit;
25
+ tune the threshold with `rerankgate eval` on your own labeled data.
26
+ """
27
+
28
+ threshold: float = 0.55
29
+ w_top_score: float = 0.35
30
+ w_coverage: float = 0.30
31
+ w_margin: float = 0.20
32
+ w_support: float = 0.15
33
+ # Minimum fraction of (non-stopword) query terms that must appear
34
+ # in the evidence before the gate can pass, regardless of score.
35
+ min_coverage: float = 0.25
36
+ # Minimum normalized top-1 rerank score before the gate can pass.
37
+ min_top_score: float = 0.10
38
+
39
+ def preset(self, name: str) -> GateConfig:
40
+ """Return a copy with a named strictness preset applied."""
41
+ presets = {
42
+ # (threshold, min_coverage): stricter gates demand more evidence.
43
+ "strict": (0.70, 0.35),
44
+ "balanced": (0.55, 0.25),
45
+ "lenient": (0.40, 0.15),
46
+ }
47
+ if name not in presets:
48
+ raise ValueError(f"unknown gate preset {name!r}; choose from {sorted(presets)}")
49
+ import dataclasses
50
+
51
+ threshold, min_coverage = presets[name]
52
+ return dataclasses.replace(self, threshold=threshold, min_coverage=min_coverage)
53
+
54
+
55
+ @dataclass
56
+ class PipelineConfig:
57
+ """Top-level pipeline configuration."""
58
+
59
+ # Retrieval
60
+ retrieve_k: int = 100 # candidates pulled from each retriever before fusion
61
+ rerank_k: int = 10 # evidence chunks returned after reranking
62
+ rrf_k: int = 60 # RRF rank constant
63
+ bm25_weight: float = 1.0 # per-system RRF multipliers
64
+ vector_weight: float = 1.0
65
+ use_vectors: bool = True # degraded to BM25-only if embeddings unavailable
66
+
67
+ # Chunking
68
+ chunk_size: int = 500
69
+ chunk_overlap: int = 50
70
+
71
+ # Gate + retry
72
+ gate: GateConfig = field(default_factory=GateConfig)
73
+ retry: RetryConfig = field(default_factory=RetryConfig)
74
+ gate_preset: str = "balanced"
75
+
76
+ def effective_gate(self) -> GateConfig:
77
+ return self.gate.preset(self.gate_preset)
@@ -0,0 +1,97 @@
1
+ """Documents and deterministic chunking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ _SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+")
10
+
11
+
12
+ @dataclass
13
+ class Document:
14
+ """A source document before chunking."""
15
+
16
+ id: str
17
+ text: str
18
+ metadata: dict[str, Any] = field(default_factory=dict)
19
+
20
+ @property
21
+ def title(self) -> str:
22
+ return str(self.metadata.get("title", ""))
23
+
24
+
25
+ @dataclass
26
+ class Chunk:
27
+ """A retrievable unit produced by chunking a Document."""
28
+
29
+ id: str # f"{doc_id}#c{n}"
30
+ doc_id: str
31
+ text: str
32
+ position: int
33
+ metadata: dict[str, Any] = field(default_factory=dict)
34
+
35
+
36
+ def chunk_text(
37
+ text: str,
38
+ doc_id: str,
39
+ size: int = 500,
40
+ overlap: int = 50,
41
+ metadata: dict | None = None,
42
+ ) -> list[Chunk]:
43
+ """Split text into sentence-aware sliding-window chunks.
44
+
45
+ Deterministic: same input always yields the same chunks.
46
+ """
47
+ text = text.strip()
48
+ if not text:
49
+ return []
50
+ sentences = [s.strip() for s in _SENTENCE_SPLIT.split(text) if s.strip()]
51
+ if not sentences:
52
+ sentences = [text]
53
+
54
+ chunks: list[Chunk] = []
55
+ buf: list[str] = []
56
+ buf_len = 0
57
+ n = 0
58
+
59
+ def flush() -> None:
60
+ nonlocal n, buf, buf_len
61
+ if buf:
62
+ chunks.append(
63
+ Chunk(
64
+ id=f"{doc_id}#c{n}",
65
+ doc_id=doc_id,
66
+ text=" ".join(buf),
67
+ position=n,
68
+ metadata=dict(metadata or {}),
69
+ )
70
+ )
71
+ n += 1
72
+
73
+ for sent in sentences:
74
+ # A single over-long sentence becomes its own chunk (hard-split).
75
+ while len(sent) > size:
76
+ flush()
77
+ chunks.append(
78
+ Chunk(
79
+ id=f"{doc_id}#c{n}",
80
+ doc_id=doc_id,
81
+ text=sent[:size],
82
+ position=n,
83
+ metadata=dict(metadata or {}),
84
+ )
85
+ )
86
+ n += 1
87
+ sent = sent[size - overlap :]
88
+ if buf_len + len(sent) + 1 > size and buf:
89
+ flush()
90
+ # overlap: carry the tail of the previous chunk forward
91
+ tail = " ".join(buf)
92
+ buf = [tail[-overlap:]] if len(tail) > overlap else []
93
+ buf_len = sum(len(b) + 1 for b in buf)
94
+ buf.append(sent)
95
+ buf_len += len(sent) + 1
96
+ flush()
97
+ return chunks
@@ -0,0 +1,76 @@
1
+ """Pluggable embedding backends.
2
+
3
+ The default path is local-first and LLM-free. Heavy model dependencies
4
+ are lazy: `SentenceTransformerBackend` only imports sentence-transformers
5
+ when instantiated, and the pipeline degrades to BM25-only if embeddings
6
+ are unavailable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Sequence
12
+ from typing import Protocol
13
+
14
+
15
+ class EmbeddingBackend(Protocol):
16
+ """Contract every embedding backend implements."""
17
+
18
+ @property
19
+ def dim(self) -> int: ...
20
+ @property
21
+ def name(self) -> str: ...
22
+ def embed(self, texts: Sequence[str]) -> list[list[float]]: ...
23
+
24
+
25
+ class SentenceTransformerBackend:
26
+ """Local dense embeddings via sentence-transformers (lazy import)."""
27
+
28
+ def __init__(self, model_name: str = "all-MiniLM-L6-v2") -> None:
29
+ try:
30
+ from sentence_transformers import SentenceTransformer # type: ignore
31
+ except ImportError as exc:
32
+ raise RuntimeError(
33
+ "sentence-transformers is not installed. "
34
+ "Install it with `pip install rerankgate[local]`, or run "
35
+ "the pipeline with use_vectors=False for BM25-only mode."
36
+ ) from exc
37
+ self._model = SentenceTransformer(model_name)
38
+ self._model_name = model_name
39
+
40
+ @property
41
+ def name(self) -> str:
42
+ return f"sentence-transformers:{self._model_name}"
43
+
44
+ @property
45
+ def dim(self) -> int:
46
+ return int(self._model.get_sentence_embedding_dimension())
47
+
48
+ def embed(self, texts: Sequence[str]) -> list[list[float]]:
49
+ vecs = self._model.encode(list(texts), normalize_embeddings=True)
50
+ return [list(map(float, v)) for v in vecs]
51
+
52
+
53
+ class FakeEmbeddings:
54
+ """Deterministic toy backend for tests — NOT for production use."""
55
+
56
+ def __init__(self, dim: int = 16) -> None:
57
+ self._dim = dim
58
+
59
+ @property
60
+ def name(self) -> str:
61
+ return "fake-test-embeddings"
62
+
63
+ @property
64
+ def dim(self) -> int:
65
+ return self._dim
66
+
67
+ def embed(self, texts: Sequence[str]) -> list[list[float]]:
68
+ import hashlib
69
+
70
+ out: list[list[float]] = []
71
+ for t in texts:
72
+ h = hashlib.sha256(t.encode()).digest()
73
+ vec = [((h[i % len(h)] / 255.0) - 0.5) * 2 for i in range(self._dim)]
74
+ norm = sum(x * x for x in vec) ** 0.5 or 1.0
75
+ out.append([x / norm for x in vec])
76
+ return out
rerankgate/gate.py ADDED
@@ -0,0 +1,171 @@
1
+ """Heuristic sufficiency gate: "is this retrieval enough to answer?".
2
+
3
+ Works WITHOUT any LLM call. Every decision carries machine-readable reason
4
+ codes plus a human explanation, so a rejection can be audited and the
5
+ threshold tuned with `rerankgate eval`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from time import perf_counter
12
+
13
+ from rerankgate.config import GateConfig
14
+ from rerankgate.retrieval import ScoredChunk
15
+ from rerankgate.tokenize import content_tokens, tokenize
16
+
17
+ # Reason codes (stable strings; safe to match on in calling code).
18
+ PASS = "PASS"
19
+ LOW_TOP_SCORE = "LOW_TOP_SCORE"
20
+ LOW_COVERAGE = "LOW_COVERAGE"
21
+ NO_MARGIN = "NO_MARGIN"
22
+ NO_CONSENSUS = "NO_CONSENSUS"
23
+ NO_EVIDENCE = "NO_EVIDENCE"
24
+
25
+ _EXPLANATIONS = {
26
+ PASS: "Retrieved evidence is sufficient to answer.",
27
+ LOW_TOP_SCORE: "Best chunk score is below the minimum — nothing looks strongly relevant.",
28
+ LOW_COVERAGE: "Too few query terms appear in the evidence — likely off-topic retrieval.",
29
+ NO_MARGIN: "Top chunks score almost identically — no clear winner, ambiguous retrieval.",
30
+ NO_CONSENSUS: "Top chunks don't corroborate each other — evidence is fragmented.",
31
+ NO_EVIDENCE: "Retrieval returned no candidates at all.",
32
+ }
33
+
34
+
35
+ @dataclass
36
+ class GateFeatures:
37
+ top_score: float = 0.0 # normalized top-1 rerank score [0,1]
38
+ coverage: float = 0.0 # fraction of query content-terms in evidence
39
+ margin: float = 0.0 # (s0 - s1) / s0 — decisiveness of the ranking
40
+ support: float = 0.0 # mean pairwise token overlap of top-3 chunks
41
+ depth: int = 0 # chunks above the score floor
42
+
43
+ def to_dict(self) -> dict:
44
+ return {
45
+ "top_score": round(self.top_score, 4),
46
+ "coverage": round(self.coverage, 4),
47
+ "margin": round(self.margin, 4),
48
+ "support": round(self.support, 4),
49
+ "depth": self.depth,
50
+ }
51
+
52
+
53
+ @dataclass
54
+ class GateDecision:
55
+ passed: bool
56
+ score: float
57
+ features: GateFeatures
58
+ reasons: list[str] = field(default_factory=list)
59
+ explanation: str = ""
60
+ threshold: float = 0.0
61
+ ms: float = 0.0
62
+
63
+ def to_dict(self) -> dict:
64
+ return {
65
+ "passed": self.passed,
66
+ "score": round(self.score, 4),
67
+ "threshold": self.threshold,
68
+ "reasons": self.reasons,
69
+ "explanation": self.explanation,
70
+ "features": self.features.to_dict(),
71
+ "ms": self.ms,
72
+ }
73
+
74
+
75
+ class SufficiencyGate:
76
+ """Decide whether reranked evidence is sufficient to answer the query."""
77
+
78
+ def __init__(self, config: GateConfig | None = None) -> None:
79
+ self.config = config or GateConfig()
80
+
81
+ def evaluate(self, query: str, evidence: list[ScoredChunk]) -> GateDecision:
82
+ t0 = perf_counter()
83
+ cfg = self.config
84
+ features = self._features(query, evidence)
85
+ reasons: list[str] = []
86
+
87
+ if not evidence:
88
+ return GateDecision(
89
+ passed=False,
90
+ score=0.0,
91
+ features=features,
92
+ reasons=[NO_EVIDENCE],
93
+ explanation=_EXPLANATIONS[NO_EVIDENCE],
94
+ threshold=cfg.threshold,
95
+ ms=_ms(t0),
96
+ )
97
+
98
+ # Hard floors first: these fail regardless of the weighted score.
99
+ if features.top_score < cfg.min_top_score:
100
+ reasons.append(LOW_TOP_SCORE)
101
+ if features.coverage < cfg.min_coverage:
102
+ reasons.append(LOW_COVERAGE)
103
+
104
+ score = (
105
+ cfg.w_top_score * features.top_score
106
+ + cfg.w_coverage * features.coverage
107
+ + cfg.w_margin * features.margin
108
+ + cfg.w_support * features.support
109
+ )
110
+
111
+ passed = score >= cfg.threshold and not reasons
112
+ if passed:
113
+ reasons = [PASS]
114
+ else:
115
+ # Soft diagnostics: explain *why* a near-miss failed.
116
+ if features.margin < 0.05:
117
+ reasons.append(NO_MARGIN)
118
+ if features.support < 0.05 and features.depth >= 2:
119
+ reasons.append(NO_CONSENSUS)
120
+ reasons = reasons or [f"SCORE_BELOW_THRESHOLD ({score:.2f} < {cfg.threshold:.2f})"]
121
+
122
+ explanation = " ".join(_EXPLANATIONS.get(r, r) for r in reasons)
123
+ return GateDecision(
124
+ passed=passed,
125
+ score=round(score, 4),
126
+ features=features,
127
+ reasons=reasons,
128
+ explanation=explanation,
129
+ threshold=cfg.threshold,
130
+ ms=_ms(t0),
131
+ )
132
+
133
+ def _features(self, query: str, evidence: list[ScoredChunk]) -> GateFeatures:
134
+ q_terms = set(content_tokens(query))
135
+ scores = [c.score for c in evidence]
136
+ top = scores[0] if scores else 0.0
137
+ # Normalize top score to [0,1] against the observed max for stability.
138
+ norm_top = min(1.0, top)
139
+
140
+ ev_toks: set[str] = set()
141
+ for c in evidence:
142
+ ev_toks |= set(tokenize(c.chunk.text))
143
+ coverage = len(q_terms & ev_toks) / len(q_terms) if q_terms else 0.0
144
+
145
+ margin = 0.0
146
+ if len(scores) >= 2 and scores[0] > 0:
147
+ margin = max(0.0, (scores[0] - scores[1]) / scores[0])
148
+
149
+ support = 0.0
150
+ top3 = evidence[:3]
151
+ if len(top3) >= 2:
152
+ pairs = 0
153
+ acc = 0.0
154
+ sets = [set(content_tokens(c.chunk.text)) for c in top3]
155
+ for i in range(len(sets)):
156
+ for j in range(i + 1, len(sets)):
157
+ a, b = sets[i], sets[j]
158
+ union = a | b
159
+ if union:
160
+ acc += len(a & b) / len(union)
161
+ pairs += 1
162
+ support = acc / pairs if pairs else 0.0
163
+
164
+ depth = sum(1 for s in scores if s >= 0.2)
165
+ return GateFeatures(
166
+ top_score=norm_top, coverage=coverage, margin=margin, support=support, depth=depth
167
+ )
168
+
169
+
170
+ def _ms(t0: float) -> float:
171
+ return round((perf_counter() - t0) * 1000, 2)