অধ্যায় 7 · মৌলিক ইভ্যাল ও রিগ্রেশন টেস্টিং
মূল্যায়ন ডেটাসেট ও নির্ধারিত টেস্ট
- পৃষ্ঠা 13 / 22
- 24 মিনিট পড়া
গত সপ্তাহে ঢাকার নাদিয়া একটা প্রম্পটের একটা লাইন বদলাল। যে উদাহরণটা সে চেষ্টা করেছিল সেটা তখনও ঠিকঠাক চলছিল, তাই সে ডিপ্লয় করে দিল। দুই দিন পর সাপোর্ট টিম দেখল, আরও তিনটা প্রশ্ন চুপচাপ ভেঙে গেছে। একটা উদাহরণে ঠিক চলা ফিচার পরের উদাহরণে ব্যর্থ হতে পারে। মূল্যায়ন (evaluation) "মনে হচ্ছে ঠিক আছে"-কে এমন সংখ্যায় বদলে দেয়, যা পরিবর্তনের আগে ও পরে তুলনা করা যায়। এই পাতায় আমরা ভিত্তি তৈরি করব: সংরক্ষিত কেস, আঙুলের ছাপ দেওয়া যায় এমন ডেটাসেট, মডেল ছাড়া চলা টেস্ট, আর রিট্রিভাল, সাইটেশন ও বিরত থাকার (abstention) পরিমাপ। কোড একটা নতুন মডিউল, evidencedesk/evals.py, যা আগের পাতাগুলোর ওপর দাঁড়িয়ে আছে। মডেলটা আবারও একটা ফেক মডেল: হাতে লেখা রিপ্লাই রান পুনরাবৃত্তিযোগ্য করে। বাস্তব মডেল কম অনুমানযোগ্য, আর ঠিক সেই কারণেই টেস্ট দরকার।
যা শিখবেন
- কীভাবে একটা মূল্যায়ন কেস তার কারণসহ সংরক্ষণ করবেন এবং কেসগুলো ভার্সন করা, ড্রিফট-চেকসহ ডেটাসেটে রাখবেন।
- কোন চেক নির্ধারিত (exact) আর কোনটাতে বিচার লাগে, এবং মডেল ছাড়া পার্সিং, রিট্রিভাল, সাইটেশন, টুল ও ফলব্যাক কীভাবে টেস্ট করবেন।
- রিট্রিভাল রিকল, সাইটেশন বৈধতা ও বিরত থাকা (abstention) কীভাবে মাপবেন, এবং মানের দিকগুলো কেন আলাদা করে স্কোর করবেন।
- পরিবর্তনের আগে কীভাবে বেসলাইন সংরক্ষণ করবেন এবং একই ডেটাসেটে দুটো কৌশল তুলনা করবেন।
মূল্যায়ন কেস নির্ধারণ করুন
একটা মূল্যায়ন কেসে থাকে একটা ইনপুট, একটা প্রত্যাশিত আচরণ এবং একটা কারণ: যে বাক্যটা ভবিষ্যতের পাঠককে বলে কেসটা কেন আছে এবং এটা ব্যর্থ হলে কী বোঝায়। কারণ না থাকলে কেউ কোনো কেস মুছতে সাহস পায় না। RAG-এর নির্ভরযোগ্যতা ও মূল্যায়নের ডেটা পাতায় আমরা RagCase পেয়েছিলাম। EvalCase সেটাকে মুড়ে নেয় এবং আরও দুই ধরনের কেস যোগ করে: tool কেস (একটা টুল কল এবং প্রত্যাশিত ফলাফল বা এরর কোড) ও format কেস (মডেলের কাঁচা রিপ্লাই এবং প্রত্যাশিত পার্সিং ফলাফল)।
# file: kit/evidencedesk/evals.py (new file)
"""Evaluation: cases, a versioned dataset with a drift check, deterministic runners and metrics, a saved baseline. No live model."""
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
from evidencedesk.chunking import chunk_corpus
from evidencedesk.citations import MIN_MARGIN, RagCase, check_citations, guarded_answer, load_rag_cases
from evidencedesk.llm import LLMRequest, LLMResponse, Usage
from evidencedesk.rag import MIN_SCORE, RecordedReplies, Retrieved, Retriever, reply
from evidencedesk.results import parse_answer
from evidencedesk.tools import ToolCall, ToolRegistry
KIT = Path(__file__).resolve().parent.parent
REASONS = { # why a RagCase matters, by its first tag (the RAG file has no reason column)
"direct": "The plain question customers ask most: a miss here is a basic failure.",
"exact_term": "An exact identifier or number: hard for embeddings.",
"paraphrase": "Different words, same meaning: retrieval beyond shared vocabulary.",
"bangla": "A Bangla question must find a Bangla document.",
"stale": "A superseded policy says something else: the old version must never answer.",
"unanswerable": "The corpus has no answer: the assistant must say so, not guess.",
}
# --- 1. One evaluation case: input, expected behaviour, and why it matters ---
class EvalCase(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
id: str
kind: Literal["rag", "tool", "format"]
input: str | dict[str, Any] # rag: the question. tool: {name, arguments}. format: {text, stop_reason}
expect: str # rag: answer | abstain. tool: ok or an error code. format: ok or a parse code
doc_ids: list[str] = [] # rag: documents that hold the answer
must_cite: bool = False
tags: list[str] = []
reason: str = Field(min_length=10) # why this case exists
@model_validator(mode="after")
def consistent(self) -> EvalCase:
if self.kind == "rag":
if self.expect not in ("answer", "abstain") or not isinstance(self.input, str):
raise ValueError("a rag case needs a question and expect answer or abstain")
if (self.expect == "answer") != bool(self.doc_ids) or (self.must_cite and self.expect != "answer"):
raise ValueError("answer needs doc_ids (and only then), and only an answer can require a citation")
elif not isinstance(self.input, dict):
raise ValueError(f"a {self.kind} case needs an object as input")
return self
@classmethod
def from_rag(cls, case: RagCase, reason: str | None = None) -> EvalCase:
return cls(id=case.id, kind="rag", input=case.question, expect="answer" if case.answerable else "abstain",
doc_ids=case.expected_doc_ids, must_cite=case.must_cite, tags=list(case.tags),
reason=reason or REASONS[case.tags[0]])ভ্যালিডেটর খারাপ কেস প্রত্যাখ্যান করে: যে RAG কেস উত্তর প্রত্যাশা করে, তাকে ডকুমেন্ট উল্লেখ করতে হবে, আর প্রতিটা কেসে অন্তত দশ অক্ষরের একটা কারণ লাগবে। from_rag বিদ্যমান ১৬টা কেস কপি না করেই রূপান্তর করে; কারণটা আসে প্রথম ট্যাগ থেকে।
from pathlib import Path
from pydantic import ValidationError
from evidencedesk.citations import load_rag_cases
from evidencedesk.evals import EvalCase
stale = EvalCase.from_rag(load_rag_cases(Path("kit/data/rag_cases.jsonl"))[8]) # rc09, the stale trap
print(stale.model_dump_json(indent=1))
try:
EvalCase(id="x1", kind="rag", input="Can I pay in instalments?", expect="abstain", reason="because")
except ValidationError as error:
print("rejected:", error.errors()[0]["msg"]){
"id": "rc09",
"kind": "rag",
"input": "Are refunds paid as store credit?",
"expect": "answer",
"doc_ids": [
"kc-returns-v3"
],
"must_cite": true,
"tags": [
"stale"
],
"reason": "A superseded policy says something else: the old version must never answer."
}
rejected: String should have at least 10 charactersনির্ধারিত টেস্ট ও বিচারনির্ভর টেস্ট আলাদা করুন
যাচাইযোগ্য ও যাচাই-অযোগ্য প্রশ্ন মেশালে টেস্ট এলোমেলোভাবে ব্যর্থ হয় অথবা কিছুই প্রমাণ করে না। নিয়মটা হলো: কোড চালানোর আগেই যদি প্রত্যাশিত মান লিখতে পারেন, তবে টেস্টটা নির্ধারিত (exact); আউটপুট পড়ে সিদ্ধান্ত নিতে হলে সেটা বিচারনির্ভর (judgment) টেস্ট।
| আউটপুট নিয়ে প্রশ্ন | ধরন | উদাহরণ |
|---|---|---|
| এটা সঠিক ফিল্ডসহ বৈধ JSON এবং স্বাভাবিকভাবে শেষ হওয়ার স্টপ কারণসহ কি না? | নির্ধারিত | format কেস (invalid_json, truncated) |
| টুল বাড়তি বা ভুল আর্গুমেন্ট প্রত্যাখ্যান করে কি না? | নির্ধারিত | tool কেস (invalid_arguments) |
| প্রত্যাশিত ডকুমেন্ট শীর্ষ ৩-এ আছে কি না? | নির্ধারিত (লেবেল মানুষের কাজ) | রিট্রিভাল রিকল |
| প্রতিটা উদ্ধৃত ID এমন চাঙ্কে মেলে কি না, যা মডেলকে দেখানো হয়েছিল? | নির্ধারিত | সাইটেশন বৈধতা |
| উত্তরহীন প্রশ্নে অ্যাসিস্ট্যান্ট উত্তর দিতে অস্বীকার করেছে কি না? | নির্ধারিত (সিদ্ধান্ত, শব্দচয়ন নয়) | বিরত থাকা |
| উত্তরটা স্পষ্ট, প্রাসঙ্গিক, প্রমাণের প্রতি বিশ্বস্ত ও ভদ্র কি না? | বিচারনির্ভর | রুব্রিক, মানুষ বা জাজ দিয়ে স্কোর করা |
নির্ধারিত টেস্ট সস্তা এবং প্রতিটা পরিবর্তনে চলে; বিচারনির্ভর টেস্ট শুধু সেখানেই ব্যবহার করুন, যা নির্ধারিত টেস্ট দেখতে পায় না। এই পাতা নির্ধারিত দিকটা বানায়; বিচারনির্ভর দিকটা বানায় রুব্রিক, রিগ্রেশন ও রিলিজ গেট।
একটা স্থির টেস্ট ডেটাসেট তৈরি করুন
ডেটাসেট তখনই কাজের, যখন এক রান থেকে আরেক রানে তা একই থাকে। আমরা kit/data/-তে দুটো ফাইল রাখি: বিদ্যমান rag_cases.jsonl (১৬টা কেস, নকল করা হয়নি) এবং নতুন eval_extra.jsonl (৬টা tool ও ৫টা format কেস, প্রতি লাইনে একটা JSON অবজেক্ট, EvalCase আকারে)। একটা ম্যানিফেস্ট (eval_manifest.json) ডেটাসেটের নাম ঠিক করে, ভার্সন দেয় এবং প্রতিটা ফাইলের কনটেন্ট হ্যাশ রাখে। লোড করার সময় হ্যাশ আবার হিসাব হয়: কোনো ফাইল বদলেছে কিন্তু ম্যানিফেস্ট বদলায়নি, এমন হলে DatasetDrift পাবেন। তাই পরিবর্তনের জন্য সচেতন একটা ধাপ লাগে: নতুন ভার্সন, নতুন ম্যানিফেস্ট।
# file: kit/evidencedesk/evals.py (add)
# --- 2. A fixed, versioned dataset: a manifest names the files and their content hashes ---
class DatasetDrift(ValueError):
"""A dataset file changed without a new version."""
@dataclass(frozen=True)
class Dataset:
name: str
version: int
sha256: str # short hash over all file hashes
cases: tuple[EvalCase, ...]
def of_kind(self, kind: str) -> list[EvalCase]:
return [c for c in self.cases if c.kind == kind]
def file_hash(path: Path) -> str:
return hashlib.sha256(path.read_bytes().replace(b"\r\n", b"\n")).hexdigest()[:16] # same hash on Windows and Linux
def write_manifest(folder: Path, name: str, version: int, files: dict[str, str]) -> Path:
"""files: {file name: 'rag' | 'eval'}. Run it on purpose, together with a version bump."""
listed = {f: {"format": fmt, "sha256": file_hash(folder / f)} for f, fmt in sorted(files.items())}
path = folder / "eval_manifest.json"
path.write_text(json.dumps({"name": name, "version": version, "files": listed}, indent=1) + "\n", encoding="utf-8")
return path
def load_dataset(manifest: Path, doc_ids: set[str] | None = None) -> Dataset:
meta = json.loads(manifest.read_text(encoding="utf-8"))
cases: list[EvalCase] = []
for fname, info in meta["files"].items():
path = manifest.parent / fname
if (found := file_hash(path)) != info["sha256"]:
raise DatasetDrift(f"{fname} changed (manifest {info['sha256']}, file {found}): bump the version, update the manifest")
if info["format"] == "rag":
cases += [EvalCase.from_rag(c) for c in load_rag_cases(path, doc_ids)]
continue
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
try:
cases.append(EvalCase.model_validate_json(line))
except ValidationError as error:
raise ValueError(f"{fname} line {number}: {error.errors()[0]['msg']}") from None
if len({c.id for c in cases}) != len(cases):
raise ValueError("case ids must be unique")
combined = "".join(f"{f}:{i['sha256']}" for f, i in meta["files"].items())
return Dataset(meta["name"], meta["version"], hashlib.sha256(combined.encode()).hexdigest()[:16], tuple(cases))হ্যাশ Windows-এর লাইন-এন্ডিং উপেক্ষা করে, তাই প্রতিটা মেশিন একই মান পায়। এটা দুর্ঘটনাবশত পরিবর্তন ধরে; লেবেল ঠিক কি না তা বলতে পারে না। ডেটাসেট তৈরি করুন (ম্যানিফেস্ট একই বাইটে আবার লেখা হয়) এবং একটা কপিতে ড্রিফট এরর ঘটান:
import json
import shutil
from evidencedesk.evals import DatasetDrift, load_dataset, write_manifest
data = Path("kit/data")
files = {"rag_cases.jsonl": "rag", "eval_extra.jsonl": "eval"}
write_manifest(data, "evidencedesk-eval", 1, files) # the same bytes as the committed manifest
dataset = load_dataset(data / "eval_manifest.json")
print(len(dataset.cases), "cases:", {k: len(dataset.of_kind(k)) for k in ("rag", "tool", "format")}, "version", dataset.version)
print({f: i["sha256"][:8] for f, i in json.loads((data / "eval_manifest.json").read_text())["files"].items()})
shutil.copytree(data, "drift")
changed = Path("drift/rag_cases.jsonl")
changed.write_text(changed.read_text(encoding="utf-8").replace("Which frame size", "What frame size"), encoding="utf-8")
try:
load_dataset(Path("drift/eval_manifest.json"))
except DatasetDrift as error:
print("drift:", error)27 cases: {'rag': 16, 'tool': 6, 'format': 5} version 1
{'eval_extra.jsonl': '72ed5a9a', 'rag_cases.jsonl': '3e0cd4d2'}
drift: rag_cases.jsonl changed (manifest 3e0cd4d2a0b6c963, file 59adee656bacd701): bump the version, update the manifestপ্রতিটা tool বা format কেস একটা লাইন, যেমন {"id": "tl02", "kind": "tool", "input": {"name": "calculator", "arguments": {...}}, "expect": "invalid_arguments", ..., "reason": "..."}; ফাইলে এগারোটাই আছে।
নির্ধারিত ইউনিট টেস্ট লিখুন
নির্ধারিত টেস্ট প্রতিটা রানে ও প্রতিটা মেশিনে একই রায় দেয়, নেটওয়ার্ক ও মডেল ছাড়াই। আগের পাতাগুলো যা বানিয়েছে, ঠিক তা-ই আপনি এভাবে টেস্ট করতে পারেন: পার্সিং, রিট্রিভাল, সাইটেশন, টুল ভ্যালিডেশন ও ফলব্যাক। এর হাতিয়ার ফেক ক্লায়েন্ট। সুইটের জন্য আমরা CaseModel যোগ করি: প্রশ্নের জন্য হাতে লেখা উত্তর থাকলে সেটা দেয়, নইলে সবচেয়ে খারাপ ক্ষেত্রের ভূমিকা নেয়: শীর্ষ প্রমাণ কপি করে এবং কখনো বিরত থাকে না। এতে উত্তরহীন কেসগুলো ফেকের ভদ্রতার নয়, আপনার গার্ডের পরীক্ষা হয়ে ওঠে। Tape মডেল কী বলেছে তা রেকর্ড করে, যাতে গার্ড কাজ করার আগেই সেটা স্কোর করা যায়।
# file: kit/evidencedesk/evals.py (add)
# --- 3. Fake models for suites (stand-ins: they prove the plumbing, not the quality of a real model) ---
class CaseModel(RecordedReplies):
"""The hand-written reply if there is one; otherwise the WORST case: copy the top evidence, never abstain."""
def complete(self, request: LLMRequest) -> LLMResponse:
question = re.search(r'<question source="user-input">\n(.*?)\n</question>', request.messages[-1].content, re.S)
if question and question.group(1) in self.replies:
return super().complete(request)
self.calls.append(request)
block = re.search(r'<evidence source="([^"]+)">\n(.*?)\n</evidence>', request.messages[-1].content, re.S)
body = reply(block.group(2)[:200], block.group(1)) if block else reply("Not stated.")
return LLMResponse(json.dumps(body), "recorded", Usage(0, 0))
class Tape:
"""Wraps a client and keeps its replies, to score what the model said before any guard acted on it."""
def __init__(self, inner) -> None:
self.inner, self.replies = inner, []
def complete(self, request: LLMRequest) -> LLMResponse:
self.replies.append(self.inner.complete(request))
return self.replies[-1]প্রয়োজনীয় বেশির ভাগ টেস্ট আগেই আছে। প্রতিটা মডিউল রক্ষা করে এমন টেস্ট বেছে নিন, যাতে ব্যর্থতা বলে দেয় কোন মডিউল খুলতে হবে:
| টেস্ট | যা রক্ষা করে |
|---|---|
test_results::test_bad_output_is_rejected_not_raised | পার্সিং (results.py) |
test_citations::test_unresolved_ids_are_split_into_outside_and_invented | সাইটেশন চেক (citations.py) |
test_tools::test_missing_extra_and_invalid_arguments | টুল ভ্যালিডেশন (tools.py) |
test_rag::test_missing_evidence_triggers_the_fallback_without_calling_the_model | ফলব্যাক ও রিট্রিভাল ফিল্টার (rag.py) |
test_evals::test_a_changed_file_is_drift_until_the_version_is_bumped | ডেটাসেট (evals.py) |
test_evals::test_baseline_is_reproducible_and_its_failures_are_the_documented_misses | পুরো পাইপলাইন |
কিটের টেস্ট ফাইল tests/test_evals.py (১৭টা টেস্ট) পুরো মডিউল চায়; বেসলাইনের অংশের পর সেটা চালানো হবে।
রিট্রিভাল রিকল মাপুন
রিট্রিভাল রিকল একটাই প্রশ্ন করে, প্রত্যাশিত ডকুমেন্ট শীর্ষ k ফলাফলের মধ্যে আছে কি না? প্রতিটা কেসে এটা হিট বা মিস; সব মিলিয়ে এটা হিটের অনুপাত। লোকাল ইনডেক্স ও রিট্রিভালের মান পাতায় recall_at_k এসেছিল। এই সংস্করণ প্রোডাকশনের একই স্ট্যাটাস ফিল্টারে (শুধু current চাঙ্ক) চলে এবং মূল্যায়ন কেস ব্যবহার করে। এটা শুধু র্যাংকিং মাপে; স্কোর থ্রেশহোল্ড অ্যাবস্টেনশন চেকের বিষয়।
প্রোভাইডার নোট (২০২৬-১০-১২ অনুযায়ী): Introduction to Information Retrieval বইটা প্রিসিশন ও রিকলকে শীর্ষ k ফলাফলে প্রয়োগ করে এবং Precision@k-কে বলে "the least stable of the commonly used evaluation measures"। Recall@k হলো শীর্ষ k-এর প্রাসঙ্গিক ডকুমেন্টকে মোট প্রাসঙ্গিক ডকুমেন্ট দিয়ে ভাগ করা। MRR (NIST TREC QA পাতার সংজ্ঞা অনুযায়ী) প্রতিটা প্রশ্নকে স্কোর করে প্রথম সঠিক উত্তরের র্যাংকের বিপরীত (1 ÷ rank) দিয়ে। প্রশ্নপ্রতি একটা প্রত্যাশিত ডকুমেন্ট থাকলে আমাদের হিট রেট Recall@k-এর সমান। বর্তমান ডকুমেন্টেশন দেখে নিন।
# file: kit/evidencedesk/evals.py (add)
# --- 4. Metrics on one question or one reply ---
def docs_in_top(retriever: Retriever, question: str, k: int = 3, where: dict | None = None) -> list[str]:
"""Documents of the top-k chunks, best first. where=None -> current chunks only (production); {} -> no filter."""
hits = retriever.index.search(question, retriever.model, k, {"status": "current"} if where is None else where)
return list(dict.fromkeys(retriever.chunks[h.passage_id].doc_id for h in hits))
def retrieval_recall(retriever: Retriever, cases: list[EvalCase], k: int = 3, where: dict | None = None) -> tuple[float, list[str]]:
"""Share of answerable rag cases whose expected document is in the top k, and the ids of the misses."""
asked = [c for c in cases if c.kind == "rag" and c.doc_ids]
missed = [c.id for c in asked if not set(c.doc_ids) & set(docs_in_top(retriever, c.input, k, where))]
return (1 - len(missed) / len(asked) if asked else 1.0), missedfrom evidencedesk.chunking import chunk_corpus
from evidencedesk.evals import docs_in_top, retrieval_recall
from evidencedesk.rag import MIN_SCORE, Retriever
# paragraph chunks, a fixed clock (build_retriever, below, wraps this)
retriever = Retriever(chunk_corpus(Path("kit/corpus")), clock=lambda: "2026-10-13T09:00:00Z", k=3, min_score=MIN_SCORE)
cases = list(dataset.cases)
for k in (1, 3):
value, missed = retrieval_recall(retriever, cases, k)
print(f"recall@{k} = {value:.3f} on 13 answerable cases; missed: {', '.join(missed)}")
for case_id in ("rc06", "rc08"):
case = next(c for c in cases if c.id == case_id)
print(f"{case_id} wanted {case.doc_ids[0]}, top 3 documents: {docs_in_top(retriever, case.input)}")
stale = [c for c in cases if "stale" in c.tags]
for label, where in (("with status filter", None), ("without filter", {})):
print(f"{label:<19} rc09 recall@1 = {retrieval_recall(retriever, stale, 1, where)[0]:.1f}, top document: {docs_in_top(retriever, stale[0].input, 1, where)}")recall@1 = 0.769 on 13 answerable cases; missed: rc02, rc06, rc08
recall@3 = 0.846 on 13 answerable cases; missed: rc06, rc08
rc06 wanted kc-returns-v3, top 3 documents: ['kc-privacy-v1', 'kc-shipping-v1', 'kc-returns-v3-bn']
rc08 wanted kc-giftcards-v1, top 3 documents: ['kc-sizing-v1', 'kc-privacy-v1', 'kc-shipping-v1']
with status filter rc09 recall@1 = 1.0, top document: ['kc-returns-v3']
without filter rc09 recall@1 = 0.0, top document: ['kc-returns-v2']k=3-তেও দুটো কেস ব্যর্থ: "How do I get my money back?" (রিটার্ন পলিসির সঙ্গে কোনো শব্দ মেলে না) এবং "voucher" প্রশ্নটা। এদের শীর্ষ ডকুমেন্ট সম্পর্কহীন, যা বলে কী ঠিক করতে হবে: এমবেডার, প্রম্পট নয়। পুরনো (stale) কেসটা ফিল্টারসহ ও ফিল্টার ছাড়া আলাদা আচরণ করে: ফিল্টার ছাড়া বাতিল (superseded) পলিসি আগে আসে, ফলে টেস্ট এমন কারণে ব্যর্থ হয়, যা প্রোডাকশন কখনো দেখে না।
সাইটেশন বৈধতার স্কোর করুন
সাইটেশন বৈধতা হলো উদ্ধৃত ID-গুলোর সেই অনুপাত, যা এমন চাঙ্কে মেলে যা মডেলকে সত্যিই দেখানো হয়েছিল। আমরা RAG-এর নির্ভরযোগ্যতা ও মূল্যায়নের ডেটা পাতার check_citations আবার ব্যবহার করি, যা না-মেলা ID-কে "আসল কিন্তু দেখানো হয়নি" ও "বানানো" ভাগে ভাগ করে।
# file: kit/evidencedesk/evals.py (add)
def citation_validity(cited: list[str], shown: list[Retrieved], known: set[str]) -> tuple[int, int]:
"""(cited ids that resolve to a chunk the model was shown, distinct cited ids)."""
report = check_citations("", cited, shown, known)
total = len(set(cited))
return total - len(report.outside) - len(report.invented), totalfrom evidencedesk.evals import citation_validity
question = "How long do I have to return a bike?"
shown, known = retriever.retrieve(question), set(retriever.chunks)
for label, cited in (("all resolve", ["kc-returns-v3:c0"]), ("one real but not shown", ["kc-returns-v3:c0", "kc-recalls-v1:c0"]),
("one invented", ["kc-returns-v3:c0", "kc-returns-v9:c0"]), ("no citation", [])):
valid, total = citation_validity(cited, shown, known)
print(f"{label:<24} {valid} of {total}")all resolve 1 of 1
one real but not shown 1 of 2
one invented 1 of 2
no citation 0 of 0ওই পাতার সীমাবদ্ধতা এখনো খাটে: মিলে যাওয়া ID মানেই সমর্থন নয়। বৈধতা ভুল ID ধরে; উদ্ধৃত টেক্সট দাবিটাকে সমর্থন করে কি না তা প্রমাণ করে না।
বিরত থাকার আচরণ মূল্যায়ন করুন
কিছু প্রশ্নের উত্তর কর্পাস থেকে দেওয়া যায় না; সঠিক আচরণ হলো তা বলে দেওয়া। বিরত থাকা একটা সিদ্ধান্ত, তাই নির্ধারিতভাবে টেস্ট করা যায়, এবং এটা দুই দিকে ব্যর্থ হয়: উত্তরহীন প্রশ্নের উত্তর দেওয়া (হ্যালুসিনেশনের ঝুঁকি) অথবা উত্তরযোগ্য প্রশ্নে বিরত থাকা (অকেজো অ্যাসিস্ট্যান্ট)। ২×২ টেবিল দুটোই দেখায়। রানার একটা কেসে চারটা চেক চালায় (abstention, retrieved, citations_resolve, cites_expected); সবগুলো পাস করলেই কেস পাস। run_suite টাইমস্ট্যাম্প ছাড়া সাধারণ JSON-যোগ্য ডেটা ফেরত দেয়, তাই রান বাইট পর্যন্ত পুনরাবৃত্তিযোগ্য।
# file: kit/evidencedesk/evals.py (add)
# --- 5. Running cases ---
@dataclass(frozen=True)
class CaseResult:
id: str
kind: str
checks: dict[str, bool]
detail: str = ""
valid: int = 0 # cited ids that resolved
cited: int = 0 # distinct cited ids
@property
def passed(self) -> bool:
return all(self.checks.values())
def run_rag_case(case: EvalCase, retriever: Retriever, client, k: int = 3, min_margin: float = MIN_MARGIN) -> CaseResult:
tape = Tape(client)
answer = guarded_answer(tape, retriever, case.input, min_margin)
checks = {"abstention": answer.abstained == (case.expect == "abstain")}
if case.doc_ids:
checks["retrieved"] = bool(set(case.doc_ids) & set(docs_in_top(retriever, case.input, k)))
cited: list[str] = []
if tape.replies and (outcome := parse_answer(tape.replies[-1])).ok:
cited = list(outcome.value.cited_ids)
valid, total = citation_validity(cited, retriever.retrieve(case.input), set(retriever.chunks))
if total:
checks["citations_resolve"] = valid == total
if case.must_cite and not answer.abstained:
checks["cites_expected"] = bool(answer.citations) and all(c.doc_id in case.doc_ids for c in answer.citations)
return CaseResult(case.id, "rag", dict(sorted(checks.items())), answer.reason, valid, total)
def run_tool_case(case: EvalCase, registry: ToolRegistry) -> CaseResult:
result = registry.execute(ToolCall(case.id, case.input["name"], case.input["arguments"]))
got = "ok" if result.ok else result.error["code"]
return CaseResult(case.id, "tool", {"outcome": got == case.expect}, got)
def run_format_case(case: EvalCase) -> CaseResult:
reply_ = LLMResponse(case.input["text"], "recorded", Usage(0, 0), case.input.get("stop_reason", "end"))
got = parse_answer(reply_).code
return CaseResult(case.id, "format", {"outcome": got == case.expect}, got)
def build_retriever(corpus: Path, strategy: str = "paragraph", min_score: float = MIN_SCORE, k: int = 3) -> Retriever:
return Retriever(chunk_corpus(corpus, strategy), clock=lambda: "2026-10-13T09:00:00Z", k=k, min_score=min_score)
def _share(done: int, total: int) -> float | None:
return round(done / total, 3) if total else None
def run_suite(dataset: Dataset, retriever: Retriever, client, registry: ToolRegistry, k: int = 3, min_margin: float = MIN_MARGIN,
notes: dict[str, Any] | None = None) -> dict[str, Any]:
"""Run every case. The result is plain JSON-able data: the same input gives the same bytes."""
results = [run_rag_case(c, retriever, client, k, min_margin) if c.kind == "rag" else run_tool_case(c, registry)
if c.kind == "tool" else run_format_case(c) for c in dataset.cases]
rag, answerable = [r for r in results if r.kind == "rag"], {c.id for c in dataset.cases if c.doc_ids}
table = {row: {"answered": 0, "abstained": 0} for row in ("answerable", "unanswerable")}
for r in rag:
table["answerable" if r.id in answerable else "unanswerable"]["answered" if r.detail == "ok" else "abstained"] += 1
passed = lambda kind=None: _share(sum(r.passed for r in results if kind in (None, r.kind)), sum(kind in (None, r.kind) for r in results))
metrics = {"recall_at_k": round(retrieval_recall(retriever, list(dataset.cases), k)[0], 3),
"citation_validity": _share(sum(r.valid for r in rag), sum(r.cited for r in rag)),
"abstention_correct": _share(sum(r.checks["abstention"] for r in rag), len(rag)),
"tool_pass": passed("tool"), "format_pass": passed("format"), "pass_rate": passed()}
config = {"k": k, "min_score": retriever.min_score, "min_margin": min_margin, "chunks": len(retriever.chunks), **(notes or {})}
return {"dataset": {"name": dataset.name, "version": dataset.version, "sha256": dataset.sha256}, "config": config,
"metrics": metrics, "confusion": table,
"cases": {r.id: {"passed": r.passed, "checks": r.checks, "detail": r.detail} for r in results},
"failures": sorted(r.id for r in results if not r.passed)}from evidencedesk.evals import CaseModel, run_suite
from evidencedesk.tools import default_registry
from tests.test_citations import REPLIES # hand-written replies from RAG Reliability and Evaluation Data
registry = default_registry(Path("kit/corpus"))
client = CaseModel({c.input: REPLIES[c.id] for c in dataset.cases if c.id in REPLIES})
result = run_suite(dataset, retriever, client, registry, notes={"chunking": "paragraph"})
print(f"{'':<14}{'answered':>9}{'abstained':>10}")
for row, counts in result["confusion"].items():
print(f"{row:<14}{counts['answered']:>9}{counts['abstained']:>10}")
print("failures:", {i: result["cases"][i]["detail"] for i in result["failures"]})
worst = run_suite(dataset, retriever, CaseModel({}), registry) # a model that never abstains
print("worst-case model, unanswerable:", worst["confusion"]["unanswerable"]) answered abstained
answerable 9 4
unanswerable 0 3
failures: {'rc03': 'weak', 'rc06': 'weak', 'rc08': 'ambiguous', 'rc13': 'ambiguous'}
worst-case model, unanswerable: {'answered': 0, 'abstained': 3}কোনো উত্তরহীন প্রশ্নের উত্তর দেওয়া হয়নি, সবচেয়ে খারাপ ক্ষেত্রের মডেলেও না: রিট্রিভাল গার্ড মডেল ডাকার আগেই তাদের থামিয়ে দেয়। দাম দিতে হয় অন্য সারিতে: ৪টা উত্তরযোগ্য প্রশ্ন ফিরিয়ে দেওয়া হয়েছে। এগুলো RAG-এর নির্ভরযোগ্যতা ও মূল্যায়নের ডেটা পাতার চারটা পরিচিত মিস (rc03, rc06, rc08, rc13); ডেটাসেট ইচ্ছা করেই এগুলো পুনরুৎপাদন করে। নিরাপদ ব্যর্থতা, কিন্তু তবু ব্যর্থতা। এটা একটা স্মোক টেস্ট: ১৬টা RAG কেস দেখায় একটা নিয়ম কাজ করছে বা ভাঙছে; এগুলো দিয়ে সিস্টেম র্যাংক করা বা ভরসাযোগ্য শতাংশ পাওয়া যায় না, আর রিপ্লাইগুলো হাতে লেখা।
রুব্রিকের সঙ্গে আউটপুট তুলনা করুন
নির্ধারিত চেক বলতে পারে না উত্তরটা ভালো কি না; একজন মানুষ (পরে একটা মডেল) রুব্রিক ধরে রেটিং দেয়। আপাতত একটাই নিয়ম: প্রাসঙ্গিকতা, প্রমাণের ব্যবহার ও ফরম্যাট মানা আলাদা আলাদাভাবে রেট করুন। একটা মিলিত স্কোর লুকিয়ে ফেলে কোন অংশ ব্যর্থ হয়েছে। Scores তিনটা রেটিং (০, ১ বা ২) রাখে এবং ইচ্ছা করেই কোনো মোট রাখে না।
# file: kit/evidencedesk/evals.py (add)
@dataclass(frozen=True)
class Scores:
"""A manual rubric in miniature: three judgments, each 0 (fails), 1 (partly) or 2 (meets), kept apart on purpose."""
relevance: int
evidence_use: int
format: int
def __post_init__(self) -> None:
if any(v not in (0, 1, 2) for v in asdict(self).values()):
raise ValueError("each score is 0, 1 or 2")from evidencedesk.evals import Scores
rated = { # one human rater, three answers to "How long do I have to return a bike?"
"30 days, cites the returns policy": Scores(relevance=2, evidence_use=2, format=2),
"30 days, no citation": Scores(relevance=2, evidence_use=0, format=2),
"correct text, but not the JSON contract": Scores(relevance=2, evidence_use=2, format=0),
}
for answer, s in rated.items():
print(f"relevance={s.relevance} evidence_use={s.evidence_use} format={s.format} {answer}")relevance=2 evidence_use=2 format=2 30 days, cites the returns policy
relevance=2 evidence_use=0 format=2 30 days, no citation
relevance=2 evidence_use=2 format=0 correct text, but not the JSON contractদ্বিতীয় ও তৃতীয় উত্তর দুটোই নম্বর হারায়, ভিন্ন ভিন্ন কারণে, যার সমাধানও ভিন্ন। পর্যবেক্ষণযোগ্য মানদণ্ডসহ যথাযথ রুব্রিক লেখা পরের পাতার কাজ।
প্রম্পট বদলানোর আগে বেসলাইন চালান
প্রম্পট, চাঙ্কিং নিয়ম বা থ্রেশহোল্ড বদলানোর আগে সুইট চালিয়ে ফলাফল সংরক্ষণ করুন; নইলে শুধু বলতে পারবেন নতুন সংখ্যাগুলো ঠিকই দেখাচ্ছে। ফাইলে থাকে ডেটাসেটের নাম, ভার্সন ও হ্যাশ, কনফিগারেশন, মেট্রিক এবং প্রতিটা কেসের ফলাফল। এটা কোডের সঙ্গে কমিট করুন (git add kit/data/baseline_v1.json) এবং এর পরিবর্তন কোডের মতোই রিভিউ করুন।
# file: kit/evidencedesk/evals.py (add)
# --- 6. Saved results (the baseline) ---
def save_results(results: dict[str, Any], path: Path) -> None:
path.write_text(json.dumps(results, indent=1, sort_keys=True, ensure_ascii=False) + "\n", encoding="utf-8")
def load_results(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))from evidencedesk.evals import load_results, save_results
path = Path("kit/data/baseline_v1.json")
save_results(result, path)
saved = load_results(path)
print({k: saved["dataset"][k] for k in ("name", "version")}, saved["config"])
print(saved["metrics"])
rerun = run_suite(dataset, retriever, client, registry, notes={"chunking": "paragraph"})
save_results(rerun, Path("again.json"))
print("second run identical, byte for byte:", Path("again.json").read_bytes() == path.read_bytes()){'name': 'evidencedesk-eval', 'version': 1} {'chunking': 'paragraph', 'chunks': 36, 'k': 3, 'min_margin': 0.05, 'min_score': 0.3}
{'abstention_correct': 0.75, 'citation_validity': 1.0, 'format_pass': 1.0, 'pass_rate': 0.852, 'recall_at_k': 0.846, 'tool_pass': 1.0}
second run identical, byte for byte: Trueবেসলাইন তার চারটা পরিচিত ব্যর্থতা রেখে দেয়: এটা আজকের সৎ চিত্র, এবং পরবর্তী পরিবর্তন এর বিপরীতে বিচার করা হয়।
এখন evals.py সম্পূর্ণ, তাই কিটের tests/test_evals.py (১৭টা টেস্ট) আগের চারটা ফাইলের সঙ্গে চালান; sed pytest-এর ঘড়ির সেকেন্ড বাদ দেয়:
python -m pytest -q kit/tests/test_evals.py kit/tests/test_rag.py kit/tests/test_tools.py kit/tests/test_results.py kit/tests/test_citations.py | sed 's/ in [0-9.]*s//'........................................................................ [ 88%]
......... [100%]
81 passedঅনুশীলনী: একটা ছোট রিগ্রেশন সুইট বানান
একই ডেটাসেট দুটো রিট্রিভাল কৌশলে চালান: ডিফল্ট (MIN_SCORE 0.3, মার্জিন 0.05) এবং শিথিল কৌশল (0.2, মার্জিন চেক নেই)।
from evidencedesk.evals import build_retriever
options = {"default": (0.3, 0.05), "lenient": (0.2, 0.0)} # (min_score, min_margin)
runs = {name: run_suite(dataset, build_retriever(Path("kit/corpus"), min_score=score), client, registry, min_margin=margin,
notes={"strategy": name}) for name, (score, margin) in options.items()}
a, b = runs["default"], runs["lenient"]
for metric in a["metrics"]:
print(f"{metric:<20}{a['metrics'][metric]:>8}{b['metrics'][metric]:>8}")
print("unanswerable answered:", a["confusion"]["unanswerable"]["answered"], "->", b["confusion"]["unanswerable"]["answered"])
print("new failures:", sorted(set(b["failures"]) - set(a["failures"])))recall_at_k 0.846 0.846
citation_validity 1.0 0.875
abstention_correct 0.75 0.688
tool_pass 1.0 1.0
format_pass 1.0 1.0
pass_rate 0.852 0.815
unanswerable answered: 0 -> 3
new failures: ['rc14', 'rc15', 'rc16']শিথিল সেটিং আরও দুটো উত্তরযোগ্য প্রশ্নের উত্তর দেয়, কিন্তু তিনটা উত্তরহীন প্রশ্নেরই এখন উত্তর দেওয়া হয় (সবচেয়ে খারাপ ক্ষেত্রের মডেল উত্তর দিয়ে ফেলে) এবং সাইটেশন বৈধতা কমে যায়। রিকল একই, তাই শুধু রিকল মাপলে সমস্যা অদৃশ্য থাকে। শিক্ষাটা pytest-এ জমিয়ে রাখুন। এই ফাইল আপনার (কিটের অংশ নয়); শিথিল কেসটা xfail(strict=True): এটা একটা পরিচিত রিগ্রেশন নথিবদ্ধ করে এবং ডিফল্ট কখনো ব্যর্থ হলে, কিংবা রিগ্রেশনটা আর না ঘটলে, স্পষ্টভাবে ব্যর্থ হয়।
# file: kit/tests/test_lab_regression.py
from pathlib import Path
import pytest
from test_citations import REPLIES
from evidencedesk.evals import CaseModel, build_retriever, load_dataset, run_suite
from evidencedesk.tools import default_registry
CORPUS = Path(__file__).resolve().parent.parent / "corpus"
DATASET = load_dataset(CORPUS.parent / "data" / "eval_manifest.json")
CLIENT = CaseModel({c.input: REPLIES[c.id] for c in DATASET.cases if c.id in REPLIES})
STRATEGIES = {"default": (0.3, 0.05), "lenient": (0.2, 0.0)} # (min_score, min_margin)
KNOWN = pytest.mark.xfail(strict=True, reason="known regression: the lenient setting answers the unanswerable")
def run(name):
score, margin = STRATEGIES[name]
return run_suite(DATASET, build_retriever(CORPUS, min_score=score), CLIENT, default_registry(CORPUS), min_margin=margin)
@pytest.mark.parametrize("name", ["default", pytest.param("lenient", marks=KNOWN)])
def test_critical_unanswerable_questions_are_never_answered(name):
assert run(name)["confusion"]["unanswerable"]["answered"] == 0python -m pytest -q kit/tests/test_lab_regression.py | sed 's/ in [0-9.]*s//'.x [100%]
1 passed, 1 xfailedএকটা পাস ও একটা প্রত্যাশিত ব্যর্থতা: ডিফল্ট টিকে আছে, আর শিথিল রিগ্রেশন ধরা পড়ে নথিবদ্ধ হয়েছে।
কাজের জায়গায় এটা কোথায় দেখা যায়
বোগোতার মাতেও একটা সাপোর্ট অ্যাসিস্ট্যান্ট দেখভাল করে। রিট্রিভাল সেটিং বা প্রম্পটের প্রতিটা পরিবর্তন পুল রিকোয়েস্টের মধ্য দিয়ে যায়; CI প্রায় ৪০টা সংরক্ষিত প্রশ্ন কমিট করা বেসলাইনের বিপরীতে চালায়, আর রিভিউয়ার কোডের ডিফের পাশে মেট্রিকের পরিবর্তন পড়ে। কেউ সংখ্যা ভালো দেখাতে কেস বদলালে ডেটাসেটের ভার্সন ও হ্যাশ তা ধরিয়ে দেয়। এটা একটা দৃষ্টান্ত, বাস্তব কেস স্টাডি নয়।
সাধারণ ভুল
- কারণ ছাড়া কেস। কেউ জানে না এর ব্যর্থতার মানে কী বা এটা মুছে ফেলা যায় কি না। কারণকে বাধ্যতামূলক ফিল্ড করুন।
- ডেটাসেট ও কোড একসঙ্গে বদলানো। সংখ্যা দুই কারণে নড়ে। আগে ডেটাসেটের ভার্সন বাড়ান, তারপর তুলনা করুন।
- প্রোডাকশন ফিল্টার ছাড়া রিট্রিভাল মাপা। বাতিল ডকুমেন্ট আগে আসে এবং মেট্রিক এমন সিস্টেম বর্ণনা করে, যা আপনি চালান না।
- একটা মিলিত মানের স্কোর। ভালো ফরম্যাট খারাপ প্রমাণ-ব্যবহার লুকিয়ে ফেলে। দিকগুলো আলাদা রাখুন।
- ১৬টা কেসকে বেঞ্চমার্ক ভাবা। এরা বাগ ধরে ও দিক দেখায়; হাতে লেখা রিপ্লাই প্লাম্বিং (কোডের জোড়া ঠিক আছে কি না) প্রমাণ করে, মডেলের মান নয়।
নিজে চেষ্টা করুন
- সহজ: টুল চুক্তি ও এক্সিকিউশন পাতাটা আবার দেখুন। ডেটাসেটের একটা কপিতে এমন একটা tool কেস যোগ করুন, যা প্রত্যাশা করে ক্যালকুলেটর
2 ** 3প্রত্যাখ্যান করবে (এরর কোডbad_expression)। ম্যানিফেস্ট হালনাগাদের আগে ও পরে কী ঘটে? - মাঝারি: লোকাল ইনডেক্স ও রিট্রিভালের মান পাতাটা আবার দেখুন। একই লেবেলে k=1 ও k=3-তে এর
recall_at_kএবংretrieval_recallতুলনা করুন। একটা k-তে কেন তারা আলাদা? - কঠিন: RAG ইনজেশন ও সাইটেশন প্রবাহ পাতাটা আবার দেখুন (চাঙ্কিং কৌশল)। প্যারাগ্রাফ চাঙ্ক ও নির্দিষ্ট-আকারের চাঙ্ক দিয়ে সুইট চালান। কোন মেট্রিক বদলায়, কোনটা বদলায় না, এবং তুলনাটা ন্যায্য কি না?
উত্তর
সহজ। একটা কপিতে একটা JSON লাইন যোগ করে লোড করুন: ম্যানিফেস্ট হালনাগাদ ছাড়া DatasetDrift পাবেন; ভার্সন 2 দিয়ে write_manifest চালালে ২৮টা কেস লোড হয়।
from evidencedesk.evals import run_tool_case
shutil.copytree(data, "easy")
line = {"id": "tl07", "kind": "tool", "input": {"name": "calculator", "arguments": {"expression": "2 ** 3"}}, "expect": "bad_expression",
"tags": ["tool"], "reason": "Powers could build huge numbers, so the calculator must refuse them."}
with open("easy/eval_extra.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(line) + "\n")
try:
load_dataset(Path("easy/eval_manifest.json"))
except DatasetDrift:
print("drift caught: the manifest was not updated")
write_manifest(Path("easy"), "evidencedesk-eval", 2, files)
bigger = load_dataset(Path("easy/eval_manifest.json"))
new = next(c for c in bigger.cases if c.id == "tl07")
print(f"version {bigger.version}, {len(bigger.cases)} cases, tl07:", run_tool_case(new, registry).detail)drift caught: the manifest was not updated
version 2, 28 cases, tl07: bad_expressionপাস চেকলিস্ট: ড্রিফট ধরা পড়েছে; ভার্সন বেড়েছে; নতুন কেসের কারণ আছে এবং সেটা পাস করে।
মাঝারি। কিটের recall_at_k সব চাঙ্কে খোঁজে; আমাদেরটা শুধু current চাঙ্কে খোঁজে।
from evidencedesk.index import recall_at_k
labels = [(c.input, c.doc_ids[0]) for c in cases if c.kind == "rag" and c.doc_ids]
for k in (1, 3):
plain, _ = recall_at_k(retriever.index, retriever.model, labels, k)
print(f"k={k}: recall_at_k (no filter) {plain:.3f}, retrieval_recall (current only) {retrieval_recall(retriever, cases, k)[0]:.3f}")k=1: recall_at_k (no filter) 0.692, retrieval_recall (current only) 0.769
k=3: recall_at_k (no filter) 0.846, retrieval_recall (current only) 0.846তারা শুধু k=1-এ আলাদা: ফিল্টার ছাড়া পুরনো (stale) প্রশ্নে বাতিল পলিসি আগে আসে; k=3-তে current পলিসিও তালিকায় থাকে। পাস চেকলিস্ট: দুই k-এর জন্য দুটো সংখ্যা; স্টেইল কেসটা কারণ হিসেবে উল্লেখ।
কঠিন। শুধু চাঙ্কিং কৌশল বদলান:
for strategy in ("paragraph", "fixed"):
r = build_retriever(Path("kit/corpus"), strategy)
res = run_suite(dataset, r, client, registry, notes={"chunking": strategy})
m = res["metrics"]
print(f"{strategy:<10} chunks={res['config']['chunks']} recall={m['recall_at_k']} citation_validity={m['citation_validity']} pass_rate={m['pass_rate']}")paragraph chunks=36 recall=0.846 citation_validity=1.0 pass_rate=0.852
fixed chunks=38 recall=0.846 citation_validity=0.889 pass_rate=0.815ডকুমেন্ট-স্তরের রিকল একই, কিন্তু সাইটেশন বৈধতা কমে এবং সঙ্গে পাস রেটও: ঠিক একটা কেস বদলায়, rc09, কারণ unknown_citation। এর হাতে লেখা রিপ্লাই প্যারাগ্রাফ চাঙ্ক kc-returns-v3:c2 উদ্ধৃত করে, যা নির্দিষ্ট-আকারের কৌশল রিট্রিভ করে না। টেস্টটা একটা চাঙ্কিংয়ে বাঁধা, তাই এই তুলনা কৌশলকে ফেকের চাঙ্ক ID-র সঙ্গে মিশিয়ে ফেলে। ন্যায্য তুলনায় প্রতি কৌশলের জন্য আলাদা রিপ্লাই (বা বাস্তব মডেল) লাগে। পাস চেকলিস্ট: শুধু চাঙ্কিং বদলেছে; রিকল অপরিবর্তিত; একমাত্র নতুন ব্যর্থতার কারণ খুঁজে পাওয়া গেছে।
সারসংক্ষেপ
- কেস ইনপুট, প্রত্যাশিত আচরণ ও কারণ রাখে; ভার্সন করা, হ্যাশ করা ডেটাসেট ড্রিফটকে এররে পরিণত করে।
- নির্ধারিত টেস্ট পার্সিং, ID, টুল, রিট্রিভাল, সাইটেশন ও বিরত থাকা ঢাকে; বিচার যায় রুব্রিকে, প্রতিটা দিক আলাদা করে স্কোর হয়ে।
- প্রোডাকশনের পথে মাপুন: স্ট্যাটাস ফিল্টারসহ রিকল, মডেল যা দেখেছে তার বিপরীতে বৈধতা, ২×২ টেবিলে বিরত থাকা।
- কিছু বদলানোর আগে ব্যর্থতাসহ বেসলাইন সংরক্ষণ করুন; pytest-এ একই ডেটাসেটে কৌশল তুলনা করুন।
- ষোলোটা কেস ও একটা ফেক মডেল প্লাম্বিং (কোডের জোড়া) ঠিক আছে কি না আর দিক কেমন, তা দেখায়; মান দেখায় না।
এরপর: রুব্রিক, রিগ্রেশন ও রিলিজ গেট বিচারকে স্পষ্ট রুব্রিকে রূপ দেয়, আউটপুট পাশাপাশি তুলনা করে এবং এমন একটা গেট বসায়, যা কোনো গুরুত্বপূর্ণ চেক পিছিয়ে গেলে বিল্ড ব্যর্থ করে।