Make the governed RAG evidence path executable end to end
Some checks failed
verify / verify (push) Has been cancelled

Separate local parsing from model indexing, bind review decisions to immutable manifests, persist vectors behind active profiles, and expose retrieval, chat, evaluation, and document workflows through the React workbench.

Constraint: Live Bailian authentication currently fails for all three configured capabilities

Rejected: Direct upload-to-embedding flow | bypasses local review and manifest binding

Confidence: high

Scope-risk: broad

Directive: Keep private-data deployment blocked until authentication, RBAC, and separate database roles land

Tested: make verify; fresh and replay Docker document smoke; worker recovery smoke; frozen synthetic evaluation; migration 0003-0004 roundtrip

Not-tested: Successful live Bailian calls, OCR, real multi-user authorization
This commit is contained in:
2026-07-13 05:58:11 +08:00
parent 75592af33a
commit ecdb10c37a
111 changed files with 25457 additions and 152 deletions

View File

@@ -0,0 +1,290 @@
"""Deterministic, dependency-free RAG evaluation metrics and run freezing."""
from __future__ import annotations
import hashlib
import json
import math
import random
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any
_SECRET_KEY_PATTERN = re.compile(
r"(?:api[_-]?key|secret|password|authorization|credential|access[_-]?token)",
re.IGNORECASE,
)
class EvaluationContractError(ValueError):
"""Raised when an evaluation would silently produce an invalid metric."""
class UnjudgedCandidateError(EvaluationContractError):
"""Raised instead of treating a pooled-but-unjudged candidate as irrelevant."""
@dataclass(frozen=True, slots=True)
class RankingMetrics:
hit_at_k: float
recall_at_k: float
reciprocal_rank: float
ndcg_at_k: float
complete_hit_at_k: float
evidence_group_recall_at_k: float
@dataclass(frozen=True, slots=True)
class CitationMetrics:
precision: float
recall: float
f1: float
@dataclass(frozen=True, slots=True)
class RefusalMetrics:
precision: float
recall: float
f1: float
accuracy: float
true_positive: int
false_positive: int
false_negative: int
true_negative: int
@dataclass(frozen=True, slots=True)
class ConfidenceInterval:
mean: float
lower: float
upper: float
seed: int
iterations: int
def evaluate_ranking(
ranked_document_ids: Sequence[str],
*,
relevance: Mapping[str, float],
judged_document_ids: frozenset[str],
evidence_groups: Sequence[frozenset[str]],
k: int,
) -> RankingMetrics:
"""Score a ranking only when every candidate in the cutoff is judged.
A relevance value greater than zero is relevant. Evidence groups express
multi-part questions: complete hit requires at least one retrieved document
from every group.
"""
if isinstance(k, bool) or not isinstance(k, int) or k < 1:
raise EvaluationContractError("k must be a positive integer")
ranking = tuple(ranked_document_ids)
if any(not isinstance(item, str) or not item for item in ranking):
raise EvaluationContractError("ranking IDs must be non-empty strings")
if len(set(ranking)) != len(ranking):
raise EvaluationContractError("ranking IDs must be unique")
if any(
not isinstance(score, (int, float))
or isinstance(score, bool)
or not math.isfinite(float(score))
or float(score) < 0
for score in relevance.values()
):
raise EvaluationContractError("relevance values must be finite and non-negative")
if not set(relevance).issubset(judged_document_ids):
raise EvaluationContractError("every qrel document must be in the judgment pool")
if any(not group or not group.issubset(judged_document_ids) for group in evidence_groups):
raise EvaluationContractError("evidence groups must be non-empty and fully judged")
top_k = ranking[:k]
unjudged = [document_id for document_id in top_k if document_id not in judged_document_ids]
if unjudged:
raise UnjudgedCandidateError(f"top-{k} contains {len(unjudged)} unjudged candidate(s)")
gains = [float(relevance.get(document_id, 0.0)) for document_id in top_k]
positive_relevance = {
document_id for document_id, score in relevance.items() if float(score) > 0
}
relevant_retrieved = positive_relevance.intersection(top_k)
hit = 1.0 if relevant_retrieved else 0.0
recall = len(relevant_retrieved) / len(positive_relevance) if positive_relevance else 0.0
reciprocal_rank = next(
(1.0 / rank for rank, gain in enumerate(gains, start=1) if gain > 0),
0.0,
)
dcg = _dcg(gains)
ideal = sorted((float(value) for value in relevance.values()), reverse=True)[:k]
ideal_dcg = _dcg(ideal)
ndcg = dcg / ideal_dcg if ideal_dcg > 0 else 0.0
covered_groups = sum(bool(group.intersection(top_k)) for group in evidence_groups)
group_recall = covered_groups / len(evidence_groups) if evidence_groups else 0.0
complete_hit = 1.0 if evidence_groups and covered_groups == len(evidence_groups) else 0.0
return RankingMetrics(
hit_at_k=hit,
recall_at_k=recall,
reciprocal_rank=reciprocal_rank,
ndcg_at_k=ndcg,
complete_hit_at_k=complete_hit,
evidence_group_recall_at_k=group_recall,
)
def evaluate_citations(
cited_source_ids: Sequence[str],
*,
supported_source_ids: frozenset[str],
) -> CitationMetrics:
citations = tuple(cited_source_ids)
if any(not isinstance(item, str) or not item for item in citations):
raise EvaluationContractError("citation IDs must be non-empty strings")
if len(set(citations)) != len(citations):
raise EvaluationContractError("citation IDs must be unique")
cited = set(citations)
true_positive = len(cited.intersection(supported_source_ids))
precision = true_positive / len(cited) if cited else (1.0 if not supported_source_ids else 0.0)
recall = true_positive / len(supported_source_ids) if supported_source_ids else 1.0
f1 = _f1(precision, recall)
return CitationMetrics(precision=precision, recall=recall, f1=f1)
def evaluate_refusals(
predicted_refusals: Sequence[bool],
*,
answerable_labels: Sequence[bool],
) -> RefusalMetrics:
if len(predicted_refusals) != len(answerable_labels) or not predicted_refusals:
raise EvaluationContractError("refusal predictions and labels must be non-empty pairs")
if any(type(value) is not bool for value in (*predicted_refusals, *answerable_labels)):
raise EvaluationContractError("refusal predictions and labels must be booleans")
expected_refusals = tuple(not answerable for answerable in answerable_labels)
true_positive = sum(
predicted and expected
for predicted, expected in zip(predicted_refusals, expected_refusals, strict=True)
)
false_positive = sum(
predicted and not expected
for predicted, expected in zip(predicted_refusals, expected_refusals, strict=True)
)
false_negative = sum(
not predicted and expected
for predicted, expected in zip(predicted_refusals, expected_refusals, strict=True)
)
true_negative = len(predicted_refusals) - true_positive - false_positive - false_negative
precision = (
true_positive / (true_positive + false_positive) if true_positive + false_positive else 0.0
)
recall = (
true_positive / (true_positive + false_negative) if true_positive + false_negative else 0.0
)
return RefusalMetrics(
precision=precision,
recall=recall,
f1=_f1(precision, recall),
accuracy=(true_positive + true_negative) / len(predicted_refusals),
true_positive=true_positive,
false_positive=false_positive,
false_negative=false_negative,
true_negative=true_negative,
)
def bootstrap_mean_confidence_interval(
values: Sequence[float],
*,
seed: int,
iterations: int = 2_000,
confidence: float = 0.95,
) -> ConfidenceInterval:
if not values:
raise EvaluationContractError("bootstrap values must not be empty")
normalized = tuple(float(value) for value in values)
if any(not math.isfinite(value) for value in normalized):
raise EvaluationContractError("bootstrap values must be finite")
if isinstance(seed, bool) or not isinstance(seed, int):
raise EvaluationContractError("bootstrap seed must be an integer")
if isinstance(iterations, bool) or not isinstance(iterations, int) or iterations < 100:
raise EvaluationContractError("bootstrap iterations must be at least 100")
if not 0 < confidence < 1:
raise EvaluationContractError("confidence must be between zero and one")
mean = sum(normalized) / len(normalized)
if len(normalized) == 1:
return ConfidenceInterval(
mean=mean,
lower=mean,
upper=mean,
seed=seed,
iterations=iterations,
)
generator = random.Random(seed) # noqa: S311 - deterministic statistics, not security.
sample_means = sorted(
sum(generator.choice(normalized) for _ in normalized) / len(normalized)
for _ in range(iterations)
)
tail = (1.0 - confidence) / 2.0
lower = _percentile(sample_means, tail)
upper = _percentile(sample_means, 1.0 - tail)
return ConfidenceInterval(
mean=mean,
lower=lower,
upper=upper,
seed=seed,
iterations=iterations,
)
def freeze_run_config(config: Mapping[str, Any]) -> tuple[str, str]:
"""Return canonical JSON and SHA-256 while rejecting secret-shaped fields."""
_validate_frozen_value(config, path="config")
canonical = json.dumps(
config,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
)
return canonical, hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _validate_frozen_value(value: Any, *, path: str) -> None:
if isinstance(value, Mapping):
for key, item in value.items():
if not isinstance(key, str) or not key:
raise EvaluationContractError(f"{path} keys must be non-empty strings")
if _SECRET_KEY_PATTERN.search(key):
raise EvaluationContractError(f"{path} contains a forbidden secret-shaped key")
_validate_frozen_value(item, path=f"{path}.{key}")
return
if isinstance(value, (list, tuple)):
for index, item in enumerate(value):
_validate_frozen_value(item, path=f"{path}[{index}]")
return
if value is None or isinstance(value, (str, int, bool)):
return
if isinstance(value, float) and math.isfinite(value):
return
raise EvaluationContractError(f"{path} contains a non-canonical value")
def _dcg(gains: Sequence[float]) -> float:
return float(
sum((2.0**gain - 1.0) / math.log2(rank + 1) for rank, gain in enumerate(gains, start=1))
)
def _f1(precision: float, recall: float) -> float:
return 2 * precision * recall / (precision + recall) if precision + recall else 0.0
def _percentile(sorted_values: Sequence[float], probability: float) -> float:
position = probability * (len(sorted_values) - 1)
lower = math.floor(position)
upper = math.ceil(position)
if lower == upper:
return sorted_values[lower]
weight = position - lower
return sorted_values[lower] * (1.0 - weight) + sorted_values[upper] * weight