Skip to content
Open
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
45 changes: 38 additions & 7 deletions application/tests/librarian/evaluate_harness_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,10 @@ def test_pipeline_runs_once_per_row_not_once_per_report(self) -> None:
_golden_row("n1", "hard_negative", "beta", []),
]
pipe = CountingPipeline(
{"alpha": [("616-305", 4.0)], "beta": [("111-111", 3.0)]}
{
"alpha": [("616-305", 4.0), ("999-999", 0.1)],
"beta": [("111-111", 3.0), ("222-222", 2.9)],
}
)

audits = harness.live_audits(rows, pipe, pipe)
Expand Down Expand Up @@ -153,10 +156,10 @@ def test_draws_only_the_two_calibration_slices(self) -> None:
]
pipe = CountingPipeline(
{
"alpha": [("616-305", 4.0)],
"beta": [("111-111", 3.0)],
"gamma": [("616-305", 2.0)],
"delta": [("616-305", 1.0)],
"alpha": [("616-305", 4.0), ("999-999", 0.1)],
"beta": [("111-111", 3.0), ("222-222", 2.9)],
"gamma": [("616-305", 2.0), ("999-999", 0.1)],
"delta": [("616-305", 1.0), ("999-999", 0.1)],
}
)
audits = harness.live_audits(rows, pipe, pipe)
Expand All @@ -174,7 +177,10 @@ def test_skips_rows_with_no_audit_and_empty_shortlists(self) -> None:
_golden_row("n1", "hard_negative", "beta", []),
]
pipe = CountingPipeline(
{"alpha": [("616-305", 4.0)], "beta": [("111-111", 3.0)]}
{
"alpha": [("616-305", 4.0), ("999-999", 0.1)],
"beta": [("111-111", 3.0), ("222-222", 2.9)],
}
)
# "empty" yields no candidates; p3 is never audited at all.
audits = harness.live_audits(rows, pipe, pipe)
Expand All @@ -183,6 +189,28 @@ def test_skips_rows_with_no_audit_and_empty_shortlists(self) -> None:
self.assertEqual(len(logit_sets), 2)
self.assertEqual(len(labels), 2)

def test_skips_single_candidate_shortlists(self) -> None:
# A one-candidate softmax is 1.0 whatever T is, and C.4 never links such
# a row, so it must not enter the fit or the ECE.
rows = [
_golden_row("p1", "positive", "alpha", ["616-305"]),
_golden_row("p2", "positive", "lone", ["616-305"]),
_golden_row("n1", "hard_negative", "beta", []),
]
pipe = CountingPipeline(
{
"alpha": [("616-305", 4.0), ("999-999", 0.1)],
"lone": [("616-305", 4.0)],
"beta": [("111-111", 3.0), ("222-222", 2.9)],
}
)
audits = harness.live_audits(rows, pipe, pipe)

logit_sets, labels = harness.calibration_set(rows, audits)

self.assertEqual(logit_sets, [[4.0, 0.1], [3.0, 2.9]])
self.assertEqual(labels, [1.0, 0.0])


class ReportCalibrationTest(unittest.TestCase):
def test_returns_status_and_fitted_scaler(self) -> None:
Expand Down Expand Up @@ -215,7 +243,10 @@ def test_degenerate_set_fails_and_yields_no_scaler(self) -> None:
_golden_row("p2", "positive", "alpha2", ["616-305"]),
]
pipe = CountingPipeline(
{"alpha": [("616-305", 5.0)], "alpha2": [("616-305", 4.0)]}
{
"alpha": [("616-305", 5.0), ("999-999", 0.1)],
"alpha2": [("616-305", 4.0), ("999-999", 0.2)],
}
)
audits = harness.live_audits(rows, pipe, pipe)

Expand Down
57 changes: 55 additions & 2 deletions application/tests/librarian/pipeline_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
import unittest
from datetime import datetime, timezone

from application.utils.librarian.pipeline import LibrarianPipeline
from application.utils.librarian.calibration.temperature import TemperatureScaler
from application.utils.librarian.pipeline import LibrarianPipeline, decide_shortlist
from application.utils.librarian.schemas import (
CreCandidate,
Decision,
KnowledgeQueueItem,
LinkProposal,
ReasonCode,
Expand Down Expand Up @@ -80,7 +82,12 @@ def confidence(self, logits):
return self._conf


TOP = [CreCandidate(cre_id="616-305", score_rerank=1.5)]
# Two candidates: C.3's softmax confidence is only defined against a rival, so a
# one-candidate shortlist never auto-links (see test_single_candidate_*).
TOP = [
CreCandidate(cre_id="616-305", score_rerank=1.5),
CreCandidate(cre_id="117-371", score_rerank=-2.0),
]


def _pipeline(rows, reranked, conf):
Expand Down Expand Up @@ -115,6 +122,52 @@ def test_empty_shortlist_reviews_no_candidates(self):
self.assertIsInstance(env, ReviewItem)
self.assertEqual(env.reason_code, ReasonCode.no_candidates)

def test_single_candidate_shortlist_reviews_below_threshold(self):
# softmax over one logit is 1.0 whatever the match quality, so a lone
# candidate must not auto-link however confident the scaler says it is.
lone = [CreCandidate(cre_id="616-305", score_rerank=-12.0)]
result = _pipeline([_row()], lone, 1.0).run(at=AT)
env = result.envelopes[0]
self.assertIsInstance(env, ReviewItem)
self.assertEqual(env.reason_code, ReasonCode.below_threshold)
self.assertEqual(result.stats.linked, 0)

def test_single_candidate_with_the_real_scaler_reviews(self):
lone = [CreCandidate(cre_id="616-305", score_rerank=-12.0)]
pipeline = LibrarianPipeline(
_Source([_row()]),
_Retriever(),
_Reranker(lone),
TemperatureScaler(temperature=1.0),
threshold=0.8,
pipeline_run_id=RUN,
)
result = pipeline.run(at=AT)
self.assertIsInstance(result.envelopes[0], ReviewItem)

def test_single_candidate_reviews_even_at_a_zero_threshold(self):
# At tau = 0 a confidence of 0.0 would still clear the bar, so the
# lone-candidate rule cannot rest on the confidence alone.
lone = [CreCandidate(cre_id="616-305", score_rerank=-12.0)]
pipeline = LibrarianPipeline(
_Source([_row()]),
_Retriever(),
_Reranker(lone),
_Scaler(1.0),
threshold=0.0,
pipeline_run_id=RUN,
)
env = pipeline.run(at=AT).envelopes[0]
self.assertIsInstance(env, ReviewItem)
self.assertEqual(env.reason_code, ReasonCode.below_threshold)

def test_single_candidate_keeps_safety_reason_precedence(self):
result = decide_shortlist(
_Scaler(1.0), [-12.0], ["616-305"], threshold=0.0, adversarial=True
)
self.assertEqual(result.decision, Decision.review)
self.assertEqual(result.reason_code, ReasonCode.adversarial_flag)

def test_uncertain_row_is_skipped_at_boundary(self):
result = _pipeline([_row(label="NOISE")], TOP, 0.95).run(at=AT)
self.assertEqual(result.stats.skipped, 1)
Expand Down
7 changes: 6 additions & 1 deletion application/tests/librarian/safety_guard_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ def retrieve(self, text):
class _Reranker:
def rerank(self, text, audit):
return audit.model_copy(
update={"reranked": [CreCandidate(cre_id="616-305", score_rerank=9.0)]}
update={
"reranked": [
CreCandidate(cre_id="616-305", score_rerank=9.0),
CreCandidate(cre_id="117-371", score_rerank=-2.0),
]
}
)


Expand Down
46 changes: 41 additions & 5 deletions application/utils/librarian/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@
from enum import Enum
from typing import Any, Dict, Iterable, List, Optional, Protocol, Sequence, Union

from application.utils.librarian.decision_engine import decide
from application.utils.librarian.decision_engine import DecisionResult, decide
from application.utils.librarian.emitter import emit
from application.utils.librarian.safety_guard import NullSafetyGuard, SafetyGuard
from application.utils.librarian.schemas import (
Decision,
KnowledgeQueueItem,
LinkProposal,
RetrievalAudit,
ReasonCode,
ReviewItem,
)
from application.utils.librarian.section_validator import (
Expand Down Expand Up @@ -105,6 +107,40 @@ class Scaler(Protocol):
def confidence(self, logits: Sequence[float]) -> float: ...


def decide_shortlist(
scaler: Scaler,
logits: Sequence[float],
cre_ids: Sequence[str],
*,
threshold: float,
adversarial: bool = False,
update_ambiguous: bool = False,
) -> DecisionResult:
"""Score one reranked shortlist with C.3 and decide it with C.4.

C.3's confidence is the top-1 mass of ``softmax(logits / T)``, which only
means something relative to rival candidates: over a one-candidate shortlist
it is 1.0 by construction, however poor that match is. With fewer than two
candidates there is nothing to calibrate against, so the confidence is 0.0
and a lone candidate never auto-links, whatever the threshold (τ = 0
included). ``decide`` still runs first, so an empty shortlist reports
NO_CANDIDATES and the safety flags keep their precedence.
"""
confidence = scaler.confidence(logits) if len(logits) >= 2 else 0.0
result = decide(
confidence,
cre_ids,
threshold=threshold,
adversarial=adversarial,
update_ambiguous=update_ambiguous,
)
if result.decision == Decision.linked and len(logits) < 2:
return DecisionResult(
Decision.review, confidence, result.cre_ids, ReasonCode.below_threshold
)
return result


@dataclass(frozen=True)
class RunStats:
"""Counts for one pipeline run.
Expand Down Expand Up @@ -196,7 +232,7 @@ def __init__(
*,
threshold: float,
pipeline_run_id: str,
safety_guard: Optional[SafetyGuard] = None
safety_guard: Optional[SafetyGuard] = None,
) -> None:
self._source = source
self._retriever = retriever
Expand Down Expand Up @@ -236,11 +272,11 @@ def run(self, *, at: datetime) -> RunResult:
reranked = [c for c in audit.reranked if c.score_rerank is not None]
logits = [float(c.score_rerank) for c in reranked]
cre_ids = [c.cre_id for c in reranked]
confidence = self._scaler.confidence(logits) if logits else 0.0

verdict = self._safety_guard.evaluate(section)
result = decide(
confidence,
result = decide_shortlist(
self._scaler,
logits,
cre_ids,
threshold=self._threshold,
adversarial=verdict.adversarial,
Expand Down
9 changes: 5 additions & 4 deletions scripts/evaluate_librarian.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,9 @@ def calibration_set(
if audit is None:
continue
reranked = [c for c in audit.reranked if c.score_rerank is not None]
if not reranked:
# A one-candidate softmax is 1.0 whatever T is, and C.4 never links such
# a row, so it carries no signal for fitting T or measuring ECE.
if len(reranked) < 2:
continue
expected = set(row.expected.cre_ids or [])
logit_sets.append([float(c.score_rerank) for c in reranked])
Expand Down Expand Up @@ -403,7 +405,7 @@ def report_decision_accuracy(
reason codes that depend on them lag until that lands; and tuning the threshold
itself is the Week 7 experiment, so hard-gating it now would be premature.
"""
from application.utils.librarian.decision_engine import decide
from application.utils.librarian.pipeline import decide_shortlist
from application.utils.librarian.schemas import Decision

graded = [r for r in rows if r.expected.decision is not None and r.id in audits]
Expand All @@ -418,8 +420,7 @@ def report_decision_accuracy(
reranked = [c for c in audit.reranked if c.score_rerank is not None]
logits = [float(c.score_rerank) for c in reranked]
cre_ids = [c.cre_id for c in reranked]
confidence = scaler.confidence(logits) if logits else 0.0
result = decide(confidence, cre_ids, threshold=threshold)
result = decide_shortlist(scaler, logits, cre_ids, threshold=threshold)
matched = result.decision == row.expected.decision
if matched:
dec_match += 1
Expand Down